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

O Operator
Diff

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

modified on local at 2026-07-12 00:48:56

Added
+43
lines
Removed
-1
lines
Context
550
unchanged
Blobs
from 6bb32e8ab07d
to a53566efc691
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 ();
18use Symbol ();
1819use JSON::PP;
1920use MODS::Config;
2021use MODS::DBConnect;
2122use MODS::Webhook;
2223
2324$| = 1;
2425my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
2526
2627my $cfg = MODS::Config->new;
2728my $db = MODS::DBConnect->new;
2829my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
2930
3031my $public_url = $cfg->settings('public_url') || 'https://watchtower.3dshawn.com';
3132
3233# ---- Fetch active rules ---------------------------------------------
3334my @rules = $db->db_readwrite_multiple($dbh, q~
3435 SELECT alert_rule_id, rule_name, match_kind, match_path_glob,
3536 match_status_list, exclude_ts_only,
3637 delivery_kind, delivery_url, delivery_email,
3738 rate_limit_per_min, coalesce_window_s,
3839 frequency_threshold, frequency_window_min,
3940 UNIX_TIMESTAMP(last_frequency_fire_at) AS last_freq_fire_epoch
4041 FROM ALERT_RULES
4142 WHERE is_active = 1
4243~, __FILE__, __LINE__);
4344
4445unless (@rules) {
4546 print "[$ts] no active alert rules, exiting\n";
4647 $db->db_disconnect($dbh);
4748 exit 0;
4849}
4950
5051# ---- Fetch cursor ---------------------------------------------------
5152my %cursor;
5253foreach my $r ($db->db_readwrite_multiple($dbh, q~
5354 SELECT source_kind, last_seen_id FROM ALERT_CURSOR
5455~, __FILE__, __LINE__)) {
5556 $cursor{$r->{source_kind}} = $r->{last_seen_id};
5657}
5758$cursor{file} //= 0;
5859$cursor{schema} //= 0;
5960
6061# ---- Pull new FILE_CHANGES since cursor ------------------------------
6162my $file_max = $cursor{file};
6263my @file_new = $db->db_readwrite_multiple($dbh, qq~
6364 SELECT fc.file_changes_id AS id, fc.file_name, fc.status, fc.blob_sha,
6465 fc.is_ts_only, fc.date_time,
6566 UNIX_TIMESTAMP(fc.date_time) AS ts_epoch,
6667 s.server_name
6768 FROM FILE_CHANGES fc
6869 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
6970 WHERE fc.file_changes_id > $cursor{file}
7071 ORDER BY fc.file_changes_id ASC
7172 LIMIT 500
7273~, __FILE__, __LINE__);
7374foreach my $r (@file_new) {
7475 $file_max = $r->{id} if $r->{id} > $file_max;
7576}
7677
7778# ---- Pull new SCHEMA_CHANGE since cursor -----------------------------
7879my $schema_max = $cursor{schema};
7980my @schema_new = $db->db_readwrite_multiple($dbh, qq~
8081 SELECT sc.schema_change_id AS id, sc.database_name, sc.table_name,
8182 sc.changes, sc.is_ts_only, sc.change_datetime,
8283 UNIX_TIMESTAMP(sc.change_datetime) AS ts_epoch,
8384 s.server_name
8485 FROM SCHEMA_CHANGE sc
8586 LEFT JOIN SERVERS s ON s.server_id = sc.server_id
8687 WHERE sc.schema_change_id > $cursor{schema}
8788 ORDER BY sc.schema_change_id ASC
8889 LIMIT 500
8990~, __FILE__, __LINE__);
9091foreach my $r (@schema_new) {
9192 $schema_max = $r->{id} if $r->{id} > $schema_max;
9293}
9394
9495my $total_new = scalar(@file_new) + scalar(@schema_new);
9596if ($total_new == 0) {
9697 print "[$ts] no new changes since cursor (file=$cursor{file} schema=$cursor{schema})\n";
9798 $db->db_disconnect($dbh);
9899 exit 0;
99100}
100101
101102# ---- Evaluate + deliver ---------------------------------------------
102103# HTTP transport is MODS::Webhook (shells out to /usr/bin/curl) so we
103104# don't need LWP::Protocol::https / IO::Socket::SSL as CPAN installs.
104105
105106my $delivered = 0;
106107my $skipped = 0;
107108my $failed = 0;
108109
109110foreach my $row (@file_new) { _process_row($row, 'file'); }
110111foreach my $row (@schema_new) { _process_row($row, 'schema'); }
111112
112113# ---- Frequency-mode rules ------------------------------------------
113114# For any rule where frequency_threshold > 0, we ignore the per-row loop
114115# above and instead: query how many file changes matching the rule's
115116# path glob happened in the last window_min minutes. If it crosses the
116117# threshold and we haven't fired within the same window, fire once with
117118# the top offender.
118119foreach my $rule (@rules) {
119120 next unless ($rule->{frequency_threshold} || 0) > 0;
120121 my $win = int($rule->{frequency_window_min} || 60);
121122 my $thr = int($rule->{frequency_threshold});
122123
123124 # Re-fire cooldown: don't fire more than once per window
124125 my $last = int($rule->{last_freq_fire_epoch} || 0);
125126 if ($last && (time - $last) < $win * 60) {
126127 next;
127128 }
128129
129130 # Find files whose match_path_glob-hit change count > threshold
130131 my $glob = $rule->{match_path_glob} // '';
131132 my $glob_sql = '';
132133 if (length $glob) {
133134 # Approx: SQL LIKE. Convert glob wildcards to SQL % / _.
134135 my $like = $glob;
135136 $like =~ s/\*\*/%/g;
136137 $like =~ s/\*/%/g;
137138 $like =~ s/\?/_/g;
138139 my $q_like = $dbh->quote($like);
139140 $glob_sql = " AND fc.file_name LIKE $q_like";
140141 }
141142 my @hot = $db->db_readwrite_multiple($dbh, qq~
142143 SELECT fc.file_name,
143144 COUNT(*) AS ct,
144145 MAX(fc.file_changes_id) AS latest_id,
145146 s.server_name
146147 FROM FILE_CHANGES fc
147148 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
148149 WHERE fc.date_time >= DATE_SUB(NOW(), INTERVAL $win MINUTE)
149150 $glob_sql
150151 GROUP BY fc.file_name
151152 HAVING ct >= $thr
152153 ORDER BY ct DESC
153154 LIMIT 5
154155 ~, __FILE__, __LINE__);
155156 next unless @hot;
156157
157158 # Fire once with the top offender's info
158159 my $top = $hot[0];
159160 my $fake_row = {
160161 id => $top->{latest_id},
161162 file_name => $top->{file_name},
162163 server_name => $top->{server_name} // 'local',
163164 date_time => POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime),
164165 ts_epoch => time,
165166 status => 'frequency_alert',
166167 };
167168 my $payload = _build_payload($rule, $fake_row, 'file');
168169 # Enrich summary with count + threshold
169170 my $extra = "frequency alert: $top->{file_name} changed $top->{ct} times in the last $win min (threshold: $thr)";
170171 if (ref($payload->{generic_webhook}) eq 'HASH') {
171172 $payload->{generic_webhook}{frequency_summary} = $extra;
172173 $payload->{generic_webhook}{frequency_count} = $top->{ct};
173174 $payload->{generic_webhook}{frequency_window} = $win;
174175 $payload->{generic_webhook}{summary} = $extra;
175176 }
176177 my ($status, $http_code, $body, $err) = _deliver($rule, $payload);
177178 _log_delivery($rule, $fake_row, 'file', $status, $http_code, $body, $err);
178179 if ($status eq 'sent') {
179180 $delivered++;
180181 $db->db_readwrite($dbh, qq~
181182 UPDATE ALERT_RULES
182183 SET fire_count = fire_count + 1,
183184 last_fired_at = NOW(),
184185 last_frequency_fire_at = NOW()
185186 WHERE alert_rule_id = $rule->{alert_rule_id}
186187 ~, __FILE__, __LINE__);
187188 } else {
188189 $failed++;
189190 }
190191}
191192
192193# ---- Advance cursor atomically --------------------------------------
193194if ($file_max > $cursor{file}) {
194195 $db->db_readwrite($dbh,
195196 "UPDATE ALERT_CURSOR SET last_seen_id = $file_max WHERE source_kind = 'file'",
196197 __FILE__, __LINE__);
197198}
198199if ($schema_max > $cursor{schema}) {
199200 $db->db_readwrite($dbh,
200201 "UPDATE ALERT_CURSOR SET last_seen_id = $schema_max WHERE source_kind = 'schema'",
201202 __FILE__, __LINE__);
202203}
203204
204205# ---- Pin-drift alerts (Feature 5 wave 4) ---------------------------
205206# For each Named Release with alert_delivery_url set, check whether any
206207# pin's current-latest SHA differs from the pinned SHA. Log the drift
207208# to PIN_DRIFT_LOG (with notified=0) and, if not yet notified, POST an
208209# alert to the release's delivery URL, then set notified=1.
209210foreach my $rel ($db->db_readwrite_multiple($dbh, q~
210211 SELECT named_release_id, name, alert_delivery_kind, alert_delivery_url
211212 FROM NAMED_RELEASES
212213 WHERE alert_delivery_kind IS NOT NULL
213214 AND alert_delivery_kind != 'none'
214215 AND alert_delivery_url IS NOT NULL
215216 AND alert_delivery_url != ''
216217~, __FILE__, __LINE__)) {
217218
218219 my $rid = $rel->{named_release_id};
219220 my @drifts;
220221 foreach my $pin ($db->db_readwrite_multiple($dbh, qq~
221222 SELECT p.pin_id, p.file_name, p.blob_sha AS pinned_sha,
222223 fc.server_id
223224 FROM NAMED_RELEASE_PINS p
224225 LEFT JOIN FILE_CHANGES fc ON fc.file_changes_id = p.file_changes_id
225226 WHERE p.named_release_id = $rid
226227 ~, __FILE__, __LINE__)) {
227228 my $q_file = $dbh->quote($pin->{file_name});
228229 my $sid = int($pin->{server_id} || 0);
229230 my $cur = $db->db_readwrite($dbh, qq~
230231 SELECT blob_sha, file_changes_id FROM FILE_CHANGES
231232 WHERE server_id = $sid AND file_name = $q_file
232233 ORDER BY file_changes_id DESC LIMIT 1
233234 ~, __FILE__, __LINE__);
234235 my $cur_sha = $cur ? $cur->{blob_sha} : '';
235236 next unless $cur_sha && $cur_sha ne $pin->{pinned_sha};
236237
237238 # Have we already logged + notified this (pin, current_sha) tuple?
238239 my $q_pinned = $dbh->quote($pin->{pinned_sha});
239240 my $q_current = $dbh->quote($cur_sha);
240241 my $seen = $db->db_readwrite($dbh, qq~
241242 SELECT drift_id, notified FROM PIN_DRIFT_LOG
242243 WHERE pin_id = $pin->{pin_id}
243244 AND current_sha = $q_current
244245 ORDER BY drift_id DESC LIMIT 1
245246 ~, __FILE__, __LINE__);
246247 if ($seen && $seen->{notified}) {
247248 next; # already notified for this exact drift state
248249 }
249250
250251 # Log the drift
251252 my $q_fname = $dbh->quote($pin->{file_name});
252253 my $cid = int($cur->{file_changes_id} || 0);
253254 $db->db_readwrite($dbh, qq~
254255 INSERT INTO PIN_DRIFT_LOG
255256 (named_release_id, pin_id, file_name, pinned_sha, current_sha, file_changes_id, notified)
256257 VALUES
257258 ($rid, $pin->{pin_id}, $q_fname, $q_pinned, $q_current, $cid, 0)
258259 ~, __FILE__, __LINE__);
259260 push @drifts, {
260261 file_name => $pin->{file_name},
261262 pinned_sha => $pin->{pinned_sha},
262263 current_sha => $cur_sha,
263264 change_id => $cid,
264265 };
265266 }
266267
267268 next unless @drifts;
268269
269270 # Build a summary payload
270271 my $summary = sprintf('Named release "%s" -- %d pinned file(s) have drifted', $rel->{name}, scalar @drifts);
271272 my $public_url = $cfg->settings('public_url') || 'https://watchtower.3dshawn.com';
272273 my $files_list = join("\n", map {
273274 " * $_->{file_name} (pinned: " . substr($_->{pinned_sha}, 0, 12) .
274275 " -> current: " . substr($_->{current_sha}, 0, 12) . ")"
275276 } @drifts);
276277 my $link = "$public_url/restore_release.cgi?release_id=$rid";
277278
278279 my $slack_body = {
279280 text => "*DriftSense pin drift*: $summary",
280281 attachments => [{
281282 color => '#f43f5e',
282283 fields => [
283284 { title => 'Release', value => $rel->{name}, short => 1 },
284285 { title => 'Drifted files', value => scalar(@drifts), short => 1 },
285286 { title => 'Details', value => $files_list, short => 0 },
286287 ],
287288 actions => [{ type => 'button', text => 'Restore all pinned', url => $link }],
288289 }],
289290 };
290291 my $discord_body = {
291292 content => "**DriftSense pin drift**: $summary",
292293 embeds => [{
293294 title => $rel->{name},
294295 url => $link,
295296 color => 16005694,
296297 description => $files_list,
297298 }],
298299 };
299300 my $generic_body = {
300301 source => 'drift_sense',
301302 alert_type => 'pin_drift',
302303 release_id => $rid,
303304 release_name => $rel->{name},
304305 drifted_count=> scalar @drifts,
305306 summary => $summary,
306307 files => \@drifts,
307308 restore_url => $link,
308309 };
309310
310311 my $body_ref = $rel->{alert_delivery_kind} eq 'slack' ? $slack_body
311312 : $rel->{alert_delivery_kind} eq 'discord' ? $discord_body
312313 : $generic_body;
313314
314315 my ($code, $rbody, $err) = MODS::Webhook::post_json(
315316 $rel->{alert_delivery_url}, $body_ref,
316317 timeout => 8,
317318 user_agent => 'DriftSense/1.0 (pin-drift)',
318319 );
319320
320321 if (!$err && $code >= 200 && $code < 400) {
321322 # Mark all just-logged rows as notified
322323 foreach my $d (@drifts) {
323324 my $q_current = $dbh->quote($d->{current_sha});
324325 $db->db_readwrite($dbh, qq~
325326 UPDATE PIN_DRIFT_LOG SET notified = 1
326327 WHERE named_release_id = $rid
327328 AND file_name = @{[ $dbh->quote($d->{file_name}) ]}
328329 AND current_sha = $q_current
329330 AND notified = 0
330331 ~, __FILE__, __LINE__);
331332 }
332333 $delivered++;
333334 print "[$ts] pin-drift alert delivered for release '$rel->{name}' (" . scalar(@drifts) . " drifted)\n";
334335 } else {
335336 $failed++;
336337 print "[$ts] pin-drift alert FAILED for release '$rel->{name}': " . ($err // "HTTP $code") . "\n";
337338 }
338339
339340 # Bookkeep the check timestamp
340341 $db->db_readwrite($dbh, qq~
341342 UPDATE NAMED_RELEASES SET last_pin_drift_check_at = NOW()
342343 WHERE named_release_id = $rid
343344 ~, __FILE__, __LINE__);
344345}
345346
346347$db->db_disconnect($dbh);
347348print "[$ts] processed +$total_new new changes: $delivered delivered, $skipped skipped, $failed failed\n";
348349exit 0;
349350
350351#---------------------------------------------------------------------
351352sub _process_row {
352353 my ($row, $kind) = @_;
353354 foreach my $rule (@rules) {
354355 next unless _rule_matches($rule, $row, $kind);
355356
356357 # Rate limit check
357358 if ($rule->{rate_limit_per_min} > 0) {
358359 my $rlq = $db->db_readwrite($dbh, qq~
359360 SELECT COUNT(*) AS n FROM ALERT_DELIVERIES
360361 WHERE alert_rule_id = $rule->{alert_rule_id}
361362 AND fired_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)
362363 AND delivery_status IN ('sent','pending')
363364 ~, __FILE__, __LINE__);
364365 if ($rlq && $rlq->{n} >= $rule->{rate_limit_per_min}) {
365366 _log_delivery($rule, $row, $kind, 'skipped', undef, undef,
366367 "rate limit ($rule->{rate_limit_per_min}/min) reached");
367368 $skipped++;
368369 next;
369370 }
370371 }
371372
372373 # Build payload + POST
373374 my $payload = _build_payload($rule, $row, $kind);
374375 my ($status, $http_code, $body, $err) = _deliver($rule, $payload);
375376 _log_delivery($rule, $row, $kind, $status, $http_code, $body, $err);
376377
377378 if ($status eq 'sent') { $delivered++; }
378379 elsif ($status eq 'skipped') { $skipped++; }
379380 else { $failed++; }
380381
381382 # Bump the rule's fire_count + last_fired_at
382383 if ($status eq 'sent') {
383384 $db->db_readwrite($dbh, qq~
384385 UPDATE ALERT_RULES
385386 SET fire_count = fire_count + 1,
386387 last_fired_at = NOW()
387388 WHERE alert_rule_id = $rule->{alert_rule_id}
388389 ~, __FILE__, __LINE__);
389390 }
390391 }
391392}
392393
393394#---------------------------------------------------------------------
394395sub _rule_matches {
395396 my ($rule, $row, $kind) = @_;
396397
397398 # Kind filter
398399 return 0 if $rule->{match_kind} ne 'either' && $rule->{match_kind} ne $kind;
399400
400401 # ts-only exclusion (files only)
401402 return 0 if $kind eq 'file' && $rule->{exclude_ts_only} && $row->{is_ts_only};
402403
403404 # Path glob
404405 my $glob = $rule->{match_path_glob} // '';
405406 if (length $glob) {
406407 my $target = $kind eq 'file'
407408 ? ($row->{file_name} // '')
408409 : (($row->{database_name} // '') . '.' . ($row->{table_name} // ''));
409410 my $re = _glob_to_regex($glob);
410411 return 0 unless $target =~ $re;
411412 }
412413
413414 # Status filter (files only)
414415 if ($kind eq 'file' && $rule->{match_status_list}) {
415416 my %allowed = map { s/^\s+|\s+$//g; $_ => 1 }
416417 split /,/, $rule->{match_status_list};
417418 return 0 unless $allowed{ $row->{status} // '' };
418419 }
419420
420421 return 1;
421422}
422423
423424sub _glob_to_regex {
424425 my $g = shift;
425426 my $re = quotemeta $g;
426427 # Handle glob patterns: escaped meta chars back to regex meaning
427428 $re =~ s{\\\*\\\*}{.*}g; # ** -> .*
428429 $re =~ s{\\\*}{[^/]*}g; # * -> [^/]*
429430 $re =~ s{\\\?}{.}g; # ? -> .
430431 return qr/^$re$/;
431432}
432433
433434#---------------------------------------------------------------------
434435sub _build_payload {
435436 my ($rule, $row, $kind) = @_;
436437
437438 my $where = $kind eq 'file'
438439 ? ($row->{file_name} // '(unknown file)')
439440 : (($row->{database_name} // '?') . '.' . ($row->{table_name} // '?'));
440441 my $when_utc = $row->{date_time} || $row->{change_datetime} || '';
441442 my $server = $row->{server_name} || 'local';
442443 my $status_h = $kind eq 'file' ? ($row->{status} // '') : 'DDL change';
443444 my $link_id = $row->{id};
444445 my $link = "$public_url/diff.cgi?kind=$kind&id=$link_id";
445446
446447 my $summary = $kind eq 'file'
447448 ? "$status_h: $where on $server"
448449 : "$where changed on $server";
449450
450451 my $slack = {
451452 text => "*DriftSense*: $summary",
452453 attachments => [{
453454 color => $kind eq 'schema' ? '#f59e0b' : '#14b8a6',
454455 fields => [
455456 { title => 'What', value => $where, short => 0 },
456457 { title => 'When', value => $when_utc, short => 1 },
457458 { title => 'Server', value => $server, short => 1 },
458459 ],
459460 actions => [{
460461 type => 'button', text => 'View diff', url => $link,
461462 }],
462463 }],
463464 };
464465 my $discord = {
465466 content => "**DriftSense**: $summary",
466467 embeds => [{
467468 title => $where,
468469 url => $link,
469470 color => $kind eq 'schema' ? 16101915 : 1349286, # amber, teal
470471 fields => [
471472 { name => 'When', value => $when_utc || '?', inline => \1 },
472473 { name => 'Server', value => $server, inline => \1 },
473474 ],
474475 }],
475476 };
476477 my $generic = {
477478 source => 'drift_sense',
478479 rule_id => $rule->{alert_rule_id},
479480 rule_name => $rule->{rule_name},
480481 kind => $kind,
481482 summary => $summary,
482483 target => $where,
483484 server => $server,
484485 when_utc => $when_utc,
485486 ts_epoch => $row->{ts_epoch},
486487 diff_url => $link,
487488 status => $status_h,
488489 };
489490
490491 return {
491492 slack => $slack,
492493 discord => $discord,
493494 generic_webhook => $generic,
494495 summary => $summary,
495496 };
496497}
497498
498499#---------------------------------------------------------------------
499500sub _deliver {
500501 my ($rule, $payload) = @_;
501502 my $kind = $rule->{delivery_kind};
502503 my $url = $rule->{delivery_url};
503504
504505 if ($kind eq 'email') {
505 return ('skipped', undef, undef, 'email delivery not yet implemented');
506 my $to = $rule->{delivery_email} || '';
507 return ('failed', undef, undef, 'no delivery_email configured') unless length $to;
508 my $summary = $payload->{summary} // 'DriftSense alert';
509 my $target = ($payload->{generic_webhook} && $payload->{generic_webhook}{target}) // '(unknown)';
510 my $server = ($payload->{generic_webhook} && $payload->{generic_webhook}{server}) // 'local';
511 my $link = ($payload->{generic_webhook} && $payload->{generic_webhook}{diff_url}) // '';
512
513 my $mail_body = "DriftSense captured a change matching alert rule: $rule->{rule_name}\n\n"
514 . "Target : $target\n"
515 . "Server : $server\n"
516 . "Summary: $summary\n"
517 . ($link ? "Diff : $link\n" : '')
518 . "\n(Automated notification from DriftSense.)\n";
519
520 my $from = $cfg->settings('alert_from_email') || 'drift_sense@localhost';
521 my $subj = "[DriftSense] " . substr($summary, 0, 120);
522
523 my $sendmail = '/usr/sbin/sendmail';
524 $sendmail = '/usr/lib/sendmail' unless -x $sendmail;
525 return ('failed', undef, undef, "no sendmail binary") unless -x $sendmail;
526
527 require IPC::Open3;
528 my ($wtr, $rdr, $err_fh);
529 $err_fh = Symbol::gensym();
530 my $pid = eval { IPC::Open3::open3($wtr, $rdr, $err_fh, $sendmail, '-t', '-i') };
531 return ('failed', undef, undef, "spawn: $@") if $@ || !$pid;
532 binmode $wtr;
533 print {$wtr} "From: $from\r\n";
534 print {$wtr} "To: $to\r\n";
535 print {$wtr} "Subject: $subj\r\n";
536 print {$wtr} "Content-Type: text/plain; charset=utf-8\r\n";
537 print {$wtr} "\r\n";
538 print {$wtr} $mail_body;
539 close $wtr;
540 do { local $/; <$rdr> // ''; };
541 do { local $/; <$err_fh> // ''; };
542 close $rdr; close $err_fh;
543 waitpid $pid, 0;
544 my $rc = $? >> 8;
545 return $rc == 0
546 ? ('sent', 0, "delivered to $to", undef)
547 : ('failed', $rc, undef, "sendmail exit $rc");
506548 }
507549 unless (length($url // '')) {
508550 return ('failed', undef, undef, "no delivery URL configured");
509551 }
510552
511553 my $body_ref = $kind eq 'slack' ? $payload->{slack}
512554 : $kind eq 'discord' ? $payload->{discord}
513555 : $payload->{generic_webhook};
514556
515557 # MODS::Webhook handles JSON encoding + curl transport; returns
516558 # (http_code, response_body, error_message).
517559 my ($code, $rbody, $err) = MODS::Webhook::post_json(
518560 $url, $body_ref,
519561 timeout => 8,
520562 user_agent => 'DriftSense/1.0 (alert-dispatch)',
521563 );
522564 $rbody //= '';
523565 $rbody = substr($rbody, 0, 500) if length($rbody) > 500;
524566
525567 if (!$err && $code >= 200 && $code < 400) {
526568 return ('sent', $code, $rbody, undef);
527569 }
528570 return ('failed', $code, $rbody, $err || "HTTP $code");
529571}
530572
531573#---------------------------------------------------------------------
532574sub _log_delivery {
533575 my ($rule, $row, $kind, $status, $http_code, $body, $err) = @_;
534576 my $summary = substr(($rule->{rule_name} // '') . ' :: ' . ($row->{file_name}
535577 || ($row->{database_name} . '.' . $row->{table_name})
536578 || '?'), 0, 490);
537579 my $q_summary = $dbh->quote($summary);
538580 my $q_status = $dbh->quote($status);
539581 my $q_body = $dbh->quote($body // '');
540582 my $q_err = $dbh->quote($err // '');
541583 my $q_code = defined($http_code) ? int($http_code) : 'NULL';
542584
543585 $db->db_readwrite($dbh, qq~
544586 INSERT INTO ALERT_DELIVERIES
545587 (alert_rule_id, source_kind, source_id, match_summary,
546588 delivery_status, http_status, http_response, error_message)
547589 VALUES
548590 ($rule->{alert_rule_id}, '$kind', $row->{id}, $q_summary,
549591 $q_status, $q_code, $q_body, $q_err)
550592 ~, __FILE__, __LINE__);
551593}