add support for different bandwidth limits based on client IP

This commit is contained in:
Nicola Murino 2021-12-10 18:43:26 +01:00
parent c153330ab8
commit 0bb141960f
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
18 changed files with 575 additions and 56 deletions

View file

@ -31,7 +31,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
- [Data At Rest Encryption](./docs/dare.md).
- Dynamic user modification before login via external programs/HTTP API.
- Quota support: accounts can have individual quota expressed as max total size and/or max number of files.
- Bandwidth throttling, with distinct settings for upload and download.
- Bandwidth throttling, with distinct settings for upload and download and overrides based on the client IP address.
- Per-protocol [rate limiting](./docs/rate-limiting.md) is supported and can be optionally connected to the built-in defender to automatically block hosts that repeatedly exceed the configured limit.
- Per user maximum concurrent sessions.
- Per user and global IP filters: login can be restricted to specific ranges of IP addresses or to a specific IP address.

View file

@ -47,6 +47,7 @@ func NewBaseConnection(id, protocol, localAddr, remoteAddr string, user dataprov
if util.IsStringInSlice(protocol, supportedProtocols) {
connID = fmt.Sprintf("%v_%v", protocol, id)
}
user.UploadBandwidth, user.DownloadBandwidth = user.GetBandwidthForIP(util.GetIPFromRemoteAddress(remoteAddr), connID)
return &BaseConnection{
ID: connID,
User: user,

View file

@ -1619,22 +1619,48 @@ func checkEmptyFiltersStruct(user *User) {
}
}
func validateFilters(user *User) error {
checkEmptyFiltersStruct(user)
func validateIPFilters(user *User) error {
user.Filters.DeniedIP = util.RemoveDuplicates(user.Filters.DeniedIP)
for _, IPMask := range user.Filters.DeniedIP {
_, _, err := net.ParseCIDR(IPMask)
if err != nil {
return util.NewValidationError(fmt.Sprintf("could not parse denied IP/Mask %#v : %v", IPMask, err))
return util.NewValidationError(fmt.Sprintf("could not parse denied IP/Mask %#v: %v", IPMask, err))
}
}
user.Filters.AllowedIP = util.RemoveDuplicates(user.Filters.AllowedIP)
for _, IPMask := range user.Filters.AllowedIP {
_, _, err := net.ParseCIDR(IPMask)
if err != nil {
return util.NewValidationError(fmt.Sprintf("could not parse allowed IP/Mask %#v : %v", IPMask, err))
return util.NewValidationError(fmt.Sprintf("could not parse allowed IP/Mask %#v: %v", IPMask, err))
}
}
return nil
}
func validateBandwidthLimitFilters(user *User) error {
for idx, bandwidthLimit := range user.Filters.BandwidthLimits {
user.Filters.BandwidthLimits[idx].Sources = util.RemoveDuplicates(bandwidthLimit.Sources)
if err := bandwidthLimit.Validate(); err != nil {
return err
}
if bandwidthLimit.DownloadBandwidth < 0 {
user.Filters.BandwidthLimits[idx].DownloadBandwidth = 0
}
if bandwidthLimit.UploadBandwidth < 0 {
user.Filters.BandwidthLimits[idx].UploadBandwidth = 0
}
}
return nil
}
func validateFilters(user *User) error {
checkEmptyFiltersStruct(user)
if err := validateIPFilters(user); err != nil {
return err
}
if err := validateBandwidthLimitFilters(user); err != nil {
return err
}
user.Filters.DeniedLoginMethods = util.RemoveDuplicates(user.Filters.DeniedLoginMethods)
if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) {
return util.NewValidationError("invalid denied_login_methods")
@ -1664,6 +1690,7 @@ func validateFilters(user *User) error {
return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
}
}
return validateFiltersPatternExtensions(user)
}
@ -1728,6 +1755,12 @@ func validateBaseParams(user *User) error {
if !filepath.IsAbs(user.HomeDir) {
return util.NewValidationError(fmt.Sprintf("home_dir must be an absolute path, actual value: %v", user.HomeDir))
}
if user.DownloadBandwidth < 0 {
user.DownloadBandwidth = 0
}
if user.UploadBandwidth < 0 {
user.UploadBandwidth = 0
}
return nil
}

View file

@ -883,6 +883,28 @@ func (u *User) GetSignature() string {
return base64.StdEncoding.EncodeToString(signature[:])
}
// GetBandwidthForIP returns the upload and download bandwidth for the specified IP
func (u *User) GetBandwidthForIP(clientIP, connectionID string) (int64, int64) {
if len(u.Filters.BandwidthLimits) > 0 {
ip := net.ParseIP(clientIP)
if ip != nil {
for _, bwLimit := range u.Filters.BandwidthLimits {
for _, source := range bwLimit.Sources {
_, ipNet, err := net.ParseCIDR(source)
if err == nil {
if ipNet.Contains(ip) {
logger.Debug(logSender, connectionID, "override bandwidth limit for ip %#v, upload limit: %v KB/s, download limit: %v KB/s",
clientIP, bwLimit.UploadBandwidth, bwLimit.DownloadBandwidth)
return bwLimit.UploadBandwidth, bwLimit.DownloadBandwidth
}
}
}
}
}
}
return u.UploadBandwidth, u.DownloadBandwidth
}
// IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr.
// If AllowedIP is defined only the specified IP/Mask can login.
// If DeniedIP is defined the specified IP/Mask cannot login.
@ -1150,7 +1172,7 @@ func (u *User) getACopy() User {
filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
filters.WebClient = make([]string, len(u.Filters.WebClient))
copy(filters.WebClient, u.Filters.WebClient)
filters.RecoveryCodes = make([]sdk.RecoveryCode, 0)
filters.RecoveryCodes = make([]sdk.RecoveryCode, 0, len(u.Filters.RecoveryCodes))
for _, code := range u.Filters.RecoveryCodes {
if code.Secret == nil {
code.Secret = kms.NewEmptySecret()
@ -1160,6 +1182,17 @@ func (u *User) getACopy() User {
Used: code.Used,
})
}
filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(u.Filters.BandwidthLimits))
for _, limit := range u.Filters.BandwidthLimits {
bwLimit := sdk.BandwidthLimit{
UploadBandwidth: limit.UploadBandwidth,
DownloadBandwidth: limit.DownloadBandwidth,
Sources: make([]string, 0, len(limit.Sources)),
}
bwLimit.Sources = make([]string, len(limit.Sources))
copy(bwLimit.Sources, limit.Sources)
filters.BandwidthLimits = append(filters.BandwidthLimits, bwLimit)
}
return User{
BaseUser: sdk.BaseUser{

14
go.mod
View file

@ -7,8 +7,8 @@ require (
github.com/Azure/azure-storage-blob-go v0.14.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/aws/aws-sdk-go v1.42.20
github.com/cockroachdb/cockroach-go/v2 v2.2.4
github.com/aws/aws-sdk-go v1.42.22
github.com/cockroachdb/cockroach-go/v2 v2.2.5
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
github.com/fclairamb/ftpserverlib v0.16.0
github.com/fclairamb/go-log v0.1.0
@ -52,10 +52,10 @@ require (
go.uber.org/automaxprocs v1.4.0
gocloud.dev v0.24.0
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e
golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d
golang.org/x/net v0.0.0-20211209124913-491a49abca63
golang.org/x/sys v0.0.0-20211210111614-af8b64212486
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.61.0
google.golang.org/api v0.62.0
google.golang.org/grpc v1.42.0
google.golang.org/protobuf v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.0.0
@ -129,7 +129,7 @@ require (
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20211207154714-918901c715cf // indirect
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
gopkg.in/ini.v1 v1.66.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
@ -140,5 +140,5 @@ replace (
github.com/fclairamb/ftpserverlib => github.com/drakkan/ftpserverlib v0.0.0-20211107071448-34ff70e85dfb
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20211203175531-87c7ca02d2a9
golang.org/x/net => github.com/drakkan/net v0.0.0-20211203175337-bdbe03411e23
golang.org/x/net => github.com/drakkan/net v0.0.0-20211210172952-3f0f9446f73f
)

26
go.sum
View file

@ -33,6 +33,7 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@ -136,8 +137,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.20 h1:nQkkmTWK5N2Ao1iVzoOx1HTIxwbSWErxyZ1eiwLJWc4=
github.com/aws/aws-sdk-go v1.42.20/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.22 h1:EwcM7/+Ytg6xK+jbeM2+f9OELHqPiEiEKetT/GgAr7I=
github.com/aws/aws-sdk-go v1.42.22/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
@ -192,8 +193,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/cockroach-go/v2 v2.2.4 h1:VuiBJKut2Imgrzl+TNk+U5+GxLOh3hnIFxU0EzjTCnI=
github.com/cockroachdb/cockroach-go/v2 v2.2.4/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
github.com/cockroachdb/cockroach-go/v2 v2.2.5 h1:tfPdGHO5YpmrpN2ikJZYpaSGgU8WALwwjH3s+msiTQ0=
github.com/cockroachdb/cockroach-go/v2 v2.2.5/go.mod h1:q4ZRgO6CQpwNyEvEwSxwNrOSVchsmzrBnAv3HuZ3Abc=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@ -224,8 +225,8 @@ github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHP
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
github.com/drakkan/ftpserverlib v0.0.0-20211107071448-34ff70e85dfb h1:cT/w4XStm7m022JgVqmrXZLcZ4UjoUER1VW5/5gd6ec=
github.com/drakkan/ftpserverlib v0.0.0-20211107071448-34ff70e85dfb/go.mod h1:fBiQ19WDhtvKArMu0Pifg71k+0xqRYn+F0d9AsjkZw8=
github.com/drakkan/net v0.0.0-20211203175337-bdbe03411e23 h1:Ocx33vj+hqabIb/Adh03t4OWIB589lVpWvOODTADbBc=
github.com/drakkan/net v0.0.0-20211203175337-bdbe03411e23/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
github.com/drakkan/net v0.0.0-20211210172952-3f0f9446f73f h1:8XuTk84FMb6DGc5MxmPkswjO5m4XFqGoZomO/Q8CUwQ=
github.com/drakkan/net v0.0.0-20211210172952-3f0f9446f73f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU=
github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@ -990,8 +991,9 @@ golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1119,8 +1121,9 @@ google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqiv
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
google.golang.org/api v0.61.0 h1:TXXKS1slM3b2bZNJwD5DV/Tp6/M2cLzLOLh9PjDhrw8=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.62.0 h1:PhGymJMXfGBzc4lBRmrx9+1w4w2wEzURHNGF/sD/xGc=
google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1196,9 +1199,11 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211207154714-918901c715cf h1:PSEM+IQFb9xdsj2CGhfqUTfsZvF8DScCVP1QZb2IiTQ=
google.golang.org/genproto v0.0.0-20211207154714-918901c715cf/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@ -1225,6 +1230,7 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=

View file

@ -502,6 +502,101 @@ func TestBasicUserHandling(t *testing.T) {
assert.NoError(t, err)
}
func TestUserBandwidthLimit(t *testing.T) {
u := getTestUser()
u.UploadBandwidth = 128
u.DownloadBandwidth = 96
u.Filters.BandwidthLimits = []sdk.BandwidthLimit{
{
Sources: []string{"1"},
},
}
_, resp, err := httpdtest.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err, string(resp))
assert.Contains(t, string(resp), "Validation error: could not parse bandwidth limit source")
u.Filters.BandwidthLimits = []sdk.BandwidthLimit{
{
Sources: []string{"127.0.0.0/8", "::1/128"},
UploadBandwidth: 256,
},
{
Sources: []string{"10.0.0.0/8"},
UploadBandwidth: 512,
DownloadBandwidth: 256,
},
}
user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err, string(resp))
assert.Len(t, user.Filters.BandwidthLimits, 2)
assert.Equal(t, u.Filters.BandwidthLimits, user.Filters.BandwidthLimits)
connID := xid.New().String()
localAddr := "127.0.0.1"
up, down := user.GetBandwidthForIP("127.0.1.1", connID)
assert.Equal(t, int64(256), up)
assert.Equal(t, int64(0), down)
conn := common.NewBaseConnection(connID, common.ProtocolHTTP, localAddr, "127.0.1.1", user)
assert.Equal(t, int64(256), conn.User.UploadBandwidth)
assert.Equal(t, int64(0), conn.User.DownloadBandwidth)
up, down = user.GetBandwidthForIP("10.1.2.3", connID)
assert.Equal(t, int64(512), up)
assert.Equal(t, int64(256), down)
conn = common.NewBaseConnection(connID, common.ProtocolHTTP, localAddr, "10.2.1.4:1234", user)
assert.Equal(t, int64(512), conn.User.UploadBandwidth)
assert.Equal(t, int64(256), conn.User.DownloadBandwidth)
up, down = user.GetBandwidthForIP("192.168.1.2", connID)
assert.Equal(t, int64(128), up)
assert.Equal(t, int64(96), down)
conn = common.NewBaseConnection(connID, common.ProtocolHTTP, localAddr, "172.16.0.1", user)
assert.Equal(t, int64(128), conn.User.UploadBandwidth)
assert.Equal(t, int64(96), conn.User.DownloadBandwidth)
up, down = user.GetBandwidthForIP("invalid", connID)
assert.Equal(t, int64(128), up)
assert.Equal(t, int64(96), down)
conn = common.NewBaseConnection(connID, common.ProtocolHTTP, localAddr, "172.16.0", user)
assert.Equal(t, int64(128), conn.User.UploadBandwidth)
assert.Equal(t, int64(96), conn.User.DownloadBandwidth)
user.Filters.BandwidthLimits = []sdk.BandwidthLimit{
{
Sources: []string{"10.0.0.0/24"},
UploadBandwidth: 256,
DownloadBandwidth: 512,
},
}
user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err, string(resp))
if assert.Len(t, user.Filters.BandwidthLimits, 1) {
bwLimit := user.Filters.BandwidthLimits[0]
assert.Equal(t, []string{"10.0.0.0/24"}, bwLimit.Sources)
assert.Equal(t, int64(256), bwLimit.UploadBandwidth)
assert.Equal(t, int64(512), bwLimit.DownloadBandwidth)
}
up, down = user.GetBandwidthForIP("10.1.2.3", connID)
assert.Equal(t, int64(128), up)
assert.Equal(t, int64(96), down)
conn = common.NewBaseConnection(connID, common.ProtocolHTTP, localAddr, "172.16.0.2", user)
assert.Equal(t, int64(128), conn.User.UploadBandwidth)
assert.Equal(t, int64(96), conn.User.DownloadBandwidth)
up, down = user.GetBandwidthForIP("10.0.0.26", connID)
assert.Equal(t, int64(256), up)
assert.Equal(t, int64(512), down)
conn = common.NewBaseConnection(connID, common.ProtocolHTTP, localAddr, "10.0.0.28", user)
assert.Equal(t, int64(256), conn.User.UploadBandwidth)
assert.Equal(t, int64(512), conn.User.DownloadBandwidth)
// this works if we remove the omitempty tag from BandwidthLimits
/*user.Filters.BandwidthLimits = nil
user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err, string(resp))
assert.Len(t, user.Filters.BandwidthLimits, 0)*/
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
}
func TestUserTimestamps(t *testing.T) {
user, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err, string(resp))
@ -12915,6 +13010,39 @@ func TestWebUserAddMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "Validation error: invalid TLS username")
form.Set("tls_username", string(sdk.TLSUsernameNone))
// invalid upload_bandwidth_source0
form.Set("bandwidth_limit_sources0", "192.168.1.0/24, 192.168.2.0/25")
form.Set("upload_bandwidth_source0", "a")
form.Set("download_bandwidth_source0", "0")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, webToken)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid upload_bandwidth_source")
// invalid download_bandwidth_source0
form.Set("upload_bandwidth_source0", "256")
form.Set("download_bandwidth_source0", "a")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, webToken)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid download_bandwidth_source")
form.Set("download_bandwidth_source0", "512")
form.Set("download_bandwidth_source1", "1024")
form.Set("bandwidth_limit_sources1", "1.1.1")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, webToken)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "Validation error: could not parse bandwidth limit source")
form.Set("bandwidth_limit_sources1", "127.0.0.1/32")
form.Set("upload_bandwidth_source1", "-1")
form.Set(csrfFormToken, "invalid form token")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
@ -12999,6 +13127,21 @@ func TestWebUserAddMock(t *testing.T) {
assert.True(t, util.IsStringInSlice("*.rar", filter.DeniedPatterns))
}
}
if assert.Len(t, newUser.Filters.BandwidthLimits, 2) {
for _, bwLimit := range newUser.Filters.BandwidthLimits {
if len(bwLimit.Sources) == 2 {
assert.Equal(t, "192.168.1.0/24", bwLimit.Sources[0])
assert.Equal(t, "192.168.2.0/25", bwLimit.Sources[1])
assert.Equal(t, int64(256), bwLimit.UploadBandwidth)
assert.Equal(t, int64(512), bwLimit.DownloadBandwidth)
} else {
assert.Equal(t, []string{"127.0.0.1/32"}, bwLimit.Sources)
assert.Equal(t, int64(0), bwLimit.UploadBandwidth)
assert.Equal(t, int64(1024), bwLimit.DownloadBandwidth)
}
}
}
assert.Equal(t, sdk.TLSUsernameNone, newUser.Filters.TLSUsername)
req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, newUser.Username), nil)
setBearerForReq(req, apiToken)
@ -13018,6 +13161,13 @@ func TestWebUserUpdateMock(t *testing.T) {
csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
assert.NoError(t, err)
user := getTestUser()
user.Filters.BandwidthLimits = []sdk.BandwidthLimit{
{
Sources: []string{"10.8.0.0/16", "192.168.1.0/25"},
UploadBandwidth: 256,
DownloadBandwidth: 512,
},
}
userAsJSON := getUserAsJSON(t, user)
req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
setBearerForReq(req, apiToken)
@ -13053,6 +13203,14 @@ func TestWebUserUpdateMock(t *testing.T) {
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.True(t, user.Filters.TOTPConfig.Enabled)
if assert.Len(t, user.Filters.BandwidthLimits, 1) {
if assert.Len(t, user.Filters.BandwidthLimits[0].Sources, 2) {
assert.Equal(t, "10.8.0.0/16", user.Filters.BandwidthLimits[0].Sources[0])
assert.Equal(t, "192.168.1.0/25", user.Filters.BandwidthLimits[0].Sources[1])
}
assert.Equal(t, int64(256), user.Filters.BandwidthLimits[0].UploadBandwidth)
assert.Equal(t, int64(512), user.Filters.BandwidthLimits[0].DownloadBandwidth)
}
dbUser, err := dataprovider.UserExists(user.Username)
assert.NoError(t, err)
@ -13178,6 +13336,7 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.True(t, util.IsStringInSlice(dataprovider.SSHLoginMethodKeyboardInteractive, updateUser.Filters.DeniedLoginMethods))
assert.True(t, util.IsStringInSlice(common.ProtocolFTP, updateUser.Filters.DeniedProtocols))
assert.True(t, util.IsStringInSlice("*.zip", updateUser.Filters.FilePatterns[0].DeniedPatterns))
assert.Len(t, updateUser.Filters.BandwidthLimits, 0)
req, err = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
assert.NoError(t, err)
setBearerForReq(req, apiToken)

View file

@ -732,6 +732,41 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
return permissions
}
func getBandwidthLimitsFromPostFields(r *http.Request) ([]sdk.BandwidthLimit, error) {
var result []sdk.BandwidthLimit
for k := range r.Form {
if strings.HasPrefix(k, "bandwidth_limit_sources") {
sources := getSliceFromDelimitedValues(r.Form.Get(k), ",")
if len(sources) > 0 {
bwLimit := sdk.BandwidthLimit{
Sources: sources,
}
idx := strings.TrimPrefix(k, "bandwidth_limit_sources")
ul := r.Form.Get(fmt.Sprintf("upload_bandwidth_source%v", idx))
dl := r.Form.Get(fmt.Sprintf("download_bandwidth_source%v", idx))
if ul != "" {
bandwidthUL, err := strconv.ParseInt(ul, 10, 64)
if err != nil {
return result, fmt.Errorf("invalid upload_bandwidth_source%v %#v: %w", idx, ul, err)
}
bwLimit.UploadBandwidth = bandwidthUL
}
if dl != "" {
bandwidthDL, err := strconv.ParseInt(dl, 10, 64)
if err != nil {
return result, fmt.Errorf("invalid download_bandwidth_source%v %#v: %w", idx, ul, err)
}
bwLimit.DownloadBandwidth = bandwidthDL
}
result = append(result, bwLimit)
}
}
}
return result, nil
}
func getFilePatternsFromPostField(r *http.Request) []sdk.PatternsFilter {
var result []sdk.PatternsFilter
@ -786,8 +821,13 @@ func getFilePatternsFromPostField(r *http.Request) []sdk.PatternsFilter {
return result
}
func getFiltersFromUserPostFields(r *http.Request) sdk.UserFilters {
func getFiltersFromUserPostFields(r *http.Request) (sdk.UserFilters, error) {
var filters sdk.UserFilters
bwLimits, err := getBandwidthLimitsFromPostFields(r)
if err != nil {
return filters, err
}
filters.BandwidthLimits = bwLimits
filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
filters.DeniedLoginMethods = r.Form["ssh_login_methods"]
@ -807,7 +847,7 @@ func getFiltersFromUserPostFields(r *http.Request) sdk.UserFilters {
}
filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
return filters
return filters, nil
}
func getSecretFromFormField(r *http.Request, field string) *kms.Secret {
@ -1143,6 +1183,10 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
if err != nil {
return user, err
}
filters, err := getFiltersFromUserPostFields(r)
if err != nil {
return user, err
}
user = dataprovider.User{
BaseUser: sdk.BaseUser{
Username: r.Form.Get("username"),
@ -1160,7 +1204,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
DownloadBandwidth: bandwidthDL,
Status: status,
ExpirationDate: expirationDateMillis,
Filters: getFiltersFromUserPostFields(r),
Filters: filters,
AdditionalInfo: r.Form.Get("additional_info"),
Description: r.Form.Get("description"),
},

View file

@ -1492,6 +1492,9 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User)
if err := compareUserFilterSubStructs(expected, actual); err != nil {
return err
}
if err := compareUserBandwidthLimitFilters(expected, actual); err != nil {
return err
}
return compareUserFilePatternsFilters(expected, actual)
}
@ -1507,6 +1510,31 @@ func checkFilterMatch(expected []string, actual []string) bool {
return true
}
func compareUserBandwidthLimitFilters(expected *dataprovider.User, actual *dataprovider.User) error {
if len(expected.Filters.BandwidthLimits) != len(actual.Filters.BandwidthLimits) {
return errors.New("bandwidth filters mismatch")
}
for idx, l := range expected.Filters.BandwidthLimits {
if actual.Filters.BandwidthLimits[idx].UploadBandwidth != l.UploadBandwidth {
return errors.New("bandwidth filters upload_bandwidth mismatch")
}
if actual.Filters.BandwidthLimits[idx].DownloadBandwidth != l.DownloadBandwidth {
return errors.New("bandwidth filters download_bandwidth mismatch")
}
if len(actual.Filters.BandwidthLimits[idx].Sources) != len(l.Sources) {
return errors.New("bandwidth filters sources mismatch")
}
for _, source := range actual.Filters.BandwidthLimits[idx].Sources {
if !util.IsStringInSlice(source, l.Sources) {
return errors.New("bandwidth filters source mismatch")
}
}
}
return nil
}
func compareUserFilePatternsFilters(expected *dataprovider.User, actual *dataprovider.User) error {
if len(expected.Filters.FilePatterns) != len(actual.Filters.FilePatterns) {
return errors.New("file patterns mismatch")

View file

@ -4282,6 +4282,22 @@ components:
example: false
description: If true, the check password hook, if defined, will not be executed
description: User specific hook overrides
BandwidthLimit:
type: object
properties:
sources:
type: array
items:
type: string
description: 'Source networks in CIDR notation as defined in RFC 4632 and RFC 4291 for example `192.0.2.0/24` or `2001:db8::/32`. The limit applies if the defined networks contain the client IP'
upload_bandwidth:
type: integer
format: int32
description: 'Maximum upload bandwidth as KB/s, 0 means unlimited'
download_bandwidth:
type: integer
format: int32
description: 'Maximum download bandwidth as KB/s, 0 means unlimited'
UserFilters:
type: object
properties:
@ -4347,6 +4363,10 @@ components:
type: array
items:
$ref: '#/components/schemas/RecoveryCode'
bandwidth_limits:
type: array
items:
$ref: '#/components/schemas/BandwidthLimit'
description: Additional user options
Secret:
type: object

View file

@ -1,6 +1,8 @@
package sdk
import (
"fmt"
"net"
"strings"
"github.com/drakkan/sftpgo/v2/kms"
@ -125,6 +127,34 @@ type TOTPConfig struct {
Protocols []string `json:"protocols,omitempty"`
}
// BandwidthLimit defines a per-source bandwidth limit
type BandwidthLimit struct {
// Source networks in CIDR notation as defined in RFC 4632 and RFC 4291
// for example "192.0.2.0/24" or "2001:db8::/32". The limit applies if the
// defined networks contain the client IP
Sources []string `json:"sources"`
// Maximum upload bandwidth as KB/s
UploadBandwidth int64 `json:"upload_bandwidth,omitempty"`
// Maximum download bandwidth as KB/s
DownloadBandwidth int64 `json:"download_bandwidth,omitempty"`
}
// Validate returns an error if the bandwidth limit is not valid
func (l *BandwidthLimit) Validate() error {
for _, source := range l.Sources {
_, _, err := net.ParseCIDR(source)
if err != nil {
return util.NewValidationError(fmt.Sprintf("could not parse bandwidth limit source %#v: %v", source, err))
}
}
return nil
}
// GetSourcesAsString returns the sources as comma separated string
func (l *BandwidthLimit) GetSourcesAsString() string {
return strings.Join(l.Sources, ",")
}
// UserFilters defines additional restrictions for a user
// TODO: rename to UserOptions in v3
type UserFilters struct {
@ -173,6 +203,8 @@ type UserFilters struct {
// UserType is an hint for authentication plugins.
// It is ignored when using SFTPGo internal authentication
UserType string `json:"user_type,omitempty"`
// Per-source bandwidth limits
BandwidthLimits []BandwidthLimit `json:"bandwidth_limits,omitempty"`
}
// BaseUser defines the shared user fields
@ -209,17 +241,19 @@ type BaseUser struct {
// List of the granted permissions
Permissions map[string][]string `json:"permissions"`
// Used quota as bytes
UsedQuotaSize int64 `json:"used_quota_size"`
UsedQuotaSize int64 `json:"used_quota_size,omitempty"`
// Used quota as number of files
UsedQuotaFiles int `json:"used_quota_files"`
UsedQuotaFiles int `json:"used_quota_files,omitempty"`
// Last quota update as unix timestamp in milliseconds
LastQuotaUpdate int64 `json:"last_quota_update"`
// Maximum upload bandwidth as KB/s, 0 means unlimited
UploadBandwidth int64 `json:"upload_bandwidth"`
// Maximum download bandwidth as KB/s, 0 means unlimited
DownloadBandwidth int64 `json:"download_bandwidth"`
LastQuotaUpdate int64 `json:"last_quota_update,omitempty"`
// Maximum upload bandwidth as KB/s, 0 means unlimited.
// This is the default if no per-source limit match
UploadBandwidth int64 `json:"upload_bandwidth,omitempty"`
// Maximum download bandwidth as KB/s, 0 means unlimited.
// This is the default if no per-source limit match
DownloadBandwidth int64 `json:"download_bandwidth,omitempty"`
// Last login as unix timestamp in milliseconds
LastLogin int64 `json:"last_login"`
LastLogin int64 `json:"last_login,omitempty"`
// Creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0
CreatedAt int64 `json:"created_at"`
// last update time as unix timestamp in milliseconds

View file

@ -178,7 +178,7 @@
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(40, true),
"render": $.fn.dataTable.render.ellipsis(40, true)
}
],
"scrollX": false,

View file

@ -228,15 +228,15 @@ function deleteAction() {
"columnDefs": [
{
"targets": [1],
"render": $.fn.dataTable.render.ellipsis(50, true),
"render": $.fn.dataTable.render.ellipsis(50, true)
},
{
"targets": [2],
"render": $.fn.dataTable.render.ellipsis(60, true),
"render": $.fn.dataTable.render.ellipsis(60, true)
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(40, true),
"render": $.fn.dataTable.render.ellipsis(40, true)
}
],
"scrollX": false,

View file

@ -364,6 +364,20 @@
</div>
</div>
<div class="form-group row">
<label for="idUID" class="col-sm-2 col-form-label">UID</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idUID" name="uid" placeholder="" value="{{.User.UID}}"
min="0" max="2147483647">
</div>
<div class="col-sm-2"></div>
<label for="idGID" class="col-sm-2 col-form-label">GID</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idGID" name="gid" placeholder="" value="{{.User.GID}}"
min="0" max="2147483647">
</div>
</div>
<div class="form-group row">
<label for="idQuotaSize" class="col-sm-2 col-form-label">Quota size (bytes)</label>
<div class="col-sm-3">
@ -425,17 +439,85 @@
</div>
</div>
<div class="form-group row">
<label for="idUID" class="col-sm-2 col-form-label">UID</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idUID" name="uid" placeholder="" value="{{.User.UID}}"
min="0" max="2147483647">
<div class="card bg-light mb-3">
<div class="card-header">
Per-source bandwidth limits
</div>
<div class="col-sm-2"></div>
<label for="idGID" class="col-sm-2 col-form-label">GID</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="idGID" name="gid" placeholder="" value="{{.User.GID}}"
min="0" max="2147483647">
<div class="card-body">
<div class="form-group row">
<div class="col-md-12 form_field_bwlimits_outer">
{{range $idx, $bwLimit := .User.Filters.BandwidthLimits -}}
<div class="row form_field_bwlimits_outer_row">
<div class="form-group col-md-8">
<textarea class="form-control" id="idBandwidthLimitSources{{$idx}}" name="bandwidth_limit_sources{{$idx}}" rows="4" placeholder=""
aria-describedby="bwLimitSourcesHelpBlock{{$idx}}">{{$bwLimit.GetSourcesAsString}}</textarea>
<small id="bwLimitSourcesHelpBlock{{$idx}}" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
<div class="col-md-3">
<div class="form-group">
<input type="number" class="form-control" id="idUploadBandwidthSource{{$idx}}" name="upload_bandwidth_source{{$idx}}"
placeholder="" value="{{$bwLimit.UploadBandwidth}}" min="0" aria-describedby="ulHelpBlock{{$idx}}">
<small id="ulHelpBlock{{$idx}}" class="form-text text-muted">
UL (KB/s). 0 means no limit
</small>
</div>
<div class="form-group">
<input type="number" class="form-control" id="idDownloadBandwidthSource{{$idx}}" name="download_bandwidth_source{{$idx}}"
placeholder="" value="{{$bwLimit.DownloadBandwidth}}" min="0" aria-describedby="dlHelpBlock{{$idx}}">
<small id="dlHelpBlock{{$idx}}" class="form-text text-muted">
DL (KB/s). 0 means no limit
</small>
</div>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{else}}
<div class="row form_field_bwlimits_outer_row">
<div class="form-group col-md-8">
<textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources0" rows="4" placeholder=""
aria-describedby="bwLimitSourcesHelpBlock0"></textarea>
<small id="bwLimitSourcesHelpBlock0" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
<div class="col-md-3">
<div class="form-group">
<input type="number" class="form-control" id="idUploadBandwidthSource0" name="upload_bandwidth_source0"
placeholder="" value="" min="0" aria-describedby="ulHelpBlock0">
<small id="ulHelpBlock0" class="form-text text-muted">
UL (KB/s). 0 means no limit
</small>
</div>
<div class="form-group">
<input type="number" class="form-control" id="idDownloadBandwidthSource0" name="download_bandwidth_source0"
placeholder="" value="" min="0" aria-describedby="dlHelpBlock0">
<small id="dlHelpBlock0" class="form-text text-muted">
DL (KB/s). 0 means no limit
</small>
</div>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_bwlimit_field_btn">
<i class="fas fa-plus"></i> Add new bandwidth limit
</button>
</div>
</div>
</div>
@ -757,6 +839,49 @@
$(this).closest(".form_field_vfolder_outer_row").remove();
});
$("body").on("click", ".add_new_bwlimit_field_btn", function () {
var index = $(".form_field_bwlimits_outer").find(".form_field_bwlimits_outer_row").length;
while (document.getElementById("idBandwidthLimitSources"+index) != null){
index++;
}
$(".form_field_bwlimits_outer").append(`
<div class="row form_field_bwlimits_outer_row">
<div class="form-group col-md-8">
<textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources${index}" rows="4" placeholder=""
aria-describedby="bwLimitSourcesHelpBlock${index}"></textarea>
<small id="bwLimitSourcesHelpBlock${index}" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
<div class="col-md-3">
<div class="form-group">
<input type="number" class="form-control" id="idUploadBandwidthSource${index}" name="upload_bandwidth_source${index}"
placeholder="" value="" min="0" aria-describedby="ulHelpBlock${index}">
<small id="ulHelpBlock${index}" class="form-text text-muted">
UL (KB/s). 0 means no limit
</small>
</div>
<div class="form-group">
<input type="number" class="form-control" id="idDownloadBandwidthSource${index}" name="download_bandwidth_source${index}"
placeholder="" value="" min="0" aria-describedby="dlHelpBlock${index}">
<small id="dlHelpBlock${index}" class="form-text text-muted">
DL (KB/s). 0 means no limit
</small>
</div>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
});
$("body").on("click", ".remove_bwlimit_btn_frm_field", function () {
$(this).closest(".form_field_bwlimits_outer_row").remove();
});
$("body").on("click", ".add_new_pattern_field_btn", function () {
var index = $(".form_field_patterns_outer").find(".form_field_patterns_outer_row").length;
while (document.getElementById("idPatternPath"+index) != null){

View file

@ -255,11 +255,11 @@
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(40, true),
"render": $.fn.dataTable.render.ellipsis(40, true)
},
{
"targets": [4],
"render": $.fn.dataTable.render.ellipsis(70, true),
"render": $.fn.dataTable.render.ellipsis(70, true)
}
],
"scrollX": false,

View file

@ -386,6 +386,27 @@
<script type="text/javascript">
var spinnerDone = false;
var escapeHTML = function ( t ) {
return t
.replace( /&/g, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /"/g, '&quot;' );
};
function shortenData(d, cutoff) {
if ( typeof d !== 'string' ) {
return d;
}
if ( d.length <= cutoff ) {
return d;
}
var shortened = d.substr(0, cutoff-1);
return shortened+'&#8230;';
}
function getIconForFile(filename) {
var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
switch (extension) {
@ -651,10 +672,17 @@
try {
var f = files[index];
var uploadPath = '{{.FileURL}}?path={{.CurrentDir}}'+encodeURIComponent("/"+f.name);
var lastModified;
try {
lastModified = f.lastModified;
} catch (e) {
console.log("unable to get last modified time from file: "+e.message);
lastModified = "";
}
response = await fetch(uploadPath, {
method: 'POST',
headers: {
'X-SFTPGO-MTIME': f.lastModified,
'X-SFTPGO-MTIME': lastModified,
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
credentials: 'same-origin',
@ -886,14 +914,22 @@
"data": "name",
"render": function (data, type, row) {
if (type === 'display') {
var title = "";
var cssClass = "";
var shortened = shortenData(data, 70);
if (shortened != data){
title = escapeHTML(data);
cssClass = "ellipsis";
}
if (row["type"] == "1") {
return `<i class="fas fa-folder"></i>&nbsp;<a href="${row['url']}">${data}</a>`;
return `<i class="fas fa-folder"></i>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
}
if (row["size"] == "") {
return `<i class="fas fa-external-link-alt"></i>&nbsp;<a href="${row['url']}">${data}</a>`;
return `<i class="fas fa-external-link-alt"></i>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
}
var icon = getIconForFile(data);
return `<i class="${icon}"></i>&nbsp;<a href="${row['url']}">${data}</a>`;
return `<i class="${icon}"></i>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
}
return data;
}

View file

@ -492,7 +492,7 @@ func GetRealIP(r *http.Request) string {
} else if clientIP := r.Header.Get(cfConnectingIP); clientIP != "" {
ip = clientIP
} else if xff := r.Header.Get(xForwardedFor); xff != "" {
i := strings.Index(xff, ", ")
i := strings.Index(xff, ",")
if i == -1 {
i = len(xff)
}

View file

@ -460,7 +460,7 @@ func TestRemoteAddress(t *testing.T) {
assert.Equal(t, remoteAddr2, ip)
req.RemoteAddr = remoteAddr2
req.Header.Set("X-Forwarded-For", fmt.Sprintf("%v, %v", "12.34.56.78", "172.16.2.4"))
req.Header.Set("X-Forwarded-For", fmt.Sprintf("%v,%v", "12.34.56.78", "172.16.2.4"))
ip = server.checkRemoteAddress(req)
assert.Equal(t, "12.34.56.78", ip)
assert.Equal(t, ip, req.RemoteAddr)