@@ -13,6 +13,7 @@ import (
1313 "github.com/icinga/icinga-notifications/internal/daemon"
1414 "github.com/icinga/icinga-notifications/internal/event"
1515 "github.com/icinga/icinga-notifications/internal/incident"
16+ "github.com/icinga/icinga-notifications/internal/rule"
1617 "go.uber.org/zap"
1718 "net/http"
1819 "time"
@@ -39,9 +40,11 @@ func NewListener(db *database.DB, runtimeConfig *config.RuntimeConfig, logs *log
3940 debugMux .HandleFunc ("/dump-config" , l .DumpConfig )
4041 debugMux .HandleFunc ("/dump-incidents" , l .DumpIncidents )
4142 debugMux .HandleFunc ("/dump-schedules" , l .DumpSchedules )
43+ debugMux .HandleFunc ("/dump-rules" , l .DumpRules )
4244
4345 l .mux .Handle ("/debug/" , http .StripPrefix ("/debug" , l .requireDebugAuth (debugMux )))
4446 l .mux .HandleFunc ("/process-event" , l .ProcessEvent )
47+ l .mux .HandleFunc ("/event-rules" , l .RulesForFilters )
4548 return l
4649}
4750
@@ -82,7 +85,45 @@ func (l *Listener) Run(ctx context.Context) error {
8285 }
8386}
8487
85- func (l * Listener ) ProcessEvent (w http.ResponseWriter , req * http.Request ) {
88+ // getRuleVersion returns the latest rule version.
89+ //
90+ // Technically, the rule version is an encoded string representation of the latest changed_at value from each rule.
91+ // Being an implementation detail, it might change over time. For the moment, a simple equality check is enough.
92+ func (l * Listener ) getRuleVersion () string {
93+ l .runtimeConfig .RLock ()
94+ defer l .runtimeConfig .RUnlock ()
95+
96+ var latest time.Time
97+ for _ , r := range l .runtimeConfig .Rules {
98+ if t := r .ChangedAt .Time (); t .After (latest ) {
99+ latest = t
100+ }
101+ }
102+
103+ if latest .IsZero () {
104+ return "NA"
105+ }
106+
107+ return fmt .Sprintf ("%x" , latest .UnixNano ())
108+ }
109+
110+ // sourceFromAuthOrAbort extracts a *config.Source from the HTTP Basic Auth. If the credentials are wrong, (nil, false) is
111+ // returned and 401 was written back to the response writer.
112+ func (l * Listener ) sourceFromAuthOrAbort (w http.ResponseWriter , r * http.Request ) (* config.Source , bool ) {
113+ if authUser , authPass , authOk := r .BasicAuth (); authOk {
114+ src := l .runtimeConfig .GetSourceFromCredentials (authUser , authPass , l .logger )
115+ if src != nil {
116+ return src , true
117+ }
118+ }
119+
120+ w .Header ().Set ("WWW-Authenticate" , `Basic realm="icinga-notifications"` )
121+ w .WriteHeader (http .StatusUnauthorized )
122+ _ , _ = fmt .Fprintln (w , "please provide the debug-password as basic auth credentials (user is ignored)" )
123+ return nil , false
124+ }
125+
126+ func (l * Listener ) ProcessEvent (w http.ResponseWriter , r * http.Request ) {
86127 // abort the current connection by sending the status code and an error both to the log and back to the client.
87128 abort := func (statusCode int , ev * event.Event , format string , a ... any ) {
88129 msg := format
@@ -99,24 +140,37 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) {
99140 logger .Debugw ("Abort listener submitted event processing" )
100141 }
101142
102- if req .Method != http .MethodPost {
143+ if r .Method != http .MethodPost {
103144 abort (http .StatusMethodNotAllowed , nil , "POST required" )
104145 return
105146 }
106147
107- var source * config. Source
108- if authUser , authPass , authOk := req . BasicAuth (); authOk {
109- source = l . runtimeConfig . GetSourceFromCredentials ( authUser , authPass , l . logger )
148+ source , validAuth := l . sourceFromAuthOrAbort ( w , r )
149+ if ! validAuth {
150+ return
110151 }
111- if source == nil {
112- w .Header ().Set ("WWW-Authenticate" , `Basic realm="icinga-notifications"` )
113- abort (http .StatusUnauthorized , nil , "HTTP authorization required" )
152+
153+ ruleIdsStr := r .Header .Get ("X-Rule-Ids" )
154+ ruleVersion := r .Header .Get ("X-Rule-Version" )
155+
156+ if latestRuleVersion := l .getRuleVersion (); ruleVersion != latestRuleVersion {
157+ abort (http .StatusFailedDependency ,
158+ nil ,
159+ "X-Rule-Version %q does not match %q, refetch rules" ,
160+ ruleVersion ,
161+ latestRuleVersion )
162+ return
163+ }
164+
165+ // TODO: parse and verify ruleIdsStr
166+ if ruleIdsStr == "" {
167+ // Case for empty X-Rule-Ids where the event was just sent to check if new rules are available (previous if).
168+ abort (http .StatusNoContent , nil , "dismissed event due to empty X-Rule-Ids" )
114169 return
115170 }
116171
117172 var ev event.Event
118- err := json .NewDecoder (req .Body ).Decode (& ev )
119- if err != nil {
173+ if err := json .NewDecoder (r .Body ).Decode (& ev ); err != nil {
120174 abort (http .StatusBadRequest , nil , "cannot parse JSON body: %v" , err )
121175 return
122176 }
@@ -136,8 +190,11 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) {
136190 return
137191 }
138192
193+ ev .ExtraTags = make (map [string ]string )
194+ ev .ExtraTags ["rules" ] = ruleIdsStr
195+
139196 l .logger .Infow ("Processing event" , zap .String ("event" , ev .String ()))
140- err = incident .ProcessEvent (context .Background (), l .db , l .logs , l .runtimeConfig , & ev )
197+ err : = incident .ProcessEvent (context .Background (), l .db , l .logs , l .runtimeConfig , & ev )
141198 if errors .Is (err , event .ErrSuperfluousStateChange ) || errors .Is (err , event .ErrSuperfluousMuteUnmuteEvent ) {
142199 abort (http .StatusNotAcceptable , & ev , "%v" , err )
143200 return
@@ -149,11 +206,20 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) {
149206
150207 l .logger .Infow ("Successfully processed event" , zap .String ("event" , ev .String ()))
151208
152- w .WriteHeader (http .StatusOK )
209+ w .WriteHeader (http .StatusAccepted )
153210 _ , _ = fmt .Fprintln (w , "event processed successfully" )
154211 _ , _ = fmt .Fprintln (w )
155212}
156213
214+ func (l * Listener ) RulesForFilters (w http.ResponseWriter , r * http.Request ) {
215+ _ , validAuth := l .sourceFromAuthOrAbort (w , r )
216+ if ! validAuth {
217+ return
218+ }
219+
220+ l .DumpRules (w , r )
221+ }
222+
157223// requireDebugAuth is a middleware that checks if the valid debug password was provided. If there is no password
158224// configured or the supplied password is incorrect, it sends an error code and does not redirect the request.
159225func (l * Listener ) requireDebugAuth (next http.Handler ) http.Handler {
@@ -256,3 +322,28 @@ func (l *Listener) DumpSchedules(w http.ResponseWriter, r *http.Request) {
256322 _ , _ = fmt .Fprintln (w )
257323 }
258324}
325+
326+ // DumpRules is used as /debug prefixed endpoint to dump all rules. The authorization has to be done beforehand.
327+ func (l * Listener ) DumpRules (w http.ResponseWriter , r * http.Request ) {
328+ if r .Method != http .MethodGet {
329+ w .WriteHeader (http .StatusMethodNotAllowed )
330+ _ , _ = fmt .Fprintln (w , "GET required" )
331+ return
332+ }
333+
334+ type Response struct {
335+ Version string
336+ Rules map [int64 ]* rule.Rule
337+ }
338+
339+ var resp Response
340+ resp .Version = l .getRuleVersion ()
341+
342+ l .runtimeConfig .RLock ()
343+ resp .Rules = l .runtimeConfig .Rules
344+ l .runtimeConfig .RUnlock ()
345+
346+ enc := json .NewEncoder (w )
347+ enc .SetIndent ("" , " " )
348+ _ = enc .Encode (resp )
349+ }
0 commit comments