From 8bda736c1ff0df45879e37f811b4703a8f051ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Thu, 9 May 2024 13:56:12 +0000 Subject: [PATCH 1/9] Use nested mapping for global filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/config.yml | 7 +++++-- src/main.go | 30 +----------------------------- src/startup.go | 42 ++++++++++++++++++++++++++++++++++++------ src/types.go | 7 +++---- src/version.go | 4 ++-- 5 files changed, 47 insertions(+), 43 deletions(-) diff --git a/src/config.yml b/src/config.yml index 3ee2ce24..4800691e 100644 --- a/src/config.yml +++ b/src/config.yml @@ -1,6 +1,10 @@ --- options: - filter_strings: ["type=container"] + filter: + type: ["container"] + # container: ["some_container_name"] + # image: ["some_image_name"] + # event: ["start", "stop", "die", "destroy"] exclude_strings: ["Action=exec_start", "Action=exec_die", "Action=exec_create"] log_level: debug server_tag: My Server @@ -27,4 +31,3 @@ reporter: url: http://mattermost.lan channel: Docker Event Channel user: Docker Event Bot - diff --git a/src/main.go b/src/main.go index 5257e847..b47f1057 100644 --- a/src/main.go +++ b/src/main.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "github.com/docker/docker/api/types" @@ -115,33 +114,6 @@ func loadConfig() { func parseArgs() { - // Parse (include) filters - config.Filter = make(map[string][]string) - - for _, filter := range config.Options.FilterStrings { - pos := strings.Index(filter, "=") - if pos == -1 { - log.Fatal().Msg("each filter should be of the form key=value") - } - key := filter[:pos] - val := filter[pos+1:] - config.Filter[key] = append(config.Filter[key], val) - } - - // Parse exclude filters - config.Exclude = make(map[string][]string) - - for _, exclude := range config.Options.ExcludeStrings { - pos := strings.Index(exclude, "=") - if pos == -1 { - log.Fatal().Msg("each filter should be of the form key=value") - } - //trim whitespaces - key := strings.TrimSpace(exclude[:pos]) - val := exclude[pos+1:] - config.Exclude[key] = append(config.Exclude[key], val) - } - //Parse Enabled reportes if config.Reporter.Gotify.Enabled { @@ -185,7 +157,7 @@ func main() { sendNotifications(timestamp, startup_message, "Starting docker event monitor", config.EnabledReporter) filterArgs := filters.NewArgs() - for key, values := range config.Filter { + for key, values := range config.Options.Filter { for _, value := range values { filterArgs.Add(key, value) } diff --git a/src/startup.go b/src/startup.go index 4967a820..ab47d135 100644 --- a/src/startup.go +++ b/src/startup.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strconv" "strings" "time" @@ -52,10 +53,10 @@ func buildStartupMessage(timestamp time.Time) string { startup_message_builder.WriteString("\nServerTag: none") } - if len(config.Options.FilterStrings) > 0 { - startup_message_builder.WriteString("\nFilterStrings: " + strings.Join(config.Options.FilterStrings, " ")) + if len(config.Options.Filter) > 0 { + startup_message_builder.WriteString("\nGlobal filter: " + mapToString(config.Options.Filter)) } else { - startup_message_builder.WriteString("\nFilterStrings: none") + startup_message_builder.WriteString("\nGlobal filter: none") } if len(config.Options.ExcludeStrings) > 0 { @@ -75,13 +76,14 @@ func logArguments() { Str("Version", version). Str("Branch", branch). Str("Commit", commit). - Time("Compile_date", stringToUnix(date)). - Time("Git_date", stringToUnix(gitdate)), + Time("Compile_date", stringToUnixTime(date)). + Time("Git_date", stringToUnixTime(gitdate)), ). Msg("Docker event monitor started") } -func stringToUnix(str string) time.Time { +// converts a string to a time.Time in unix fortmat +func stringToUnixTime(str string) time.Time { i, err := strconv.ParseInt(str, 10, 64) if err != nil { log.Fatal().Err(err).Msg("String to timestamp conversion failed") @@ -89,3 +91,31 @@ func stringToUnix(str string) time.Time { tm := time.Unix(i, 0) return tm } + +// walks through a nested map and returns a unified string as in +// string = [key:value,value] [key:value] []key:value,value +func mapToString(m map[string][]string) string { + var builder strings.Builder + + // Iterate over the map + for key, values := range m { + // Append key to the string + builder.WriteString(fmt.Sprintf("[%s: ", key)) + + lenght := len(values) + i := 0 + // Append values to the string + for _, value := range values { + i++ + builder.WriteString(value) + // Add comma except for the last value + if i < lenght { + builder.WriteString(", ") + } + } + // Add a space to separate keys + builder.WriteString("] ") + } + + return builder.String() +} diff --git a/src/types.go b/src/types.go index 6d539725..0a64ec7c 100644 --- a/src/types.go +++ b/src/types.go @@ -34,16 +34,15 @@ type reporter struct { } type options struct { - FilterStrings []string `yaml:"filter_strings,flow"` + Filter map[string][]string `yaml:"filter"` ExcludeStrings []string `yaml:"exclude_strings,flow"` - LogLevel string `yaml:"log_level"` - ServerTag string `yaml:"server_tag"` + LogLevel string `yaml:"log_level"` + ServerTag string `yaml:"server_tag"` } type Config struct { Reporter reporter Options options EnabledReporter []string `yaml:"-"` - Filter map[string][]string `yaml:"-"` Exclude map[string][]string `yaml:"-"` } diff --git a/src/version.go b/src/version.go index b3fea23e..41ae7bb3 100644 --- a/src/version.go +++ b/src/version.go @@ -20,8 +20,8 @@ func printVersion() { Str("Version", version). Str("Branch", branch). Str("Commit", commit). - Time("Compile_date", stringToUnix(date)). - Time("Git_date", stringToUnix(gitdate)). + Time("Compile_date", stringToUnixTime(date)). + Time("Git_date", stringToUnixTime(gitdate)). Msg("Version Information") os.Exit(0) } From c7a12fb5be711c498bf69fd50a12ba17a1eb4f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Thu, 9 May 2024 13:57:33 +0000 Subject: [PATCH 2/9] Add notification option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/config.yml | 26 ++++++++++++++++++++++++++ src/startup.go | 1 + src/types.go | 15 +++++++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/config.yml b/src/config.yml index 4800691e..68c37629 100644 --- a/src/config.yml +++ b/src/config.yml @@ -31,3 +31,29 @@ reporter: url: http://mattermost.lan channel: Docker Event Channel user: Docker Event Bot + +notifications: + - name: "Alert me when watchtower/.* based container restarts" + event: + "Action": ["(start|stop)"] + "Actor.Attributes.image": ["watchtower/.*"] + notify: + - pushover + - mail + - gotify + - name: "Alert me when unifi/.* based container dies with exitCode 1" + event: + "Action": ["(die|destroy)"] + "Actor.Attributes.image": ["unifi/.*"] + "Actor.Attributes.exitCode": ["1"] + notify: + - pushover + - mattermost + + - name: "Alert only on gotify when container dies with exitCode 0" + event: + "Action": ["(die|destroy)"] + "Actor.Attributes.image": ["pihole/.*"] + "Actor.Attributes.exitCode": ["0"] + notify: + - gotify diff --git a/src/startup.go b/src/startup.go index ab47d135..669be4af 100644 --- a/src/startup.go +++ b/src/startup.go @@ -72,6 +72,7 @@ func logArguments() { log.Info(). Interface("options", config.Options). Interface("reporter", config.Reporter). + Interface("notifications", config.Notifications). Dict("version", zerolog.Dict(). Str("Version", version). Str("Branch", branch). diff --git a/src/types.go b/src/types.go index 0a64ec7c..b64cbf1c 100644 --- a/src/types.go +++ b/src/types.go @@ -34,10 +34,16 @@ type reporter struct { } type options struct { - Filter map[string][]string `yaml:"filter"` - ExcludeStrings []string `yaml:"exclude_strings,flow"` - LogLevel string `yaml:"log_level"` - ServerTag string `yaml:"server_tag"` + Filter map[string][]string `yaml:"filter"` + ExcludeStrings []string `yaml:"exclude_strings,flow"` + LogLevel string `yaml:"log_level"` + ServerTag string `yaml:"server_tag"` +} + +type notification struct { + Name string `yaml:"name"` + Event map[string][]string `yaml:"event"` + Notify []string `yaml:"notify"` } type Config struct { @@ -45,4 +51,5 @@ type Config struct { Options options EnabledReporter []string `yaml:"-"` Exclude map[string][]string `yaml:"-"` + Notifications []notification `yaml:"notifications"` } From 5a785ddceb1cd16bfdcef84faa6e9e81ae87df88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Thu, 9 May 2024 13:59:01 +0000 Subject: [PATCH 3/9] Remove event exclusion code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/config.yml | 1 - src/events.go | 92 -------------------------------------------------- src/main.go | 8 ----- src/startup.go | 6 ---- src/types.go | 2 -- 5 files changed, 109 deletions(-) diff --git a/src/config.yml b/src/config.yml index 68c37629..805e6ddd 100644 --- a/src/config.yml +++ b/src/config.yml @@ -5,7 +5,6 @@ options: # container: ["some_container_name"] # image: ["some_image_name"] # event: ["start", "stop", "die", "destroy"] - exclude_strings: ["Action=exec_start", "Action=exec_die", "Action=exec_create"] log_level: debug server_tag: My Server diff --git a/src/events.go b/src/events.go index 985f9271..8c025885 100644 --- a/src/events.go +++ b/src/events.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "strings" "time" @@ -135,94 +134,3 @@ func getActorName(event events.Message) string { return ActorName } - -func excludeEvent(event events.Message) bool { - // Checks if any of the exclusion criteria matches the event - - ActorID := getActorID(event) - - // Convert the event (struct of type event.Message) to a flattend map - eventMap := structToFlatMap(event) - - // Check for all exclude key -> value combinations if they match the event - for key, values := range config.Exclude { - eventValue, keyExist := eventMap[key] - - // Check if the exclusion key exists in the eventMap - if !keyExist { - log.Debug(). - Str("ActorID", ActorID). - Msgf("Exclusion key \"%s\" did not match", key) - return false - } - - log.Debug(). - Str("ActorID", ActorID). - Msgf("Exclusion key \"%s\" matched, checking values", key) - - log.Debug(). - Str("ActorID", ActorID). - Msgf("Event's value for key \"%s\" is \"%s\"", key, eventValue) - - for _, value := range values { - // comparing the prefix to be able to filter actions like "exec_XXX: YYYY" which use a - // special, dynamic, syntax - // see https://github.com/moby/moby/blob/bf053be997f87af233919a76e6ecbd7d17390e62/api/types/events/events.go#L74-L81 - - if strings.HasPrefix(eventValue, value) { - log.Debug(). - Str("ActorID", ActorID). - Msgf("Event excluded based on exclusion setting \"%s=%s\"", key, value) - return true - } - } - log.Debug(). - Str("ActorID", ActorID). - Msgf("Exclusion key \"%s\" matched, but values did not match", key) - } - - return false -} - -// flatten a nested map, separating nested keys by dots -func flattenMap(prefix string, m map[string]interface{}) map[string]string { - flatMap := make(map[string]string) - for k, v := range m { - newKey := k - // separate nested keys by dot - if prefix != "" { - newKey = prefix + "." + k - } - // if the value is a map/struct itself, transverse it recursivly - switch k { - case "Actor", "Attributes": - nestedMap := v.(map[string]interface{}) - for nk, nv := range flattenMap(newKey, nestedMap) { - flatMap[nk] = nv - } - case "time", "timeNano": - flatMap[newKey] = string(v.(json.Number)) - default: - flatMap[newKey] = v.(string) - } - } - return flatMap -} - -// Convert struct to flat map by first converting it to a map (via JSON) and flatten it afterwards -func structToFlatMap(s interface{}) map[string]string { - m := make(map[string]interface{}) - b, err := json.Marshal(s) - if err != nil { - log.Fatal().Err(err).Msg("Marshaling JSON failed") - } - - // Using a custom decoder to set 'UseNumber' which will preserver a string representation of - // time and timeNano instead of converting it to float64 - decoder := json.NewDecoder(strings.NewReader(string(b))) - decoder.UseNumber() - if err := decoder.Decode(&m); err != nil { - log.Fatal().Err(err).Msg("Unmarshaling JSON failed") - } - return flattenMap("", m) -} diff --git a/src/main.go b/src/main.go index b47f1057..7d4af39f 100644 --- a/src/main.go +++ b/src/main.go @@ -180,14 +180,6 @@ func main() { // if logging level is debug, log the event log.Debug(). Interface("event", event).Msg("") - - // Check if event should be exlcuded from reporting - if len(config.Exclude) > 0 { - log.Debug().Msg("Performing check for event exclusion") - if excludeEvent(event) { - break //breaks out of the select and waits for the next event to arrive - } - } processEvent(event) } } diff --git a/src/startup.go b/src/startup.go index 669be4af..11051a29 100644 --- a/src/startup.go +++ b/src/startup.go @@ -59,12 +59,6 @@ func buildStartupMessage(timestamp time.Time) string { startup_message_builder.WriteString("\nGlobal filter: none") } - if len(config.Options.ExcludeStrings) > 0 { - startup_message_builder.WriteString("\nExcludeStrings: " + strings.Join(config.Options.ExcludeStrings, " ")) - } else { - startup_message_builder.WriteString("\nExcludeStrings: none") - } - return startup_message_builder.String() } diff --git a/src/types.go b/src/types.go index b64cbf1c..ead857ca 100644 --- a/src/types.go +++ b/src/types.go @@ -35,7 +35,6 @@ type reporter struct { type options struct { Filter map[string][]string `yaml:"filter"` - ExcludeStrings []string `yaml:"exclude_strings,flow"` LogLevel string `yaml:"log_level"` ServerTag string `yaml:"server_tag"` } @@ -50,6 +49,5 @@ type Config struct { Reporter reporter Options options EnabledReporter []string `yaml:"-"` - Exclude map[string][]string `yaml:"-"` Notifications []notification `yaml:"notifications"` } From fa129bd6e4e1e9f26b56d3e8eb366018327d6505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Fri, 10 May 2024 11:54:10 +0000 Subject: [PATCH 4/9] Allow individual reports to be used by different rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/events.go | 51 ++++++++++++++++++++++++++++++++++++++++++-- src/gotify.go | 2 +- src/mail.go | 2 +- src/main.go | 19 +++++++++-------- src/mattermost.go | 2 +- src/notifications.go | 12 +++++------ src/pushover.go | 2 +- src/types.go | 26 +++++++++++----------- 8 files changed, 82 insertions(+), 34 deletions(-) diff --git a/src/events.go b/src/events.go index 8c025885..2e72b6fd 100644 --- a/src/events.go +++ b/src/events.go @@ -1,6 +1,7 @@ package main import ( + "slices" "strings" "time" @@ -10,7 +11,53 @@ import ( "golang.org/x/text/language" ) -func processEvent(event events.Message) { +func checkReporter(event events.Message) { + + // check if notifications are configured and apply event checks + // if none are configured process event right away + if len(config.Notifications) > 0 { + + for _, notification := range config.Notifications { + log.Debug().Str("rule", notification.Name).Msg("Checking event for match") + + if matchEvent(event, notification) { + log.Debug().Str("rule", notification.Name).Msg("Rule matched") + + // check which reporters should be used + // if none are configured notification is send to all enabled reporters + if len(notification.Notify) > 0 { + + // remove disabled reporters + for _, reporter := range notification.Notify { + if !slices.Contains(config.EnabledReporter, reporter) { + log.Error().Str("reporter", reporter).Msg("Reporter not enabled") + notification.Notify = removeFromSlice(notification.Notify, reporter) + } + } + + // check if there are reporters left after removing disabled ones + if len(notification.Notify) > 0 { + log.Debug().Str("rule", notification.Name).Interface("using reporters", notification.Notify).Send() + processEvent(event, notification.Notify) + } else { + log.Error().Str("rule", notification.Name).Msg("No enabled reporter for this rule found") + } + + } else { + processEvent(event, config.EnabledReporter) + } + } + } + + } else { + processEvent(event, config.EnabledReporter) + } +} +func matchEvent(event events.Message, notification notification) bool { + return true +} + +func processEvent(event events.Message, reporters []string) { // the Docker Events endpoint will return a struct events.Message // https://pkg.go.dev/github.com/docker/docker/api/types/events#Message @@ -79,7 +126,7 @@ func processEvent(event events.Message) { // send notifications to various reporters // function will finish when all reporters finished - sendNotifications(timestamp, message, title, config.EnabledReporter) + sendNotifications(timestamp, message, title, reporters) } diff --git a/src/gotify.go b/src/gotify.go index 881901bb..ececd650 100644 --- a/src/gotify.go +++ b/src/gotify.go @@ -21,7 +21,7 @@ func sendGotify(message string, title string, errCh chan ReporterError) { } e := ReporterError{ - Reporter: "Gotify", + Reporter: "gotify", } messageJSON, err := json.Marshal(m) diff --git a/src/mail.go b/src/mail.go index d31fd0da..781404ac 100644 --- a/src/mail.go +++ b/src/mail.go @@ -25,7 +25,7 @@ func buildEMail(timestamp time.Time, from string, to []string, subject string, b func sendMail(timestamp time.Time, message string, title string, errCh chan ReporterError) { e := ReporterError{ - Reporter: "Mail", + Reporter: "mail", } from := config.Reporter.Mail.From diff --git a/src/main.go b/src/main.go index 7d4af39f..f96a56d6 100644 --- a/src/main.go +++ b/src/main.go @@ -20,7 +20,7 @@ import ( // hold config options and settings globally var config Config -// should we only print version information and exit +// should we only print version information and exit? var showVersion bool // config file path @@ -117,16 +117,16 @@ func parseArgs() { //Parse Enabled reportes if config.Reporter.Gotify.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "Gotify") + config.EnabledReporter = append(config.EnabledReporter, "gotify") } if config.Reporter.Mattermost.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "Mattermost") + config.EnabledReporter = append(config.EnabledReporter, "mattermost") } if config.Reporter.Pushover.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "Pushover") + config.EnabledReporter = append(config.EnabledReporter, "pushover") } if config.Reporter.Mail.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "Mail") + config.EnabledReporter = append(config.EnabledReporter, "mail") } } @@ -152,9 +152,10 @@ func main() { // log all supplied arguments logArguments() - timestamp := time.Now() - startup_message := buildStartupMessage(timestamp) - sendNotifications(timestamp, startup_message, "Starting docker event monitor", config.EnabledReporter) + startup_time := time.Now() + startup_message := buildStartupMessage(startup_time) + sendNotifications(startup_time, startup_message, "Starting docker event monitor", config.EnabledReporter) + filterArgs := filters.NewArgs() for key, values := range config.Options.Filter { @@ -180,7 +181,7 @@ func main() { // if logging level is debug, log the event log.Debug(). Interface("event", event).Msg("") - processEvent(event) + checkReporter(event) } } } diff --git a/src/mattermost.go b/src/mattermost.go index 1ecbde6c..6cc45abf 100644 --- a/src/mattermost.go +++ b/src/mattermost.go @@ -26,7 +26,7 @@ func sendMattermost(message string, title string, errCh chan ReporterError) { } e := ReporterError{ - Reporter: "Mattermost", + Reporter: "mattermost", } messageJSON, err := json.Marshal(m) diff --git a/src/notifications.go b/src/notifications.go index eabae2b6..201c6631 100644 --- a/src/notifications.go +++ b/src/notifications.go @@ -21,7 +21,7 @@ type ReporterError struct { func sendNotifications(timestamp time.Time, message string, title string, reporters []string) { // Sending messages to different services as goroutines concurrently - // Adding a wait group here to delay execution until all functions return, + // Adding a wait group here to delay execution until all functions return var wg sync.WaitGroup var ReporterErrors []ReporterError @@ -34,7 +34,7 @@ func sendNotifications(timestamp time.Time, message string, title string, report title = "[" + config.Options.ServerTag + "] " + title } - if slices.Contains(reporters, "Pushover") { + if slices.Contains(reporters, "pushover") { wg.Add(1) go func() { defer wg.Done() @@ -42,7 +42,7 @@ func sendNotifications(timestamp time.Time, message string, title string, report }() } - if slices.Contains(reporters, "Gotify") { + if slices.Contains(reporters, "gotify") { wg.Add(1) go func() { defer wg.Done() @@ -50,7 +50,7 @@ func sendNotifications(timestamp time.Time, message string, title string, report }() } - if slices.Contains(reporters, "Mail") { + if slices.Contains(reporters, "mail") { wg.Add(1) go func() { defer wg.Done() @@ -58,7 +58,7 @@ func sendNotifications(timestamp time.Time, message string, title string, report }() } - if slices.Contains(reporters, "Mattermost") { + if slices.Contains(reporters, "mattermost") { wg.Add(1) go func() { defer wg.Done() @@ -84,7 +84,7 @@ func sendNotifications(timestamp time.Time, message string, title string, report return } - // iterate over the failed reportes and remove them from all enabled reports + // iterate over the failed reportes and remove them from all enabled reporters // send error notifications to remaining (working) reporters only to // prevent looping error notifications to non-working reporters for _, item := range ReporterErrors { diff --git a/src/pushover.go b/src/pushover.go index 4d29a185..9fa24648 100644 --- a/src/pushover.go +++ b/src/pushover.go @@ -29,7 +29,7 @@ func sendPushover(timestamp time.Time, message string, title string, errCh chan } e := ReporterError{ - Reporter: "Pushover", + Reporter: "pushover", } messageJSON, err := json.Marshal(m) diff --git a/src/types.go b/src/types.go index ead857ca..13677b00 100644 --- a/src/types.go +++ b/src/types.go @@ -1,16 +1,16 @@ package main -type pushover struct { +type pushoverConfig struct { Enabled bool APIToken string `yaml:"api_token"` UserKey string `yaml:"user_key"` } -type gotify struct { +type gotifyConfig struct { Enabled bool URL string `yaml:"url"` Token string `yaml:"token"` } -type mail struct { +type mailConfig struct { Enabled bool From string `yaml:"from"` To string `yaml:"to"` @@ -19,7 +19,7 @@ type mail struct { Port int `yaml:"port"` Host string `yaml:"host"` } -type mattermost struct { +type mattermostConfig struct { Enabled bool URL string `yaml:"url"` Channel string `yaml:"channel"` @@ -27,16 +27,16 @@ type mattermost struct { } type reporter struct { - Pushover pushover - Gotify gotify - Mail mail - Mattermost mattermost + Pushover pushoverConfig + Gotify gotifyConfig + Mail mailConfig + Mattermost mattermostConfig } type options struct { - Filter map[string][]string `yaml:"filter"` - LogLevel string `yaml:"log_level"` - ServerTag string `yaml:"server_tag"` + Filter map[string][]string `yaml:"filter"` + LogLevel string `yaml:"log_level"` + ServerTag string `yaml:"server_tag"` } type notification struct { @@ -48,6 +48,6 @@ type notification struct { type Config struct { Reporter reporter Options options - EnabledReporter []string `yaml:"-"` - Notifications []notification `yaml:"notifications"` + EnabledReporter []string `yaml:"-"` + Notifications []notification `yaml:"notifications"` } From 1d32e5ca17c544c18c645da6523fff0113dfe2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Fri, 10 May 2024 12:31:02 +0000 Subject: [PATCH 5/9] Move helper functions to utils.go and remove strings from slice case insensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/events.go | 2 +- src/notifications.go | 12 +-------- src/startup.go | 39 ---------------------------- src/utils.go | 61 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 51 deletions(-) create mode 100644 src/utils.go diff --git a/src/events.go b/src/events.go index 2e72b6fd..f8fa2e86 100644 --- a/src/events.go +++ b/src/events.go @@ -31,7 +31,7 @@ func checkReporter(event events.Message) { for _, reporter := range notification.Notify { if !slices.Contains(config.EnabledReporter, reporter) { log.Error().Str("reporter", reporter).Msg("Reporter not enabled") - notification.Notify = removeFromSlice(notification.Notify, reporter) + notification.Notify = removeStringFromSliceInsensitive(notification.Notify, reporter) } } diff --git a/src/notifications.go b/src/notifications.go index 201c6631..f8e3f63f 100644 --- a/src/notifications.go +++ b/src/notifications.go @@ -88,7 +88,7 @@ func sendNotifications(timestamp time.Time, message string, title string, report // send error notifications to remaining (working) reporters only to // prevent looping error notifications to non-working reporters for _, item := range ReporterErrors { - reporters = removeFromSlice(reporters, item.Reporter) + reporters = removeStringFromSliceInsensitive(reporters, item.Reporter) } for _, item := range ReporterErrors { @@ -99,16 +99,6 @@ func sendNotifications(timestamp time.Time, message string, title string, report } } -func removeFromSlice(slice []string, element string) []string { - var result []string - for _, item := range slice { - if item != element { - result = append(result, item) - } - } - return result -} - func sendhttpMessage(reporter string, address string, messageJSON []byte) error { // Create request diff --git a/src/startup.go b/src/startup.go index 11051a29..092f2c82 100644 --- a/src/startup.go +++ b/src/startup.go @@ -1,8 +1,6 @@ package main import ( - "fmt" - "strconv" "strings" "time" @@ -77,40 +75,3 @@ func logArguments() { Msg("Docker event monitor started") } -// converts a string to a time.Time in unix fortmat -func stringToUnixTime(str string) time.Time { - i, err := strconv.ParseInt(str, 10, 64) - if err != nil { - log.Fatal().Err(err).Msg("String to timestamp conversion failed") - } - tm := time.Unix(i, 0) - return tm -} - -// walks through a nested map and returns a unified string as in -// string = [key:value,value] [key:value] []key:value,value -func mapToString(m map[string][]string) string { - var builder strings.Builder - - // Iterate over the map - for key, values := range m { - // Append key to the string - builder.WriteString(fmt.Sprintf("[%s: ", key)) - - lenght := len(values) - i := 0 - // Append values to the string - for _, value := range values { - i++ - builder.WriteString(value) - // Add comma except for the last value - if i < lenght { - builder.WriteString(", ") - } - } - // Add a space to separate keys - builder.WriteString("] ") - } - - return builder.String() -} diff --git a/src/utils.go b/src/utils.go new file mode 100644 index 00000000..cb7a1144 --- /dev/null +++ b/src/utils.go @@ -0,0 +1,61 @@ +package main + +//various helper functions +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +// converts a string to a time.Time in unix fortmat +func stringToUnixTime(str string) time.Time { + i, err := strconv.ParseInt(str, 10, 64) + if err != nil { + log.Fatal().Err(err).Msg("String to timestamp conversion failed") + } + tm := time.Unix(i, 0) + return tm +} + +// walks through a nested map and returns a unified string as in +// string = [key:value,value] [key:value] []key:value,value +func mapToString(m map[string][]string) string { + var builder strings.Builder + + // Iterate over the map + for key, values := range m { + // Append key to the string + builder.WriteString(fmt.Sprintf("[%s: ", key)) + + lenght := len(values) + i := 0 + // Append values to the string + for _, value := range values { + i++ + builder.WriteString(value) + // Add comma except for the last value + if i < lenght { + builder.WriteString(", ") + } + } + // Add a space to separate keys + builder.WriteString("] ") + } + + return builder.String() +} + +// removes a string from a []string (case insensitive) +func removeStringFromSliceInsensitive(slice []string, element string) []string { + var result []string + for _, item := range slice { + // comparing case-insensitive here + if !strings.EqualFold(item, element) { + result = append(result, item) + } + } + return result +} From 83f7859f2efbed1523da534b73d05d930fa28064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Sun, 12 May 2024 11:03:45 +0000 Subject: [PATCH 6/9] Add actual event match function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/events.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- src/utils.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/events.go b/src/events.go index f8fa2e86..7e791a86 100644 --- a/src/events.go +++ b/src/events.go @@ -1,6 +1,7 @@ package main import ( + "regexp" "slices" "strings" "time" @@ -14,17 +15,15 @@ import ( func checkReporter(event events.Message) { // check if notifications are configured and apply event checks - // if none are configured process event right away + // if none are configured, process event right away if len(config.Notifications) > 0 { for _, notification := range config.Notifications { - log.Debug().Str("rule", notification.Name).Msg("Checking event for match") if matchEvent(event, notification) { - log.Debug().Str("rule", notification.Name).Msg("Rule matched") // check which reporters should be used - // if none are configured notification is send to all enabled reporters + // if none are configured, notification is send to all enabled reporters if len(notification.Notify) > 0 { // remove disabled reporters @@ -54,6 +53,49 @@ func checkReporter(event events.Message) { } } func matchEvent(event events.Message, notification notification) bool { + + // only proceed when rules are set + if len(notification.Event) == 0 { + log.Error().Str("name", notification.Name).Msg("No rules configured. Skipping") + return false + } + + log.Debug().Str("name", notification.Name).Msg("Checking event for match") + + // Convert the event to a flattend map + eventMap := structToFlatMap(event) + + for eventKey, rules := range notification.Event { + ruleString := strings.Join(rules, ", ") + for _, rule := range rules { + + // get the value of the event's eventKey + eventValue, keyExist := eventMap[eventKey] + + // Check if the key exists in the eventMap + if !keyExist { + log.Debug(). + Msgf("Eventkey \"%s\" does not exist in event", eventKey) + return false + } + + matched, err := regexp.MatchString(rule, eventValue) + if err != nil { + log.Error().Err(err).Msg("regex matching failed") + } + + // regex did not match + if !matched { + log.Debug(). + Msgf("Rule \"%s: %s\" did not match", eventKey, ruleString) + return false + } + + } + log.Debug(). + Msgf("Rule \"%s: %s\" matched", eventKey, ruleString) + } + log.Debug().Str("name", notification.Name).Msg("All rules matched. Triggering notification") return true } diff --git a/src/utils.go b/src/utils.go index cb7a1144..9345291c 100644 --- a/src/utils.go +++ b/src/utils.go @@ -2,6 +2,7 @@ package main //various helper functions import ( + "encoding/json" "fmt" "strconv" "strings" @@ -59,3 +60,46 @@ func removeStringFromSliceInsensitive(slice []string, element string) []string { } return result } + +// flatten a nested map, separating nested keys by dots +func flattenMap(prefix string, m map[string]interface{}) map[string]string { + flatMap := make(map[string]string) + for k, v := range m { + newKey := k + // separate nested keys by dot + if prefix != "" { + newKey = prefix + "." + k + } + // if the value is a map/struct itself, transverse it recursivly + switch k { + case "Actor", "Attributes": + nestedMap := v.(map[string]interface{}) + for nk, nv := range flattenMap(newKey, nestedMap) { + flatMap[nk] = nv + } + case "time", "timeNano": + flatMap[newKey] = string(v.(json.Number)) + default: + flatMap[newKey] = v.(string) + } + } + return flatMap +} + +// Convert struct to flat map by first converting it to a map (via JSON) and flatten it afterwards +func structToFlatMap(s interface{}) map[string]string { + m := make(map[string]interface{}) + b, err := json.Marshal(s) + if err != nil { + log.Fatal().Err(err).Msg("Marshaling JSON failed") + } + + // Using a custom decoder to set 'UseNumber' which will preserver a string representation of + // time and timeNano instead of converting it to float64 + decoder := json.NewDecoder(strings.NewReader(string(b))) + decoder.UseNumber() + if err := decoder.Decode(&m); err != nil { + log.Fatal().Err(err).Msg("Unmarshaling JSON failed") + } + return flattenMap("", m) +} From ebcede71d989146c46d65a7a59b398dff5ac0df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Sun, 12 May 2024 18:38:41 +0000 Subject: [PATCH 7/9] Add possibility to disable/enable individual notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/config.yml | 3 +++ src/events.go | 41 +++++++++++++++++++++++------------------ src/types.go | 7 ++++--- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/config.yml b/src/config.yml index 805e6ddd..d0078edf 100644 --- a/src/config.yml +++ b/src/config.yml @@ -33,6 +33,7 @@ reporter: notifications: - name: "Alert me when watchtower/.* based container restarts" + enabled: false event: "Action": ["(start|stop)"] "Actor.Attributes.image": ["watchtower/.*"] @@ -41,6 +42,7 @@ notifications: - mail - gotify - name: "Alert me when unifi/.* based container dies with exitCode 1" + enabled: true event: "Action": ["(die|destroy)"] "Actor.Attributes.image": ["unifi/.*"] @@ -50,6 +52,7 @@ notifications: - mattermost - name: "Alert only on gotify when container dies with exitCode 0" + enabled: true event: "Action": ["(die|destroy)"] "Actor.Attributes.image": ["pihole/.*"] diff --git a/src/events.go b/src/events.go index 7e791a86..2e3e2b5d 100644 --- a/src/events.go +++ b/src/events.go @@ -20,31 +20,36 @@ func checkReporter(event events.Message) { for _, notification := range config.Notifications { - if matchEvent(event, notification) { + // only process enabled notifications + if notification.Enabled { + if matchEvent(event, notification) { - // check which reporters should be used - // if none are configured, notification is send to all enabled reporters - if len(notification.Notify) > 0 { + // check which reporters should be used + // if none are configured, notification is send to all enabled reporters + if len(notification.Notify) > 0 { - // remove disabled reporters - for _, reporter := range notification.Notify { - if !slices.Contains(config.EnabledReporter, reporter) { - log.Error().Str("reporter", reporter).Msg("Reporter not enabled") - notification.Notify = removeStringFromSliceInsensitive(notification.Notify, reporter) + // remove disabled reporters + for _, reporter := range notification.Notify { + if !slices.Contains(config.EnabledReporter, reporter) { + log.Error().Str("reporter", reporter).Msg("Reporter not enabled") + notification.Notify = removeStringFromSliceInsensitive(notification.Notify, reporter) + } + } + + // check if there are reporters left after removing disabled ones + if len(notification.Notify) > 0 { + log.Debug().Str("rule", notification.Name).Interface("using reporters", notification.Notify).Send() + processEvent(event, notification.Notify) + } else { + log.Error().Str("rule", notification.Name).Msg("No enabled reporter for this rule found") } - } - // check if there are reporters left after removing disabled ones - if len(notification.Notify) > 0 { - log.Debug().Str("rule", notification.Name).Interface("using reporters", notification.Notify).Send() - processEvent(event, notification.Notify) } else { - log.Error().Str("rule", notification.Name).Msg("No enabled reporter for this rule found") + processEvent(event, config.EnabledReporter) } - - } else { - processEvent(event, config.EnabledReporter) } + } else { + log.Debug().Msgf("Skipping disabled notification \"%s\"", notification.Name) } } diff --git a/src/types.go b/src/types.go index 13677b00..1aeda2cf 100644 --- a/src/types.go +++ b/src/types.go @@ -40,9 +40,10 @@ type options struct { } type notification struct { - Name string `yaml:"name"` - Event map[string][]string `yaml:"event"` - Notify []string `yaml:"notify"` + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + Event map[string][]string `yaml:"event"` + Notify []string `yaml:"notify"` } type Config struct { From f48a6cc8a6832e903679b05c7899a2e1f91b7520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Tue, 14 May 2024 20:40:10 +0000 Subject: [PATCH 8/9] No need for a slice of stings as individual rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/config.yml | 16 ++++++++-------- src/events.go | 45 +++++++++++++++++++++------------------------ src/types.go | 2 +- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/config.yml b/src/config.yml index d0078edf..3dbc47c4 100644 --- a/src/config.yml +++ b/src/config.yml @@ -35,8 +35,8 @@ notifications: - name: "Alert me when watchtower/.* based container restarts" enabled: false event: - "Action": ["(start|stop)"] - "Actor.Attributes.image": ["watchtower/.*"] + "Action": "(start|stop)" + "Actor.Attributes.image": "watchtower/.*" notify: - pushover - mail @@ -44,9 +44,9 @@ notifications: - name: "Alert me when unifi/.* based container dies with exitCode 1" enabled: true event: - "Action": ["(die|destroy)"] - "Actor.Attributes.image": ["unifi/.*"] - "Actor.Attributes.exitCode": ["1"] + "Action": [(die|destroy)" + "Actor.Attributes.image": "unifi/.*" + "Actor.Attributes.exitCode": "1" notify: - pushover - mattermost @@ -54,8 +54,8 @@ notifications: - name: "Alert only on gotify when container dies with exitCode 0" enabled: true event: - "Action": ["(die|destroy)"] - "Actor.Attributes.image": ["pihole/.*"] - "Actor.Attributes.exitCode": ["0"] + "Action": "(die|destroy)" + "Actor.Attributes.image": "pihole/.*" + "Actor.Attributes.exitCode": "0" notify: - gotify diff --git a/src/events.go b/src/events.go index 2e3e2b5d..15c38ca5 100644 --- a/src/events.go +++ b/src/events.go @@ -70,35 +70,32 @@ func matchEvent(event events.Message, notification notification) bool { // Convert the event to a flattend map eventMap := structToFlatMap(event) - for eventKey, rules := range notification.Event { - ruleString := strings.Join(rules, ", ") - for _, rule := range rules { - - // get the value of the event's eventKey - eventValue, keyExist := eventMap[eventKey] - - // Check if the key exists in the eventMap - if !keyExist { - log.Debug(). - Msgf("Eventkey \"%s\" does not exist in event", eventKey) - return false - } + for eventKey, rule := range notification.Event { - matched, err := regexp.MatchString(rule, eventValue) - if err != nil { - log.Error().Err(err).Msg("regex matching failed") - } + // get the value of the event's eventKey + eventValue, keyExist := eventMap[eventKey] - // regex did not match - if !matched { - log.Debug(). - Msgf("Rule \"%s: %s\" did not match", eventKey, ruleString) - return false - } + // Check if the key exists in the eventMap + if !keyExist { + log.Debug(). + Msgf("Eventkey \"%s\" does not exist in event", eventKey) + return false + } + matched, err := regexp.MatchString(rule, eventValue) + if err != nil { + log.Error().Err(err).Msg("regex matching failed") } + + // regex did not match + if !matched { + log.Debug(). + Msgf("Rule \"%s: %s\" did not match", eventKey, rule) + return false + } + log.Debug(). - Msgf("Rule \"%s: %s\" matched", eventKey, ruleString) + Msgf("Rule \"%s: %s\" matched", eventKey, rule) } log.Debug().Str("name", notification.Name).Msg("All rules matched. Triggering notification") return true diff --git a/src/types.go b/src/types.go index 1aeda2cf..2225971f 100644 --- a/src/types.go +++ b/src/types.go @@ -42,7 +42,7 @@ type options struct { type notification struct { Name string `yaml:"name"` Enabled bool `yaml:"enabled"` - Event map[string][]string `yaml:"event"` + Event map[string]string `yaml:"event"` Notify []string `yaml:"notify"` } From 4f2825d3d454156d974a69b103eb260b1a441da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6nig?= Date: Wed, 15 May 2024 21:25:12 +0000 Subject: [PATCH 9/9] Compile regex only once during startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian König --- src/config.yml | 2 +- src/events.go | 14 +++----- src/main.go | 97 ++++++++++++++++++++++++++++++++------------------ src/types.go | 13 ++++--- 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/config.yml b/src/config.yml index 3dbc47c4..3a037547 100644 --- a/src/config.yml +++ b/src/config.yml @@ -44,7 +44,7 @@ notifications: - name: "Alert me when unifi/.* based container dies with exitCode 1" enabled: true event: - "Action": [(die|destroy)" + "Action": "[(die|destroy)]" "Actor.Attributes.image": "unifi/.*" "Actor.Attributes.exitCode": "1" notify: diff --git a/src/events.go b/src/events.go index 15c38ca5..8977eec2 100644 --- a/src/events.go +++ b/src/events.go @@ -1,7 +1,6 @@ package main import ( - "regexp" "slices" "strings" "time" @@ -60,7 +59,7 @@ func checkReporter(event events.Message) { func matchEvent(event events.Message, notification notification) bool { // only proceed when rules are set - if len(notification.Event) == 0 { + if len(notification.Regex) == 0 { log.Error().Str("name", notification.Name).Msg("No rules configured. Skipping") return false } @@ -70,7 +69,7 @@ func matchEvent(event events.Message, notification notification) bool { // Convert the event to a flattend map eventMap := structToFlatMap(event) - for eventKey, rule := range notification.Event { + for eventKey, regex := range notification.Regex { // get the value of the event's eventKey eventValue, keyExist := eventMap[eventKey] @@ -82,20 +81,17 @@ func matchEvent(event events.Message, notification notification) bool { return false } - matched, err := regexp.MatchString(rule, eventValue) - if err != nil { - log.Error().Err(err).Msg("regex matching failed") - } + matched := regex.MatchString(eventValue) // regex did not match if !matched { log.Debug(). - Msgf("Rule \"%s: %s\" did not match", eventKey, rule) + Msgf("Rule \"%s: %s\" did not match", eventKey, notification.Event[eventKey]) return false } log.Debug(). - Msgf("Rule \"%s: %s\" matched", eventKey, rule) + Msgf("Rule \"%s: %s\" matched", eventKey, notification.Event[eventKey]) } log.Debug().Str("name", notification.Name).Msg("All rules matched. Triggering notification") return true diff --git a/src/main.go b/src/main.go index f96a56d6..a5dc1063 100644 --- a/src/main.go +++ b/src/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "time" "github.com/docker/docker/api/types" @@ -52,7 +53,45 @@ func init() { zerolog.SetGlobalLevel(zerolog.DebugLevel) } - parseArgs() + parseReporter() + parseRegex() + +} + +func loadConfig() { + configFile, err := filepath.Abs(configFilePath) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to set config file path") + } + + buf, err := os.ReadFile(configFile) + if err != nil { + log.Fatal().Err(err).Msg("Failed to read config file") + } + + err = yaml.Unmarshal(buf, &config) + if err != nil { + log.Fatal().Err(err).Msg("Failed to parse config file") + } +} + +func parseReporter() { + + //Parse Enabled reportes + + if config.Reporter.Gotify.Enabled { + config.EnabledReporter = append(config.EnabledReporter, "gotify") + } + if config.Reporter.Mattermost.Enabled { + config.EnabledReporter = append(config.EnabledReporter, "mattermost") + } + if config.Reporter.Pushover.Enabled { + config.EnabledReporter = append(config.EnabledReporter, "pushover") + } + if config.Reporter.Mail.Enabled { + config.EnabledReporter = append(config.EnabledReporter, "mail") + } if config.Reporter.Pushover.Enabled { if len(config.Reporter.Pushover.APIToken) == 0 { @@ -94,43 +133,32 @@ func init() { } } -func loadConfig() { - configFile, err := filepath.Abs(configFilePath) - - if err != nil { - log.Fatal().Err(err).Msg("Failed to set config file path") - } - - buf, err := os.ReadFile(configFile) - if err != nil { - log.Fatal().Err(err).Msg("Failed to read config file") - } - - err = yaml.Unmarshal(buf, &config) - if err != nil { - log.Fatal().Err(err).Msg("Failed to parse config file") - } -} - -func parseArgs() { - - //Parse Enabled reportes +func parseRegex() { + + // check any notification is configure at all + if len(config.Notifications) > 0 { + // for each notification + for i, notification := range config.Notifications { + + // only process enabled notifications + if notification.Enabled { + config.Notifications[i].Regex = make(map[string]regexp.Regexp) + // process every rule of the notification + for eventKey, rule := range notification.Event { + regex, err := regexp.Compile(rule) + if err != nil { + log.Fatal().Err(err).Msg("Failed to compile regex") + } + config.Notifications[i].Regex[eventKey] = *regex + } + } else { + log.Debug().Msgf("Not parsing regex of disabled notification \"%s\"", notification.Name) + } + } - if config.Reporter.Gotify.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "gotify") - } - if config.Reporter.Mattermost.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "mattermost") - } - if config.Reporter.Pushover.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "pushover") - } - if config.Reporter.Mail.Enabled { - config.EnabledReporter = append(config.EnabledReporter, "mail") } } - func configureLogger() { // Configure time/timestamp format @@ -156,7 +184,6 @@ func main() { startup_message := buildStartupMessage(startup_time) sendNotifications(startup_time, startup_message, "Starting docker event monitor", config.EnabledReporter) - filterArgs := filters.NewArgs() for key, values := range config.Options.Filter { for _, value := range values { diff --git a/src/types.go b/src/types.go index 2225971f..9d596fab 100644 --- a/src/types.go +++ b/src/types.go @@ -1,5 +1,9 @@ package main +import ( + "regexp" +) + type pushoverConfig struct { Enabled bool APIToken string `yaml:"api_token"` @@ -40,10 +44,11 @@ type options struct { } type notification struct { - Name string `yaml:"name"` - Enabled bool `yaml:"enabled"` - Event map[string]string `yaml:"event"` - Notify []string `yaml:"notify"` + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + Event map[string]string `yaml:"event"` + Notify []string `yaml:"notify"` + Regex map[string]regexp.Regexp `yaml:"-"` } type Config struct {