Diff -- /var/www/vhosts/3dshawn.com/site1/_alert_dispatch.pl
Diff

/var/www/vhosts/3dshawn.com/site1/_alert_dispatch.pl

modified on local at 2026-07-12 23:13:09

Added
+46
lines
Removed
-0
lines
Context
593
unchanged
Blobs
from a53566efc691
to dfc7f017bdf8
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- alert dispatcher
44#
55# Cron entry point (NOT a CGI). Walks FILE_CHANGES + SCHEMA_CHANGE rows
66# inserted since the last cursor tick, evaluates every ACTIVE rule
77# against each new row, and POSTs matching payloads to the configured
88# webhook (Slack / Discord / generic HTTP). Every attempt is logged
99# to ALERT_DELIVERIES for the operator's audit trail.
1010#
1111# Config: /etc/drift_sense/drift_sense.conf (via MODS::Config).
1212# Log: /var/log/drift_sense/alert_dispatch.log (via cron redirect).
1313#======================================================================
1414use strict;
1515use warnings;
1616use lib '/var/www/vhosts/3dshawn.com/site1';
1717use POSIX ();
1818use Symbol ();
1919use JSON::PP;
2020use MODS::Config;
2121use MODS::DBConnect;
2222use MODS::Webhook;
2323
2424$| = 1;
2525my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
2626
2727my $cfg = MODS::Config->new;
2828my $db = MODS::DBConnect->new;
2929my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
3030
3131my $public_url = $cfg->settings('public_url') || 'https://watchtower.3dshawn.com';
32
33# ---- Quiet hours ---------------------------------------------------
34# Which monitors are in a quiet window right now? A window that straddles
35# midnight (e.g. 22:00 -> 06:00) is active when now >= start OR now < end.
36my %QUIET_MID;
37{
38 my $probe = $db->db_readwrite($dbh, q~
39 SELECT 1 AS ok FROM INFORMATION_SCHEMA.COLUMNS
40 WHERE TABLE_SCHEMA=DATABASE()
41 AND TABLE_NAME='FILE_MONITOR_SETTINGS'
42 AND COLUMN_NAME='quiet_hours_start'
43 ~, __FILE__, __LINE__);
44 if ($probe && $probe->{ok}) {
45 my @rows = $db->db_readwrite_multiple($dbh, q~
46 SELECT file_monitor_list_id AS mid,
47 TIME_TO_SEC(quiet_hours_start) AS start_s,
48 TIME_TO_SEC(quiet_hours_end) AS end_s
49 FROM FILE_MONITOR_SETTINGS
50 WHERE quiet_hours_start IS NOT NULL
51 AND quiet_hours_end IS NOT NULL
52 ~, __FILE__, __LINE__);
53 my $now_row = $db->db_readwrite($dbh,
54 "SELECT TIME_TO_SEC(CURTIME()) AS now_s", __FILE__, __LINE__);
55 my $now = $now_row ? $now_row->{now_s} : 0;
56 foreach my $r (@rows) {
57 my $s = $r->{start_s}; my $e = $r->{end_s};
58 my $quiet = ($s <= $e) ? ($now >= $s && $now < $e)
59 : ($now >= $s || $now < $e);
60 $QUIET_MID{$r->{mid}} = 1 if $quiet;
61 }
62 if (%QUIET_MID) {
63 print "[$ts] quiet-hours active for monitors: " . join(',', sort keys %QUIET_MID) . "\n";
64 }
65 }
66}
67sub _monitor_is_quiet { my $mid = shift; return $QUIET_MID{$mid || 0} ? 1 : 0; }
3268
3369# ---- Fetch active rules ---------------------------------------------
3470my @rules = $db->db_readwrite_multiple($dbh, q~
3571 SELECT alert_rule_id, rule_name, match_kind, match_path_glob,
3672 match_status_list, exclude_ts_only,
3773 delivery_kind, delivery_url, delivery_email,
3874 rate_limit_per_min, coalesce_window_s,
3975 frequency_threshold, frequency_window_min,
4076 UNIX_TIMESTAMP(last_frequency_fire_at) AS last_freq_fire_epoch
4177 FROM ALERT_RULES
4278 WHERE is_active = 1
4379~, __FILE__, __LINE__);
4480
4581unless (@rules) {
4682 print "[$ts] no active alert rules, exiting\n";
4783 $db->db_disconnect($dbh);
4884 exit 0;
4985}
5086
5187# ---- Fetch cursor ---------------------------------------------------
5288my %cursor;
5389foreach my $r ($db->db_readwrite_multiple($dbh, q~
5490 SELECT source_kind, last_seen_id FROM ALERT_CURSOR
5591~, __FILE__, __LINE__)) {
5692 $cursor{$r->{source_kind}} = $r->{last_seen_id};
5793}
5894$cursor{file} //= 0;
5995$cursor{schema} //= 0;
6096
6197# ---- Pull new FILE_CHANGES since cursor ------------------------------
6298my $file_max = $cursor{file};
6399my @file_new = $db->db_readwrite_multiple($dbh, qq~
64100 SELECT fc.file_changes_id AS id, fc.file_name, fc.status, fc.blob_sha,
65101 fc.is_ts_only, fc.date_time,
66102 UNIX_TIMESTAMP(fc.date_time) AS ts_epoch,
103 fc.file_monitor_list_id AS mid,
67104 s.server_name
68105 FROM FILE_CHANGES fc
69106 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
70107 WHERE fc.file_changes_id > $cursor{file}
71108 ORDER BY fc.file_changes_id ASC
72109 LIMIT 500
73110~, __FILE__, __LINE__);
74111foreach my $r (@file_new) {
75112 $file_max = $r->{id} if $r->{id} > $file_max;
113}
114
115# Quiet-hours filter: still advance cursor past silenced rows so we don't
116# fire them once the window ends, but drop them from the per-rule loop.
117if (%QUIET_MID) {
118 my $before = scalar @file_new;
119 @file_new = grep { !_monitor_is_quiet($_->{mid}) } @file_new;
120 my $dropped = $before - scalar @file_new;
121 print "[$ts] quiet-hours filtered $dropped file change(s) out of alert dispatch\n" if $dropped;
76122}
77123
78124# ---- Pull new SCHEMA_CHANGE since cursor -----------------------------
79125my $schema_max = $cursor{schema};
80126my @schema_new = $db->db_readwrite_multiple($dbh, qq~
81127 SELECT sc.schema_change_id AS id, sc.database_name, sc.table_name,
82128 sc.changes, sc.is_ts_only, sc.change_datetime,
83129 UNIX_TIMESTAMP(sc.change_datetime) AS ts_epoch,
84130 s.server_name
85131 FROM SCHEMA_CHANGE sc
86132 LEFT JOIN SERVERS s ON s.server_id = sc.server_id
87133 WHERE sc.schema_change_id > $cursor{schema}
88134 ORDER BY sc.schema_change_id ASC
89135 LIMIT 500
90136~, __FILE__, __LINE__);
91137foreach my $r (@schema_new) {
92138 $schema_max = $r->{id} if $r->{id} > $schema_max;
93139}
94140
95141my $total_new = scalar(@file_new) + scalar(@schema_new);
96142if ($total_new == 0) {
97143 print "[$ts] no new changes since cursor (file=$cursor{file} schema=$cursor{schema})\n";
98144 $db->db_disconnect($dbh);
99145 exit 0;
100146}
101147
102148# ---- Evaluate + deliver ---------------------------------------------
103149# HTTP transport is MODS::Webhook (shells out to /usr/bin/curl) so we
104150# don't need LWP::Protocol::https / IO::Socket::SSL as CPAN installs.
105151
106152my $delivered = 0;
107153my $skipped = 0;
108154my $failed = 0;
109155
110156foreach my $row (@file_new) { _process_row($row, 'file'); }
111157foreach my $row (@schema_new) { _process_row($row, 'schema'); }
112158
113159# ---- Frequency-mode rules ------------------------------------------
114160# For any rule where frequency_threshold > 0, we ignore the per-row loop
115161# above and instead: query how many file changes matching the rule's
116162# path glob happened in the last window_min minutes. If it crosses the
117163# threshold and we haven't fired within the same window, fire once with
118164# the top offender.
119165foreach my $rule (@rules) {
120166 next unless ($rule->{frequency_threshold} || 0) > 0;
121167 my $win = int($rule->{frequency_window_min} || 60);
122168 my $thr = int($rule->{frequency_threshold});
123169
124170 # Re-fire cooldown: don't fire more than once per window
125171 my $last = int($rule->{last_freq_fire_epoch} || 0);
126172 if ($last && (time - $last) < $win * 60) {
127173 next;
128174 }
129175
130176 # Find files whose match_path_glob-hit change count > threshold
131177 my $glob = $rule->{match_path_glob} // '';
132178 my $glob_sql = '';
133179 if (length $glob) {
134180 # Approx: SQL LIKE. Convert glob wildcards to SQL % / _.
135181 my $like = $glob;
136182 $like =~ s/\*\*/%/g;
137183 $like =~ s/\*/%/g;
138184 $like =~ s/\?/_/g;
139185 my $q_like = $dbh->quote($like);
140186 $glob_sql = " AND fc.file_name LIKE $q_like";
141187 }
142188 my @hot = $db->db_readwrite_multiple($dbh, qq~
143189 SELECT fc.file_name,
144190 COUNT(*) AS ct,
145191 MAX(fc.file_changes_id) AS latest_id,
146192 s.server_name
147193 FROM FILE_CHANGES fc
148194 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
149195 WHERE fc.date_time >= DATE_SUB(NOW(), INTERVAL $win MINUTE)
150196 $glob_sql
151197 GROUP BY fc.file_name
152198 HAVING ct >= $thr
153199 ORDER BY ct DESC
154200 LIMIT 5
155201 ~, __FILE__, __LINE__);
156202 next unless @hot;
157203
158204 # Fire once with the top offender's info
159205 my $top = $hot[0];
160206 my $fake_row = {
161207 id => $top->{latest_id},
162208 file_name => $top->{file_name},
163209 server_name => $top->{server_name} // 'local',
164210 date_time => POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime),
165211 ts_epoch => time,
166212 status => 'frequency_alert',
167213 };
168214 my $payload = _build_payload($rule, $fake_row, 'file');
169215 # Enrich summary with count + threshold
170216 my $extra = "frequency alert: $top->{file_name} changed $top->{ct} times in the last $win min (threshold: $thr)";
171217 if (ref($payload->{generic_webhook}) eq 'HASH') {
172218 $payload->{generic_webhook}{frequency_summary} = $extra;
173219 $payload->{generic_webhook}{frequency_count} = $top->{ct};
174220 $payload->{generic_webhook}{frequency_window} = $win;
175221 $payload->{generic_webhook}{summary} = $extra;
176222 }
177223 my ($status, $http_code, $body, $err) = _deliver($rule, $payload);
178224 _log_delivery($rule, $fake_row, 'file', $status, $http_code, $body, $err);
179225 if ($status eq 'sent') {
180226 $delivered++;
181227 $db->db_readwrite($dbh, qq~
182228 UPDATE ALERT_RULES
183229 SET fire_count = fire_count + 1,
184230 last_fired_at = NOW(),
185231 last_frequency_fire_at = NOW()
186232 WHERE alert_rule_id = $rule->{alert_rule_id}
187233 ~, __FILE__, __LINE__);
188234 } else {
189235 $failed++;
190236 }
191237}
192238
193239# ---- Advance cursor atomically --------------------------------------
194240if ($file_max > $cursor{file}) {
195241 $db->db_readwrite($dbh,
196242 "UPDATE ALERT_CURSOR SET last_seen_id = $file_max WHERE source_kind = 'file'",
197243 __FILE__, __LINE__);
198244}
199245if ($schema_max > $cursor{schema}) {
200246 $db->db_readwrite($dbh,
201247 "UPDATE ALERT_CURSOR SET last_seen_id = $schema_max WHERE source_kind = 'schema'",
202248 __FILE__, __LINE__);
203249}
204250
205251# ---- Pin-drift alerts (Feature 5 wave 4) ---------------------------
206252# For each Named Release with alert_delivery_url set, check whether any
207253# pin's current-latest SHA differs from the pinned SHA. Log the drift
208254# to PIN_DRIFT_LOG (with notified=0) and, if not yet notified, POST an
209255# alert to the release's delivery URL, then set notified=1.
210256foreach my $rel ($db->db_readwrite_multiple($dbh, q~
211257 SELECT named_release_id, name, alert_delivery_kind, alert_delivery_url
212258 FROM NAMED_RELEASES
213259 WHERE alert_delivery_kind IS NOT NULL
214260 AND alert_delivery_kind != 'none'
215261 AND alert_delivery_url IS NOT NULL
216262 AND alert_delivery_url != ''
217263~, __FILE__, __LINE__)) {
218264
219265 my $rid = $rel->{named_release_id};
220266 my @drifts;
221267 foreach my $pin ($db->db_readwrite_multiple($dbh, qq~
222268 SELECT p.pin_id, p.file_name, p.blob_sha AS pinned_sha,
223269 fc.server_id
224270 FROM NAMED_RELEASE_PINS p
225271 LEFT JOIN FILE_CHANGES fc ON fc.file_changes_id = p.file_changes_id
226272 WHERE p.named_release_id = $rid
227273 ~, __FILE__, __LINE__)) {
228274 my $q_file = $dbh->quote($pin->{file_name});
229275 my $sid = int($pin->{server_id} || 0);
230276 my $cur = $db->db_readwrite($dbh, qq~
231277 SELECT blob_sha, file_changes_id FROM FILE_CHANGES
232278 WHERE server_id = $sid AND file_name = $q_file
233279 ORDER BY file_changes_id DESC LIMIT 1
234280 ~, __FILE__, __LINE__);
235281 my $cur_sha = $cur ? $cur->{blob_sha} : '';
236282 next unless $cur_sha && $cur_sha ne $pin->{pinned_sha};
237283
238284 # Have we already logged + notified this (pin, current_sha) tuple?
239285 my $q_pinned = $dbh->quote($pin->{pinned_sha});
240286 my $q_current = $dbh->quote($cur_sha);
241287 my $seen = $db->db_readwrite($dbh, qq~
242288 SELECT drift_id, notified FROM PIN_DRIFT_LOG
243289 WHERE pin_id = $pin->{pin_id}
244290 AND current_sha = $q_current
245291 ORDER BY drift_id DESC LIMIT 1
246292 ~, __FILE__, __LINE__);
247293 if ($seen && $seen->{notified}) {
248294 next; # already notified for this exact drift state
249295 }
250296
251297 # Log the drift
252298 my $q_fname = $dbh->quote($pin->{file_name});
253299 my $cid = int($cur->{file_changes_id} || 0);
254300 $db->db_readwrite($dbh, qq~
255301 INSERT INTO PIN_DRIFT_LOG
256302 (named_release_id, pin_id, file_name, pinned_sha, current_sha, file_changes_id, notified)
257303 VALUES
258304 ($rid, $pin->{pin_id}, $q_fname, $q_pinned, $q_current, $cid, 0)
259305 ~, __FILE__, __LINE__);
260306 push @drifts, {
261307 file_name => $pin->{file_name},
262308 pinned_sha => $pin->{pinned_sha},
263309 current_sha => $cur_sha,
264310 change_id => $cid,
265311 };
266312 }
267313
268314 next unless @drifts;
269315
270316 # Build a summary payload
271317 my $summary = sprintf('Named release "%s" -- %d pinned file(s) have drifted', $rel->{name}, scalar @drifts);
272318 my $public_url = $cfg->settings('public_url') || 'https://watchtower.3dshawn.com';
273319 my $files_list = join("\n", map {
274320 " * $_->{file_name} (pinned: " . substr($_->{pinned_sha}, 0, 12) .
275321 " -> current: " . substr($_->{current_sha}, 0, 12) . ")"
276322 } @drifts);
277323 my $link = "$public_url/restore_release.cgi?release_id=$rid";
278324
279325 my $slack_body = {
280326 text => "*DriftSense pin drift*: $summary",
281327 attachments => [{
282328 color => '#f43f5e',
283329 fields => [
284330 { title => 'Release', value => $rel->{name}, short => 1 },
285331 { title => 'Drifted files', value => scalar(@drifts), short => 1 },
286332 { title => 'Details', value => $files_list, short => 0 },
287333 ],
288334 actions => [{ type => 'button', text => 'Restore all pinned', url => $link }],
289335 }],
290336 };
291337 my $discord_body = {
292338 content => "**DriftSense pin drift**: $summary",
293339 embeds => [{
294340 title => $rel->{name},
295341 url => $link,
296342 color => 16005694,
297343 description => $files_list,
298344 }],
299345 };
300346 my $generic_body = {
301347 source => 'drift_sense',
302348 alert_type => 'pin_drift',
303349 release_id => $rid,
304350 release_name => $rel->{name},
305351 drifted_count=> scalar @drifts,
306352 summary => $summary,
307353 files => \@drifts,
308354 restore_url => $link,
309355 };
310356
311357 my $body_ref = $rel->{alert_delivery_kind} eq 'slack' ? $slack_body
312358 : $rel->{alert_delivery_kind} eq 'discord' ? $discord_body
313359 : $generic_body;
314360
315361 my ($code, $rbody, $err) = MODS::Webhook::post_json(
316362 $rel->{alert_delivery_url}, $body_ref,
317363 timeout => 8,
318364 user_agent => 'DriftSense/1.0 (pin-drift)',
319365 );
320366
321367 if (!$err && $code >= 200 && $code < 400) {
322368 # Mark all just-logged rows as notified
323369 foreach my $d (@drifts) {
324370 my $q_current = $dbh->quote($d->{current_sha});
325371 $db->db_readwrite($dbh, qq~
326372 UPDATE PIN_DRIFT_LOG SET notified = 1
327373 WHERE named_release_id = $rid
328374 AND file_name = @{[ $dbh->quote($d->{file_name}) ]}
329375 AND current_sha = $q_current
330376 AND notified = 0
331377 ~, __FILE__, __LINE__);
332378 }
333379 $delivered++;
334380 print "[$ts] pin-drift alert delivered for release '$rel->{name}' (" . scalar(@drifts) . " drifted)\n";
335381 } else {
336382 $failed++;
337383 print "[$ts] pin-drift alert FAILED for release '$rel->{name}': " . ($err // "HTTP $code") . "\n";
338384 }
339385
340386 # Bookkeep the check timestamp
341387 $db->db_readwrite($dbh, qq~
342388 UPDATE NAMED_RELEASES SET last_pin_drift_check_at = NOW()
343389 WHERE named_release_id = $rid
344390 ~, __FILE__, __LINE__);
345391}
346392
347393$db->db_disconnect($dbh);
348394print "[$ts] processed +$total_new new changes: $delivered delivered, $skipped skipped, $failed failed\n";
349395exit 0;
350396
351397#---------------------------------------------------------------------
352398sub _process_row {
353399 my ($row, $kind) = @_;
354400 foreach my $rule (@rules) {
355401 next unless _rule_matches($rule, $row, $kind);
356402
357403 # Rate limit check
358404 if ($rule->{rate_limit_per_min} > 0) {
359405 my $rlq = $db->db_readwrite($dbh, qq~
360406 SELECT COUNT(*) AS n FROM ALERT_DELIVERIES
361407 WHERE alert_rule_id = $rule->{alert_rule_id}
362408 AND fired_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)
363409 AND delivery_status IN ('sent','pending')
364410 ~, __FILE__, __LINE__);
365411 if ($rlq && $rlq->{n} >= $rule->{rate_limit_per_min}) {
366412 _log_delivery($rule, $row, $kind, 'skipped', undef, undef,
367413 "rate limit ($rule->{rate_limit_per_min}/min) reached");
368414 $skipped++;
369415 next;
370416 }
371417 }
372418
373419 # Build payload + POST
374420 my $payload = _build_payload($rule, $row, $kind);
375421 my ($status, $http_code, $body, $err) = _deliver($rule, $payload);
376422 _log_delivery($rule, $row, $kind, $status, $http_code, $body, $err);
377423
378424 if ($status eq 'sent') { $delivered++; }
379425 elsif ($status eq 'skipped') { $skipped++; }
380426 else { $failed++; }
381427
382428 # Bump the rule's fire_count + last_fired_at
383429 if ($status eq 'sent') {
384430 $db->db_readwrite($dbh, qq~
385431 UPDATE ALERT_RULES
386432 SET fire_count = fire_count + 1,
387433 last_fired_at = NOW()
388434 WHERE alert_rule_id = $rule->{alert_rule_id}
389435 ~, __FILE__, __LINE__);
390436 }
391437 }
392438}
393439
394440#---------------------------------------------------------------------
395441sub _rule_matches {
396442 my ($rule, $row, $kind) = @_;
397443
398444 # Kind filter
399445 return 0 if $rule->{match_kind} ne 'either' && $rule->{match_kind} ne $kind;
400446
401447 # ts-only exclusion (files only)
402448 return 0 if $kind eq 'file' && $rule->{exclude_ts_only} && $row->{is_ts_only};
403449
404450 # Path glob
405451 my $glob = $rule->{match_path_glob} // '';
406452 if (length $glob) {
407453 my $target = $kind eq 'file'
408454 ? ($row->{file_name} // '')
409455 : (($row->{database_name} // '') . '.' . ($row->{table_name} // ''));
410456 my $re = _glob_to_regex($glob);
411457 return 0 unless $target =~ $re;
412458 }
413459
414460 # Status filter (files only)
415461 if ($kind eq 'file' && $rule->{match_status_list}) {
416462 my %allowed = map { s/^\s+|\s+$//g; $_ => 1 }
417463 split /,/, $rule->{match_status_list};
418464 return 0 unless $allowed{ $row->{status} // '' };
419465 }
420466
421467 return 1;
422468}
423469
424470sub _glob_to_regex {
425471 my $g = shift;
426472 my $re = quotemeta $g;
427473 # Handle glob patterns: escaped meta chars back to regex meaning
428474 $re =~ s{\\\*\\\*}{.*}g; # ** -> .*
429475 $re =~ s{\\\*}{[^/]*}g; # * -> [^/]*
430476 $re =~ s{\\\?}{.}g; # ? -> .
431477 return qr/^$re$/;
432478}
433479
434480#---------------------------------------------------------------------
435481sub _build_payload {
436482 my ($rule, $row, $kind) = @_;
437483
438484 my $where = $kind eq 'file'
439485 ? ($row->{file_name} // '(unknown file)')
440486 : (($row->{database_name} // '?') . '.' . ($row->{table_name} // '?'));
441487 my $when_utc = $row->{date_time} || $row->{change_datetime} || '';
442488 my $server = $row->{server_name} || 'local';
443489 my $status_h = $kind eq 'file' ? ($row->{status} // '') : 'DDL change';
444490 my $link_id = $row->{id};
445491 my $link = "$public_url/diff.cgi?kind=$kind&id=$link_id";
446492
447493 my $summary = $kind eq 'file'
448494 ? "$status_h: $where on $server"
449495 : "$where changed on $server";
450496
451497 my $slack = {
452498 text => "*DriftSense*: $summary",
453499 attachments => [{
454500 color => $kind eq 'schema' ? '#f59e0b' : '#14b8a6',
455501 fields => [
456502 { title => 'What', value => $where, short => 0 },
457503 { title => 'When', value => $when_utc, short => 1 },
458504 { title => 'Server', value => $server, short => 1 },
459505 ],
460506 actions => [{
461507 type => 'button', text => 'View diff', url => $link,
462508 }],
463509 }],
464510 };
465511 my $discord = {
466512 content => "**DriftSense**: $summary",
467513 embeds => [{
468514 title => $where,
469515 url => $link,
470516 color => $kind eq 'schema' ? 16101915 : 1349286, # amber, teal
471517 fields => [
472518 { name => 'When', value => $when_utc || '?', inline => \1 },
473519 { name => 'Server', value => $server, inline => \1 },
474520 ],
475521 }],
476522 };
477523 my $generic = {
478524 source => 'drift_sense',
479525 rule_id => $rule->{alert_rule_id},
480526 rule_name => $rule->{rule_name},
481527 kind => $kind,
482528 summary => $summary,
483529 target => $where,
484530 server => $server,
485531 when_utc => $when_utc,
486532 ts_epoch => $row->{ts_epoch},
487533 diff_url => $link,
488534 status => $status_h,
489535 };
490536
491537 return {
492538 slack => $slack,
493539 discord => $discord,
494540 generic_webhook => $generic,
495541 summary => $summary,
496542 };
497543}
498544
499545#---------------------------------------------------------------------
500546sub _deliver {
501547 my ($rule, $payload) = @_;
502548 my $kind = $rule->{delivery_kind};
503549 my $url = $rule->{delivery_url};
504550
505551 if ($kind eq 'email') {
506552 my $to = $rule->{delivery_email} || '';
507553 return ('failed', undef, undef, 'no delivery_email configured') unless length $to;
508554 my $summary = $payload->{summary} // 'DriftSense alert';
509555 my $target = ($payload->{generic_webhook} && $payload->{generic_webhook}{target}) // '(unknown)';
510556 my $server = ($payload->{generic_webhook} && $payload->{generic_webhook}{server}) // 'local';
511557 my $link = ($payload->{generic_webhook} && $payload->{generic_webhook}{diff_url}) // '';
512558
513559 my $mail_body = "DriftSense captured a change matching alert rule: $rule->{rule_name}\n\n"
514560 . "Target : $target\n"
515561 . "Server : $server\n"
516562 . "Summary: $summary\n"
517563 . ($link ? "Diff : $link\n" : '')
518564 . "\n(Automated notification from DriftSense.)\n";
519565
520566 my $from = $cfg->settings('alert_from_email') || 'drift_sense@localhost';
521567 my $subj = "[DriftSense] " . substr($summary, 0, 120);
522568
523569 my $sendmail = '/usr/sbin/sendmail';
524570 $sendmail = '/usr/lib/sendmail' unless -x $sendmail;
525571 return ('failed', undef, undef, "no sendmail binary") unless -x $sendmail;
526572
527573 require IPC::Open3;
528574 my ($wtr, $rdr, $err_fh);
529575 $err_fh = Symbol::gensym();
530576 my $pid = eval { IPC::Open3::open3($wtr, $rdr, $err_fh, $sendmail, '-t', '-i') };
531577 return ('failed', undef, undef, "spawn: $@") if $@ || !$pid;
532578 binmode $wtr;
533579 print {$wtr} "From: $from\r\n";
534580 print {$wtr} "To: $to\r\n";
535581 print {$wtr} "Subject: $subj\r\n";
536582 print {$wtr} "Content-Type: text/plain; charset=utf-8\r\n";
537583 print {$wtr} "\r\n";
538584 print {$wtr} $mail_body;
539585 close $wtr;
540586 do { local $/; <$rdr> // ''; };
541587 do { local $/; <$err_fh> // ''; };
542588 close $rdr; close $err_fh;
543589 waitpid $pid, 0;
544590 my $rc = $? >> 8;
545591 return $rc == 0
546592 ? ('sent', 0, "delivered to $to", undef)
547593 : ('failed', $rc, undef, "sendmail exit $rc");
548594 }
549595 unless (length($url // '')) {
550596 return ('failed', undef, undef, "no delivery URL configured");
551597 }
552598
553599 my $body_ref = $kind eq 'slack' ? $payload->{slack}
554600 : $kind eq 'discord' ? $payload->{discord}
555601 : $payload->{generic_webhook};
556602
557603 # MODS::Webhook handles JSON encoding + curl transport; returns
558604 # (http_code, response_body, error_message).
559605 my ($code, $rbody, $err) = MODS::Webhook::post_json(
560606 $url, $body_ref,
561607 timeout => 8,
562608 user_agent => 'DriftSense/1.0 (alert-dispatch)',
563609 );
564610 $rbody //= '';
565611 $rbody = substr($rbody, 0, 500) if length($rbody) > 500;
566612
567613 if (!$err && $code >= 200 && $code < 400) {
568614 return ('sent', $code, $rbody, undef);
569615 }
570616 return ('failed', $code, $rbody, $err || "HTTP $code");
571617}
572618
573619#---------------------------------------------------------------------
574620sub _log_delivery {
575621 my ($rule, $row, $kind, $status, $http_code, $body, $err) = @_;
576622 my $summary = substr(($rule->{rule_name} // '') . ' :: ' . ($row->{file_name}
577623 || ($row->{database_name} . '.' . $row->{table_name})
578624 || '?'), 0, 490);
579625 my $q_summary = $dbh->quote($summary);
580626 my $q_status = $dbh->quote($status);
581627 my $q_body = $dbh->quote($body // '');
582628 my $q_err = $dbh->quote($err // '');
583629 my $q_code = defined($http_code) ? int($http_code) : 'NULL';
584630
585631 $db->db_readwrite($dbh, qq~
586632 INSERT INTO ALERT_DELIVERIES
587633 (alert_rule_id, source_kind, source_id, match_summary,
588634 delivery_status, http_status, http_response, error_message)
589635 VALUES
590636 ($rule->{alert_rule_id}, '$kind', $row->{id}, $q_summary,
591637 $q_status, $q_code, $q_body, $q_err)
592638 ~, __FILE__, __LINE__);
593639}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help