event manager: add IP blocked trigger

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-08-11 20:09:53 +02:00
parent d65c00728a
commit 194c3c13ac
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
12 changed files with 247 additions and 31 deletions

View file

@ -198,7 +198,7 @@ We only provide the slim variant and so the optional `git` dependency is not ava
### `sftpgo:<suite>-slim`
These tags provide a slimmer image that does not include the optional `git`, `rsync` and `jq` dependencies.
These tags provide a slimmer image that does not include `jq` and the optional `git` and `rsync` dependencies.
### `sftpgo:<suite>-plugins`

View file

@ -1,4 +1,5 @@
#!/usr/bin/env bash
set -e
ARCH=`uname -m`

View file

@ -40,6 +40,7 @@ The following trigger events are supported:
- `Filesystem events`, for example `upload`, `download` etc.
- `Provider events`, for example `add`, `update`, `delete` user or other resources.
- `Schedules`.
- `IP Blocked`, this event can be generated if you enable the [defender](./defender.md).
You can further restrict a rule by specifying additional conditions that must be met before the rules actions are taken. For example you can react to uploads only if they are performed by a particular user or using a specified protocol.
@ -58,3 +59,4 @@ Some actions are not supported for some triggers, rules containing incompatible
- `Filesystem events`, folder quota reset cannot be executed, we don't have a direct way to get the affected folder.
- `Provider events`, user quota reset, transfer quota reset, data retention check and filesystem actions can be executed only if we modify a user. They will be executed for the affected user. Folder quota reset can be executed only for folders. Filesystem actions are not executed for `delete` user events because the actions is executed after the user deletion.
- `Schedules`, filesystem actions cannot be executed, they require a user.
- `IP Blocked`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed, we only have an IP.

8
go.mod
View file

@ -3,7 +3,7 @@ module github.com/drakkan/sftpgo/v2
go 1.19
require (
cloud.google.com/go/storage v1.24.0
cloud.google.com/go/storage v1.25.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
@ -66,9 +66,9 @@ require (
go.uber.org/automaxprocs v1.5.1
gocloud.dev v0.26.0
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48
golang.org/x/net v0.0.0-20220809184613-07c6da5e1ced
golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9
google.golang.org/api v0.92.0
gopkg.in/natefinch/lumberjack.v2 v2.0.0
@ -168,5 +168,5 @@ replace (
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e
golang.org/x/net => github.com/drakkan/net v0.0.0-20220805164234-d1ea7e8d1b71
golang.org/x/net => github.com/drakkan/net v0.0.0-20220811173512-bde04f9047cc
)

12
go.sum
View file

@ -77,8 +77,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
cloud.google.com/go/storage v1.24.0 h1:a4N0gIkx83uoVFGz8B2eAV3OhN90QoWF5OZWLKl39ig=
cloud.google.com/go/storage v1.24.0/go.mod h1:3xrJEFMXBsQLgxwThyjuD3aYlroL0TMRec1ypGUQ0KE=
cloud.google.com/go/storage v1.25.0 h1:D2Dn0PslpK7Z3B2AvuUHyIC762bDbGJdlmQlCBR71os=
cloud.google.com/go/storage v1.25.0/go.mod h1:Qys4JU+jeup3QnuKKAosWuxrD95C4MSqxfVDnSirDsI=
cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A=
cloud.google.com/go/trace v1.2.0/go.mod h1:Wc8y/uYyOhPy12KEnXG9XGrvfMz5F5SrYecQlbW1rwM=
contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
@ -266,8 +266,8 @@ github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e h1:ZvOJ5DqEUZig5lGl
github.com/drakkan/crypto v0.0.0-20220723143649-81550382d55e/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
github.com/drakkan/net v0.0.0-20220805164234-d1ea7e8d1b71 h1:zC5D08STgdsK74Nh0457cZp7hKmbW+upbr8lfPo3CJw=
github.com/drakkan/net v0.0.0-20220805164234-d1ea7e8d1b71/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
github.com/drakkan/net v0.0.0-20220811173512-bde04f9047cc h1:nWhdNJ31a4S7oBCwIRRPY/QfpOdHl3i3irjrJXrfM7w=
github.com/drakkan/net v0.0.0-20220811173512-bde04f9047cc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d h1:kNk/KRhszPJASp7WvjagNW254aKK643Lu8/fr4/ukiM=
github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
@ -974,8 +974,8 @@ golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 h1:v1W7bwXHsnLLloWYTVEdvGvA7BHMeBYsPcF0GLDxIRs=
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View file

@ -46,7 +46,7 @@ renewed by the SFTPGo service
configDir = util.CleanDirInput(configDir)
err := config.LoadConfig(configDir, configFile)
if err != nil {
logger.ErrorToConsole("Unable to initialize data provider, config load error: %v", err)
logger.ErrorToConsole("Unable to initialize ACME, config load error: %v", err)
return
}
acmeConfig := config.GetACMEConfig()

View file

@ -107,6 +107,13 @@ func (d *dbDefender) AddEvent(ip string, event HostEvent) {
if host.Score > d.config.Threshold {
banTime := time.Now().Add(time.Duration(d.config.BanTime) * time.Minute)
err = dataprovider.SetDefenderBanTime(ip, util.GetTimeAsMsSinceEpoch(banTime))
if err == nil {
eventManager.handleIPBlockedEvent(EventParams{
Event: ipBlockedEventName,
IP: ip,
Timestamp: time.Now().UnixNano(),
})
}
}
if err == nil {

View file

@ -209,6 +209,11 @@ func (d *memoryDefender) AddEvent(ip string, event HostEvent) {
d.banned[ip] = time.Now().Add(time.Duration(d.config.BanTime) * time.Minute)
delete(d.hosts, ip)
d.cleanupBanned()
eventManager.handleIPBlockedEvent(EventParams{
Event: ipBlockedEventName,
IP: ip,
Timestamp: time.Now().UnixNano(),
})
} else {
d.hosts[ip] = hs
}

View file

@ -41,6 +41,10 @@ import (
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
const (
ipBlockedEventName = "IP Blocked"
)
var (
// eventManager handle the supported event rules actions
eventManager eventRulesContainer
@ -71,11 +75,12 @@ func init() {
// eventRulesContainer stores event rules by trigger
type eventRulesContainer struct {
sync.RWMutex
lastLoad int64
FsEvents []dataprovider.EventRule
ProviderEvents []dataprovider.EventRule
Schedules []dataprovider.EventRule
IPBlockedEvents []dataprovider.EventRule
schedulesMapping map[string][]cron.EntryID
lastLoad int64
concurrencyGuard chan struct{}
}
@ -124,6 +129,15 @@ func (r *eventRulesContainer) removeRuleInternal(name string) {
return
}
}
for idx := range r.IPBlockedEvents {
if r.IPBlockedEvents[idx].Name == name {
lastIdx := len(r.IPBlockedEvents) - 1
r.IPBlockedEvents[idx] = r.IPBlockedEvents[lastIdx]
r.IPBlockedEvents = r.IPBlockedEvents[:lastIdx]
eventManagerLog(logger.LevelDebug, "removed rule %q from IP blocked events", name)
return
}
}
for idx := range r.Schedules {
if r.Schedules[idx].Name == name {
if schedules, ok := r.schedulesMapping[name]; ok {
@ -160,6 +174,9 @@ func (r *eventRulesContainer) addUpdateRuleInternal(rule dataprovider.EventRule)
case dataprovider.EventTriggerProviderEvent:
r.ProviderEvents = append(r.ProviderEvents, rule)
eventManagerLog(logger.LevelDebug, "added rule %q to provider events", rule.Name)
case dataprovider.EventTriggerIPBlocked:
r.IPBlockedEvents = append(r.IPBlockedEvents, rule)
eventManagerLog(logger.LevelDebug, "added rule %q to IP blocked events", rule.Name)
case dataprovider.EventTriggerSchedule:
for _, schedule := range rule.Conditions.Schedules {
cronSpec := schedule.GetCronSpec()
@ -200,8 +217,8 @@ func (r *eventRulesContainer) loadRules() {
r.addUpdateRuleInternal(rule)
}
}
eventManagerLog(logger.LevelDebug, "event rules updated, fs events: %d, provider events: %d, schedules: %d",
len(r.FsEvents), len(r.ProviderEvents), len(r.Schedules))
eventManagerLog(logger.LevelDebug, "event rules updated, fs events: %d, provider events: %d, schedules: %d, ip blocked events: %d",
len(r.FsEvents), len(r.ProviderEvents), len(r.Schedules), len(r.IPBlockedEvents))
r.setLastLoadTime(modTime)
}
@ -323,6 +340,28 @@ func (r *eventRulesContainer) handleProviderEvent(params EventParams) {
}
}
func (r *eventRulesContainer) handleIPBlockedEvent(params EventParams) {
r.RLock()
defer r.RUnlock()
if len(r.IPBlockedEvents) == 0 {
return
}
var rules []dataprovider.EventRule
for _, rule := range r.IPBlockedEvents {
if err := rule.CheckActionsConsistency(""); err == nil {
rules = append(rules, rule)
} else {
eventManagerLog(logger.LevelWarn, "rule %q skipped: %v, event %q",
rule.Name, err, params.Event)
}
}
if len(rules) > 0 {
go executeAsyncRulesActions(rules, params)
}
}
// EventParams defines the supported event parameters
type EventParams struct {
Name string

View file

@ -3530,6 +3530,129 @@ func TestEventRuleFsActions(t *testing.T) {
assert.NoError(t, err)
}
func TestEventRuleIPBlocked(t *testing.T) {
oldConfig := config.GetCommonConfig()
cfg := config.GetCommonConfig()
cfg.DefenderConfig.Enabled = true
cfg.DefenderConfig.Threshold = 3
cfg.DefenderConfig.ScoreLimitExceeded = 2
err := common.Initialize(cfg, 0)
assert.NoError(t, err)
smtpCfg := smtp.Config{
Host: "127.0.0.1",
Port: 2525,
From: "notification@example.com",
TemplatesPath: "templates",
}
err = smtpCfg.Initialize(configDir)
require.NoError(t, err)
a1 := dataprovider.BaseEventAction{
Name: "action1",
Type: dataprovider.ActionTypeEmail,
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test3@example.com", "test4@example.com"},
Subject: `New "{{Event}}"`,
Body: "IP: {{IP}} Timestamp: {{Timestamp}}",
},
},
}
action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
assert.NoError(t, err)
a2 := dataprovider.BaseEventAction{
Name: "action2",
Type: dataprovider.ActionTypeFolderQuotaReset,
}
action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
assert.NoError(t, err)
r1 := dataprovider.EventRule{
Name: "test rule ip blocked",
Trigger: dataprovider.EventTriggerIPBlocked,
Actions: []dataprovider.EventAction{
{
BaseEventAction: dataprovider.BaseEventAction{
Name: action1.Name,
},
Order: 1,
},
},
}
rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
assert.NoError(t, err)
r2 := dataprovider.EventRule{
Name: "test rule 2",
Trigger: dataprovider.EventTriggerIPBlocked,
Actions: []dataprovider.EventAction{
{
BaseEventAction: dataprovider.BaseEventAction{
Name: action1.Name,
},
Order: 1,
},
{
BaseEventAction: dataprovider.BaseEventAction{
Name: action2.Name,
},
Order: 2,
},
},
}
rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated)
assert.NoError(t, err)
u := getTestUser()
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
lastReceivedEmail.reset()
time.Sleep(300 * time.Millisecond)
assert.Empty(t, lastReceivedEmail.get().From, string(lastReceivedEmail.get().Data))
for i := 0; i < 3; i++ {
user.Password = "wrong_pwd"
_, _, err = getSftpClient(user)
assert.Error(t, err)
}
// the client is now banned
user.Password = defaultPassword
_, _, err = getSftpClient(user)
assert.Error(t, err)
// check the email notification
assert.Eventually(t, func() bool {
return lastReceivedEmail.get().From != ""
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 2)
assert.True(t, util.Contains(email.To, "test3@example.com"))
assert.True(t, util.Contains(email.To, "test4@example.com"))
assert.Contains(t, string(email.Data), `Subject: New "IP Blocked"`)
err = dataprovider.DeleteEventRule(rule1.Name, "", "")
assert.NoError(t, err)
err = dataprovider.DeleteEventRule(rule2.Name, "", "")
assert.NoError(t, err)
err = dataprovider.DeleteEventAction(action1.Name, "", "")
assert.NoError(t, err)
err = dataprovider.DeleteEventAction(action2.Name, "", "")
assert.NoError(t, err)
err = dataprovider.DeleteUser(user.Username, "", "")
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
smtpCfg = smtp.Config{}
err = smtpCfg.Initialize(configDir)
require.NoError(t, err)
err = common.Initialize(oldConfig, 0)
assert.NoError(t, err)
}
func TestSyncUploadAction(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")

View file

@ -84,10 +84,12 @@ const (
// Provider events such as add, update, delete
EventTriggerProviderEvent
EventTriggerSchedule
EventTriggerIPBlocked
)
var (
supportedEventTriggers = []int{EventTriggerFsEvent, EventTriggerProviderEvent, EventTriggerSchedule}
supportedEventTriggers = []int{EventTriggerFsEvent, EventTriggerProviderEvent, EventTriggerSchedule,
EventTriggerIPBlocked}
)
func isEventTriggerValid(trigger int) bool {
@ -100,6 +102,8 @@ func getTriggerTypeAsString(trigger int) string {
return "Filesystem event"
case EventTriggerProviderEvent:
return "Provider event"
case EventTriggerIPBlocked:
return "IP blocked"
default:
return "Schedule"
}
@ -881,6 +885,15 @@ func (c *EventConditions) validate(trigger int) error {
return err
}
}
case EventTriggerIPBlocked:
c.FsEvents = nil
c.ProviderEvents = nil
c.Options.Names = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.Schedules = nil
default:
c.FsEvents = nil
c.ProviderEvents = nil
@ -1000,24 +1013,43 @@ func (r *EventRule) validate() error {
return nil
}
func (r *EventRule) checkIPBlockedActions() error {
unavailableActions := []int{ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset,
ActionTypeDataRetentionCheck, ActionTypeFilesystem}
for _, action := range r.Actions {
if util.Contains(unavailableActions, action.Type) {
return fmt.Errorf("action %q, type %q is not supported for IP blocked events",
action.Name, getActionTypeAsString(action.Type))
}
}
return nil
}
func (r *EventRule) checkProviderEventActions(providerObjectType string) error {
// user quota reset, transfer quota reset, data retention check and filesystem actions
// can be executed only if we modify a user. They will be executed for the
// affected user. Folder quota reset can be executed only for folders.
userSpecificActions := []int{ActionTypeUserQuotaReset, ActionTypeTransferQuotaReset,
ActionTypeDataRetentionCheck, ActionTypeFilesystem}
for _, action := range r.Actions {
if util.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser {
return fmt.Errorf("action %q, type %q is only supported for provider user events",
action.Name, getActionTypeAsString(action.Type))
}
if action.Type == ActionTypeFolderQuotaReset && providerObjectType != actionObjectFolder {
return fmt.Errorf("action %q, type %q is only supported for provider folder events",
action.Name, getActionTypeAsString(action.Type))
}
}
return nil
}
// CheckActionsConsistency returns an error if the actions cannot be executed
func (r *EventRule) CheckActionsConsistency(providerObjectType string) error {
switch r.Trigger {
case EventTriggerProviderEvent:
// user quota reset, transfer quota reset, data retention check and filesystem actions
// can be executed only if we modify a user. They will be executed for the
// affected user. Folder quota reset can be executed only for folders.
userSpecificActions := []int{ActionTypeUserQuotaReset, ActionTypeTransferQuotaReset,
ActionTypeDataRetentionCheck, ActionTypeFilesystem}
for _, action := range r.Actions {
if util.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser {
return fmt.Errorf("action %q, type %q is only supported for provider user events",
action.Name, getActionTypeAsString(action.Type))
}
if action.Type == ActionTypeFolderQuotaReset && providerObjectType != actionObjectFolder {
return fmt.Errorf("action %q, type %q is only supported for provider folder events",
action.Name, getActionTypeAsString(action.Type))
}
if err := r.checkProviderEventActions(providerObjectType); err != nil {
return err
}
case EventTriggerFsEvent:
// folder quota reset cannot be executed
@ -1035,6 +1067,10 @@ func (r *EventRule) CheckActionsConsistency(providerObjectType string) error {
action.Name, getActionTypeAsString(action.Type))
}
}
case EventTriggerIPBlocked:
if err := r.checkIPBlockedActions(); err != nil {
return err
}
}
return nil
}

View file

@ -183,7 +183,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="card bg-light mb-3">
<div class="card bg-light mb-3 trigger trigger-fs trigger-provider trigger-schedule">
<div class="card-header">
<b>Name filters</b>
</div>
@ -543,6 +543,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
case 3:
$('.trigger-schedule').show();
break;
case '4':
case 4:
break;
default:
console.log(`unsupported event trigger type: ${val}`);
}