diff --git a/docker/README.md b/docker/README.md index d9d9231a..d5490fa8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -198,7 +198,7 @@ We only provide the slim variant and so the optional `git` dependency is not ava ### `sftpgo:-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:-plugins` diff --git a/docker/scripts/download-plugins.sh b/docker/scripts/download-plugins.sh index 6dcf966e..84617081 100755 --- a/docker/scripts/download-plugins.sh +++ b/docker/scripts/download-plugins.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -e ARCH=`uname -m` diff --git a/docs/eventmanager.md b/docs/eventmanager.md index d892d146..824c1338 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -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 rule’s 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. diff --git a/go.mod b/go.mod index f7e72fcd..53a868e0 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index f71d4710..f2620424 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/acme.go b/internal/cmd/acme.go index e27fdf15..0181b7b2 100644 --- a/internal/cmd/acme.go +++ b/internal/cmd/acme.go @@ -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() diff --git a/internal/common/defenderdb.go b/internal/common/defenderdb.go index 4f4a2b32..9c15675f 100644 --- a/internal/common/defenderdb.go +++ b/internal/common/defenderdb.go @@ -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 { diff --git a/internal/common/defendermem.go b/internal/common/defendermem.go index dce3c809..d457cd35 100644 --- a/internal/common/defendermem.go +++ b/internal/common/defendermem.go @@ -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 } diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index d550f1b3..b3baf2a2 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -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 diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index ef472e3b..50f5bfb0 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -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") diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 71e911db..50951d87 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -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 } diff --git a/templates/webadmin/eventrule.html b/templates/webadmin/eventrule.html index 6b2d1729..83b0a383 100644 --- a/templates/webadmin/eventrule.html +++ b/templates/webadmin/eventrule.html @@ -183,7 +183,7 @@ along with this program. If not, see . -
+
Name filters
@@ -543,6 +543,9 @@ along with this program. If not, see . case 3: $('.trigger-schedule').show(); break; + case '4': + case 4: + break; default: console.log(`unsupported event trigger type: ${val}`); }