Skip to content

Commit 508ea9c

Browse files
committed
Icinga DB Source: Address Review
- Update IGL to include rules and rule versions in the event. Thus, being available in JSON and no longer in a HTTP header. - Simplify schema rule version incrementation logic by ignoring the edge case of sources w/o rules. In this case, the rule will not be reset, but further incremented. - Fix import names. - Abort processing incoming events for unknown rules. This could only occur if there is a race between changing rules and a processed event, as rules are initially checked. - Erase race condition between mutex locks and unlocks for HTTP rule endpoint and ensure debug dump rules has a mutex all the time. - Change the SQL CHECK for the listener_password_hash to allow different versions of bcrypt. The "$2y$" thingy is PHP specific to highlight "old" bcrypt passwords being affected by a PHP implementation bug, not a bcrypt specification bug or change.
1 parent 4f46d7d commit 508ea9c

File tree

13 files changed

+58
-100
lines changed

13 files changed

+58
-100
lines changed

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ module github.com/icinga/icinga-notifications
22

33
go 1.24.0
44

5+
toolchain go1.24.6
6+
57
require (
68
github.com/creasty/defaults v1.8.0
79
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
810
github.com/emersion/go-smtp v0.24.0
911
github.com/google/uuid v1.6.0
10-
github.com/icinga/icinga-go-library v0.7.3-0.20250904130608-5032573a325e
12+
github.com/icinga/icinga-go-library v0.7.3-0.20250909100113-20db23663e45
1113
github.com/jhillyerd/enmime v1.3.0
1214
github.com/jmoiron/sqlx v1.4.0
1315
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
3535
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
3636
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
3737
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
38-
github.com/icinga/icinga-go-library v0.7.3-0.20250904130608-5032573a325e h1:yZPWPPCHKozWRm9VedDHd8igJh9uI4U2CJdgl1On+/4=
39-
github.com/icinga/icinga-go-library v0.7.3-0.20250904130608-5032573a325e/go.mod h1:exEJdfik2GPYrvZM6Gn4BXIBLIGg6OrCCMnILT+mTUs=
38+
github.com/icinga/icinga-go-library v0.7.3-0.20250909100113-20db23663e45 h1:Wz6ttTYgYB7y8FH7snBSnnllLuzhE0QSp6m3P9b/QfM=
39+
github.com/icinga/icinga-go-library v0.7.3-0.20250909100113-20db23663e45/go.mod h1:uCENf5EVhNVvXTvhB+jXiwRB2NdLlz8cymseOM4qmI0=
4040
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
4141
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
4242
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=

internal/config/rule.go

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type SourceRulesInfo struct {
1212
//
1313
// It is a monotonically increasing number that is updated whenever a rule is added, modified, or deleted.
1414
// With each state change of the rules referenced by RuleIDs, the Version will always be incremented
15-
// by 1, starting from 0. When there are no configured rules for the source, Version will be reset to 0.
15+
// by 1, effectively starting at 1.
1616
//
1717
// The Version is not unique across different sources, but it is unique for a specific source at a specific time.
1818
Version uint64
@@ -36,6 +36,10 @@ func (r *RuntimeConfig) applyPendingRules() {
3636
// Keep track of sources the rules were updated for, so we can update their version later.
3737
updatedSources := make(map[int64]struct{})
3838

39+
if r.RulesBySource == nil {
40+
r.RulesBySource = make(map[int64]*SourceRulesInfo)
41+
}
42+
3943
incrementalApplyPending(
4044
r,
4145
&r.Rules, &r.configChange.Rules,
@@ -49,17 +53,16 @@ func (r *RuntimeConfig) applyPendingRules() {
4953
}
5054

5155
newElement.Escalations = make(map[int64]*rule.Escalation)
52-
updatedSources[newElement.SourceID] = struct{}{}
53-
if r.RulesBySource == nil {
54-
r.RulesBySource = make(map[int64]*SourceRulesInfo)
55-
}
5656

5757
// Add the new rule to the per-source rules cache.
58-
if sourceInfo := r.RulesBySource[newElement.SourceID]; sourceInfo == nil {
59-
r.RulesBySource[newElement.SourceID] = &SourceRulesInfo{RuleIDs: []int64{newElement.ID}}
60-
} else {
58+
if sourceInfo, ok := r.RulesBySource[newElement.SourceID]; ok {
6159
sourceInfo.RuleIDs = append(sourceInfo.RuleIDs, newElement.ID)
60+
} else {
61+
r.RulesBySource[newElement.SourceID] = &SourceRulesInfo{RuleIDs: []int64{newElement.ID}}
6262
}
63+
64+
updatedSources[newElement.SourceID] = struct{}{}
65+
6366
return nil
6467
},
6568
func(curElement, update *rule.Rule) error {
@@ -79,6 +82,7 @@ func (r *RuntimeConfig) applyPendingRules() {
7982

8083
// ObjectFilter{,Expr} are being initialized by config.IncrementalConfigurableInitAndValidatable.
8184
curElement.ObjectFilterExpr = update.ObjectFilterExpr
85+
8286
updatedSources[curElement.SourceID] = struct{}{}
8387

8488
return nil
@@ -88,10 +92,10 @@ func (r *RuntimeConfig) applyPendingRules() {
8892
sourceInfo.RuleIDs = slices.DeleteFunc(sourceInfo.RuleIDs, func(id int64) bool {
8993
return id == delElement.ID
9094
})
91-
if len(sourceInfo.RuleIDs) == 0 {
92-
delete(r.RulesBySource, delElement.SourceID) // Remove the source if no rules are left.
93-
}
9495
}
96+
97+
updatedSources[delElement.SourceID] = struct{}{}
98+
9599
return nil
96100
},
97101
)
@@ -101,11 +105,8 @@ func (r *RuntimeConfig) applyPendingRules() {
101105
// or deleted only once per applyPendingRules call, even if multiple rules from the same source
102106
// were changed.
103107
for sourceID := range updatedSources {
104-
if r.RulesBySource != nil {
105-
if sourceInfo, ok := r.RulesBySource[sourceID]; ok {
106-
// Invariant: len(sourceInfo.RuleIDs) > 0 if the source exists in RulesBySource (see delete above).
107-
sourceInfo.Version++
108-
}
108+
if sourceInfo, ok := r.RulesBySource[sourceID]; ok {
109+
sourceInfo.Version++
109110
}
110111
}
111112

internal/config/runtime.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,15 @@ func (r *RuntimeConfig) GetRuleEscalation(escalationID int64) *rule.Escalation {
165165
return nil
166166
}
167167

168+
// RulesVersionString formats a rule version.
169+
func (r *RuntimeConfig) RulesVersionString(version uint64) string {
170+
if version > 0 {
171+
return fmt.Sprintf("%x", version)
172+
}
173+
174+
return source.EmptyRulesVersion
175+
}
176+
168177
// GetRulesVersionFor retrieves the version of the rules for a specific source.
169178
//
170179
// It returns the version as a hexadecimal string, which is a representation of the version number.
@@ -177,11 +186,11 @@ func (r *RuntimeConfig) GetRulesVersionFor(srcId int64) string {
177186

178187
if r.RulesBySource != nil {
179188
if sourceInfo, ok := r.RulesBySource[srcId]; ok && sourceInfo.Version > 0 {
180-
return fmt.Sprintf("%x", sourceInfo.Version)
189+
return r.RulesVersionString(sourceInfo.Version)
181190
}
182191
}
183192

184-
return source.EmptyRulesVersion
193+
return r.RulesVersionString(0)
185194
}
186195

187196
// GetContact returns *recipient.Contact by the given username (case-insensitive).

internal/event/event.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import (
55
"context"
66
"errors"
77
"fmt"
8-
"strconv"
9-
"strings"
108
"time"
119

1210
"github.com/icinga/icinga-go-library/database"
@@ -33,8 +31,6 @@ type Event struct {
3331
SourceId int64 `json:"-"`
3432
ID int64 `json:"-"`
3533

36-
MatchedRules map[int64]struct{} `json:"-"` // MatchedRules contains the event rule IDs received from source.
37-
3834
*baseEv.Event `json:",inline"`
3935
}
4036

@@ -85,28 +81,6 @@ func (e *Event) String() string {
8581
return fmt.Sprintf("[time=%s type=%q severity=%s]", e.Time, e.Type, e.Severity.String())
8682
}
8783

88-
// LoadMatchedRulesFromString parses a comma-separated string of rule IDs and loads them into the event's MatchedRules.
89-
//
90-
// Returns an error if any of the rule IDs cannot be parsed as an int64.
91-
func (e *Event) LoadMatchedRulesFromString(ruleIdsStr string) error {
92-
if e.MatchedRules == nil {
93-
e.MatchedRules = make(map[int64]struct{})
94-
}
95-
96-
if ruleIdsStr == "" {
97-
return nil // No rule IDs to load, nothing to do.
98-
}
99-
100-
for _, ruleIdStr := range strings.Split(ruleIdsStr, ",") {
101-
ruleId, err := strconv.ParseInt(ruleIdStr, 10, 64)
102-
if err != nil {
103-
return fmt.Errorf("cannot parse rule ID %q: %w", ruleIdStr, err)
104-
}
105-
e.MatchedRules[ruleId] = struct{}{}
106-
}
107-
return nil
108-
}
109-
11084
func (e *Event) FullString() string {
11185
var b bytes.Buffer
11286
_, _ = fmt.Fprintf(&b, "Event:\n")

internal/incident/db_types.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package incident
33
import (
44
"context"
55
"github.com/icinga/icinga-go-library/database"
6-
baseEv "github.com/icinga/icinga-go-library/notifications/event"
6+
"github.com/icinga/icinga-go-library/notifications/event"
77
"github.com/icinga/icinga-go-library/types"
88
"github.com/icinga/icinga-notifications/internal/recipient"
99
"github.com/jmoiron/sqlx"
@@ -74,8 +74,8 @@ type HistoryRow struct {
7474
Time types.UnixMilli `db:"time"`
7575
Type HistoryEventType `db:"type"`
7676
ChannelID types.Int `db:"channel_id"`
77-
NewSeverity baseEv.Severity `db:"new_severity"`
78-
OldSeverity baseEv.Severity `db:"old_severity"`
77+
NewSeverity event.Severity `db:"new_severity"`
78+
OldSeverity event.Severity `db:"old_severity"`
7979
NewRecipientRole ContactRole `db:"new_recipient_role"`
8080
OldRecipientRole ContactRole `db:"old_recipient_role"`
8181
Message types.String `db:"message"`

internal/incident/incident.go

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -394,25 +394,16 @@ func (i *Incident) handleMuteUnmute(ctx context.Context, tx *sqlx.Tx, ev *event.
394394
}
395395

396396
// applyMatchingRules walks through the rule IDs obtained from source and generates a RuleMatched history entry.
397-
//
398-
// Unknown event rule IDs are ignored, and a warning is logged. Otherwise, if the rule is not already matched on this
399-
// incident's Object with previous events, it is added to the incident rules and a corresponding history is generated.
400-
//
401-
// Returns an error on any database failure.
402397
func (i *Incident) applyMatchingRules(ctx context.Context, tx *sqlx.Tx, ev *event.Event) error {
403398
if i.Rules == nil {
404399
i.Rules = make(map[int64]struct{})
405400
}
406401

407-
for ruleID := range ev.MatchedRules {
408-
r, ok := i.runtimeConfig.Rules[ruleID]
402+
for _, ruleId := range ev.RuleIds {
403+
r, ok := i.runtimeConfig.Rules[ruleId]
409404
if !ok {
410-
// Usually, sources aren't expected to deliberately send rule IDs that don't exist, but if they do,
411-
// we warn about it and move on. Other causes of this could be a rule being deleted just right after
412-
// the version validation by the listener, and before the incident acquired a read lock on the runtime
413-
// config in the ProcessEvent method.
414-
i.logger.Warnw("Event refers to non-existing event rule, might got deleted", zap.Int64("rule_id", ruleID))
415-
continue
405+
i.logger.Errorw("Event refers to non-existing event rule, might got deleted", zap.Int64("rule_id", ruleId))
406+
return fmt.Errorf("cannot apply unknown rule %d", ruleId)
416407
}
417408

418409
if _, ok := i.Rules[r.ID]; !ok {

internal/listener/listener.go

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"github.com/icinga/icinga-go-library/database"
1010
"github.com/icinga/icinga-go-library/logging"
1111
baseEv "github.com/icinga/icinga-go-library/notifications/event"
12-
"github.com/icinga/icinga-go-library/notifications/source"
1312
"github.com/icinga/icinga-notifications/internal"
1413
"github.com/icinga/icinga-notifications/internal/config"
1514
"github.com/icinga/icinga-notifications/internal/daemon"
@@ -124,38 +123,31 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, r *http.Request) {
124123
return
125124
}
126125

127-
src, validAuth := l.sourceFromAuthOrAbort(w, r)
128-
if !validAuth {
126+
src, isAuthenticated := l.sourceFromAuthOrAbort(w, r)
127+
if !isAuthenticated {
128+
// Listener.sourceFromAuthOrAbort writes 401 response by itself; no abort() necessary.
129129
return
130130
}
131131

132-
ruleIdsStr := r.Header.Get(source.XIcingaRulesId)
133-
ruleVersion := r.Header.Get(source.XIcingaRulesVersion)
132+
var ev event.Event
133+
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
134+
abort(http.StatusBadRequest, nil, "cannot parse JSON body: %v", err)
135+
return
136+
}
134137

135138
// If the client uses an outdated rules version, reject the request but send also the current rules version
136139
// and rules for this source back to the client, so it can retry the request with the updated rules.
137-
if latestRuleVersion := l.runtimeConfig.GetRulesVersionFor(src.ID); ruleVersion != latestRuleVersion {
140+
if latestRuleVersion := l.runtimeConfig.GetRulesVersionFor(src.ID); ev.RulesVersion != latestRuleVersion {
138141
w.WriteHeader(http.StatusPreconditionFailed)
139142
l.writeSourceRulesInfo(w, src)
140143

141144
l.logger.Debugw("Abort event processing due to outdated rules version",
142145
zap.String("current_version", latestRuleVersion),
143-
zap.String("provided_version", ruleVersion),
146+
zap.String("provided_version", ev.RulesVersion),
144147
zap.String("source", src.Name))
145148
return
146149
}
147150

148-
var ev event.Event
149-
if err := ev.LoadMatchedRulesFromString(ruleIdsStr); err != nil {
150-
abort(http.StatusBadRequest, nil, "cannot parse %s header: %v", source.XIcingaRulesId, err)
151-
return
152-
}
153-
154-
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
155-
abort(http.StatusBadRequest, nil, "cannot parse JSON body: %v", err)
156-
return
157-
}
158-
159151
ev.Time = time.Now()
160152
ev.SourceId = src.ID
161153
if ev.Type == baseEv.TypeUnknown {
@@ -301,12 +293,11 @@ func (l *Listener) DumpRules(w http.ResponseWriter, r *http.Request) {
301293
}
302294

303295
l.runtimeConfig.RLock()
304-
rules := l.runtimeConfig.Rules
305-
l.runtimeConfig.RUnlock()
296+
defer l.runtimeConfig.RUnlock()
306297

307298
enc := json.NewEncoder(w)
308299
enc.SetIndent("", " ")
309-
_ = enc.Encode(rules)
300+
_ = enc.Encode(l.runtimeConfig.Rules)
310301
}
311302

312303
// writeSourceRulesInfo writes the rules information for a specific source to the response writer.
@@ -319,14 +310,13 @@ func (l *Listener) writeSourceRulesInfo(w http.ResponseWriter, source *config.So
319310
}
320311

321312
var resp Response
322-
resp.Version = l.runtimeConfig.GetRulesVersionFor(source.ID)
323313

324314
func() { // Use a function to ensure that the RLock and RUnlock are called before writing the response.
325315
l.runtimeConfig.RLock()
326316
defer l.runtimeConfig.RUnlock()
327317

328-
sourceInfo := l.runtimeConfig.RulesBySource[source.ID]
329-
if sourceInfo != nil {
318+
if sourceInfo, ok := l.runtimeConfig.RulesBySource[source.ID]; ok {
319+
resp.Version = l.runtimeConfig.RulesVersionString(sourceInfo.Version)
330320
resp.Rules = make(map[int64]*rule.Rule, len(sourceInfo.RuleIDs))
331321
for _, rID := range sourceInfo.RuleIDs {
332322
resp.Rules[rID] = l.runtimeConfig.Rules[rID]

internal/rule/rule.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,6 @@ type Rule struct {
2222

2323
// IncrementalInitAndValidate implements the config.IncrementalConfigurableInitAndValidatable interface.
2424
func (r *Rule) IncrementalInitAndValidate() error {
25-
// if r.ObjectFilterExpr.Valid {
26-
// f, err := filter.Parse(r.ObjectFilterExpr.String)
27-
// if err != nil {
28-
// return err
29-
// }
30-
31-
// r.ObjectFilter = f
32-
// }
33-
3425
return nil
3526
}
3627

schema/mysql/schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ CREATE TABLE source (
217217
-- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures
218218
-- that listener_password_hash can only be populated with bcrypt hashes.
219219
-- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend
220-
CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash LIKE '$2y$%'),
220+
CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash LIKE '$2_$%'),
221221

222222
CONSTRAINT pk_source PRIMARY KEY (id)
223223
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

0 commit comments

Comments
 (0)