diff --git a/src/config.yml b/src/config.yml index 3ee2ce24..3a037547 100644 --- a/src/config.yml +++ b/src/config.yml @@ -1,7 +1,10 @@ --- options: - filter_strings: ["type=container"] - exclude_strings: ["Action=exec_start", "Action=exec_die", "Action=exec_create"] + filter: + type: ["container"] + # container: ["some_container_name"] + # image: ["some_image_name"] + # event: ["start", "stop", "die", "destroy"] log_level: debug server_tag: My Server @@ -28,3 +31,31 @@ reporter: channel: Docker Event Channel user: Docker Event Bot +notifications: + - name: "Alert me when watchtower/.* based container restarts" + enabled: false + event: + "Action": "(start|stop)" + "Actor.Attributes.image": "watchtower/.*" + notify: + - pushover + - mail + - gotify + - 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" + notify: + - pushover + - mattermost + + - 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" + notify: + - gotify diff --git a/src/events.go b/src/events.go index 985f9271..8977eec2 100644 --- a/src/events.go +++ b/src/events.go @@ -1,7 +1,7 @@ package main import ( - "encoding/json" + "slices" "strings" "time" @@ -11,7 +11,93 @@ 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 { + + // 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 { + + // 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") + } + + } else { + processEvent(event, config.EnabledReporter) + } + } + } else { + log.Debug().Msgf("Skipping disabled notification \"%s\"", notification.Name) + } + } + + } else { + processEvent(event, config.EnabledReporter) + } +} +func matchEvent(event events.Message, notification notification) bool { + + // only proceed when rules are set + if len(notification.Regex) == 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, regex := range notification.Regex { + + // 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 := regex.MatchString(eventValue) + + // regex did not match + if !matched { + log.Debug(). + Msgf("Rule \"%s: %s\" did not match", eventKey, notification.Event[eventKey]) + return false + } + + log.Debug(). + Msgf("Rule \"%s: %s\" matched", eventKey, notification.Event[eventKey]) + } + log.Debug().Str("name", notification.Name).Msg("All rules matched. Triggering notification") + 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 @@ -80,7 +166,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) } @@ -135,94 +221,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/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 5257e847..a5dc1063 100644 --- a/src/main.go +++ b/src/main.go @@ -6,7 +6,7 @@ import ( "fmt" "os" "path/filepath" - "strings" + "regexp" "time" "github.com/docker/docker/api/types" @@ -21,7 +21,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 @@ -53,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 { @@ -95,70 +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 (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") +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) + } } - //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 { - 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 @@ -180,12 +180,12 @@ 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.Filter { + for key, values := range config.Options.Filter { for _, value := range values { filterArgs.Add(key, value) } @@ -208,15 +208,7 @@ 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) + 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..f8e3f63f 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,11 +84,11 @@ 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 { - 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/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/startup.go b/src/startup.go index 4967a820..092f2c82 100644 --- a/src/startup.go +++ b/src/startup.go @@ -1,7 +1,6 @@ package main import ( - "strconv" "strings" "time" @@ -52,16 +51,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") - } - - if len(config.Options.ExcludeStrings) > 0 { - startup_message_builder.WriteString("\nExcludeStrings: " + strings.Join(config.Options.ExcludeStrings, " ")) - } else { - startup_message_builder.WriteString("\nExcludeStrings: none") + startup_message_builder.WriteString("\nGlobal filter: none") } return startup_message_builder.String() @@ -71,21 +64,14 @@ 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). 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 { - 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 -} diff --git a/src/types.go b/src/types.go index 6d539725..9d596fab 100644 --- a/src/types.go +++ b/src/types.go @@ -1,16 +1,20 @@ package main -type pushover struct { +import ( + "regexp" +) + +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 +23,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,23 +31,29 @@ 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 { - FilterStrings []string `yaml:"filter_strings,flow"` - ExcludeStrings []string `yaml:"exclude_strings,flow"` - 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 { + 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 { Reporter reporter Options options - EnabledReporter []string `yaml:"-"` - Filter map[string][]string `yaml:"-"` - Exclude map[string][]string `yaml:"-"` + EnabledReporter []string `yaml:"-"` + Notifications []notification `yaml:"notifications"` } diff --git a/src/utils.go b/src/utils.go new file mode 100644 index 00000000..9345291c --- /dev/null +++ b/src/utils.go @@ -0,0 +1,105 @@ +package main + +//various helper functions +import ( + "encoding/json" + "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 +} + +// 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/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) }