diff --git a/README.md b/README.md index 56a4e0ea..50b9721f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/common/connection.go b/common/connection.go index 26b71cb8..1a8a4472 100644 --- a/common/connection.go +++ b/common/connection.go @@ -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, diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 0f3d76a4..42fc1471 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -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 } diff --git a/dataprovider/user.go b/dataprovider/user.go index 4cdf8112..ec91f8bc 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -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{ diff --git a/go.mod b/go.mod index 13335a27..e79efdbc 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 2a2b1de5..02c3195e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index a9576e9a..b916069c 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -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) diff --git a/httpd/webadmin.go b/httpd/webadmin.go index d294147f..e958a489 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -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"), }, diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index 8386acbd..af5c2ba9 100644 --- a/httpdtest/httpdtest.go +++ b/httpdtest/httpdtest.go @@ -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") diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index b4d14734..13a487b6 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -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 diff --git a/sdk/user.go b/sdk/user.go index 58c687f2..7d5d2303 100644 --- a/sdk/user.go +++ b/sdk/user.go @@ -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 diff --git a/templates/webadmin/admins.html b/templates/webadmin/admins.html index 03f990b6..6370523f 100644 --- a/templates/webadmin/admins.html +++ b/templates/webadmin/admins.html @@ -178,7 +178,7 @@ }, { "targets": [3], - "render": $.fn.dataTable.render.ellipsis(40, true), + "render": $.fn.dataTable.render.ellipsis(40, true) } ], "scrollX": false, diff --git a/templates/webadmin/folders.html b/templates/webadmin/folders.html index b1f94e56..ee90a82a 100644 --- a/templates/webadmin/folders.html +++ b/templates/webadmin/folders.html @@ -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, diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 39436a2c..96f01423 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -364,6 +364,20 @@ +