Restore

O Operator
Restore

Restore file to captured state

Every captured change carries its SHA-256-addressed content in BLOB_STORE. Restoring writes that content back to the original path — current file gets backed up to <path>.drift_restore_backup_<epoch> before overwrite.

What will change
Preview -- nothing has been written yet.
Target file/var/www/vhosts/3dshawn.com/site1/_alert_dispatch.pl
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-12 00:19:42
Captured SHA6bb32e8ab07de715f6d981acb9ec586d418e6797f4e913c5ff49544aa07bd495
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size22933 bytes
Current SHAa53566efc691
Same as captured?no -- restore will change it
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +1 additions, -43 deletions, 550 unchanged context lines.
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 ();
1918use JSON::PP;
2019use MODS::Config;
2120use MODS::DBConnect;
2221use MODS::Webhook;
2322
2423$| = 1;
2524my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
2625
2726my $cfg = MODS::Config->new;
2827my $db = MODS::DBConnect->new;
2928my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
3029
3130my $public_url = $cfg->settings('public_url') || 'https://watchtower.3dshawn.com';
3231
3332# ---- Fetch active rules ---------------------------------------------
3433my @rules = $db->db_readwrite_multiple($dbh, q~
3534 SELECT alert_rule_id, rule_name, match_kind, match_path_glob,
3635 match_status_list, exclude_ts_only,
3736 delivery_kind, delivery_url, delivery_email,
3837 rate_limit_per_min, coalesce_window_s,
3938 frequency_threshold, frequency_window_min,
4039 UNIX_TIMESTAMP(last_frequency_fire_at) AS last_freq_fire_epoch
4140 FROM ALERT_RULES
4241 WHERE is_active = 1
4342~, __FILE__, __LINE__);
4443
4544unless (@rules) {
4645 print "[$ts] no active alert rules, exiting\n";
4746 $db->db_disconnect($dbh);
4847 exit 0;
4948}
5049
5150# ---- Fetch cursor ---------------------------------------------------
5251my %cursor;
5352foreach my $r ($db->db_readwrite_multiple($dbh, q~
5453 SELECT source_kind, last_seen_id FROM ALERT_CURSOR
5554~, __FILE__, __LINE__)) {
5655 $cursor{$r->{source_kind}} = $r->{last_seen_id};
5756}
5857$cursor{file} //= 0;
5958$cursor{schema} //= 0;
6059
6160# ---- Pull new FILE_CHANGES since cursor ------------------------------
6261my $file_max = $cursor{file};
6362my @file_new = $db->db_readwrite_multiple($dbh, qq~
6463 SELECT fc.file_changes_id AS id, fc.file_name, fc.status, fc.blob_sha,
6564 fc.is_ts_only, fc.date_time,
6665 UNIX_TIMESTAMP(fc.date_time) AS ts_epoch,
6766 s.server_name
6867 FROM FILE_CHANGES fc
6968 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
7069 WHERE fc.file_changes_id > $cursor{file}
7170 ORDER BY fc.file_changes_id ASC
7271 LIMIT 500
7372~, __FILE__, __LINE__);
7473foreach my $r (@file_new) {
7574 $file_max = $r->{id} if $r->{id} > $file_max;
7675}
7776
7877# ---- Pull new SCHEMA_CHANGE since cursor -----------------------------
7978my $schema_max = $cursor{schema};
8079my @schema_new = $db->db_readwrite_multiple($dbh, qq~
8180 SELECT sc.schema_change_id AS id, sc.database_name, sc.table_name,
8281 sc.changes, sc.is_ts_only, sc.change_datetime,
8382 UNIX_TIMESTAMP(sc.change_datetime) AS ts_epoch,
8483 s.server_name
8584 FROM SCHEMA_CHANGE sc
8685 LEFT JOIN SERVERS s ON s.server_id = sc.server_id
8786 WHERE sc.schema_change_id > $cursor{schema}
8887 ORDER BY sc.schema_change_id ASC
8988 LIMIT 500
9089~, __FILE__, __LINE__);
9190foreach my $r (@schema_new) {
9291 $schema_max = $r->{id} if $r->{id} > $schema_max;
9392}
9493
9594my $total_new = scalar(@file_new) + scalar(@schema_new);
9695if ($total_new == 0) {
9796 print "[$ts] no new changes since cursor (file=$cursor{file} schema=$cursor{schema})\n";
9897 $db->db_disconnect($dbh);
9998 exit 0;
10099}
101100
102101# ---- Evaluate + deliver ---------------------------------------------
103102# HTTP transport is MODS::Webhook (shells out to /usr/bin/curl) so we
104103# don't need LWP::Protocol::https / IO::Socket::SSL as CPAN installs.
105104
106105my $delivered = 0;
107106my $skipped = 0;
108107my $failed = 0;
109108
110109foreach my $row (@file_new) { _process_row($row, 'file'); }
111110foreach my $row (@schema_new) { _process_row($row, 'schema'); }
112111
113112# ---- Frequency-mode rules ------------------------------------------
114113# For any rule where frequency_threshold > 0, we ignore the per-row loop
115114# above and instead: query how many file changes matching the rule's
116115# path glob happened in the last window_min minutes. If it crosses the
117116# threshold and we haven't fired within the same window, fire once with
118117# the top offender.
119118foreach my $rule (@rules) {
120119 next unless ($rule->{frequency_threshold} || 0) > 0;
121120 my $win = int($rule->{frequency_window_min} || 60);
122121 my $thr = int($rule->{frequency_threshold});
123122
124123 # Re-fire cooldown: don't fire more than once per window
125124 my $last = int($rule->{last_freq_fire_epoch} || 0);
126125 if ($last && (time - $last) < $win * 60) {
127126 next;
128127 }
129128
130129 # Find files whose match_path_glob-hit change count > threshold
131130 my $glob = $rule->{match_path_glob} // '';
132131 my $glob_sql = '';
133132 if (length $glob) {
134133 # Approx: SQL LIKE. Convert glob wildcards to SQL % / _.
135134 my $like = $glob;
136135 $like =~ s/\*\*/%/g;
137136 $like =~ s/\*/%/g;
138137 $like =~ s/\?/_/g;
139138 my $q_like = $dbh->quote($like);
140139 $glob_sql = " AND fc.file_name LIKE $q_like";
141140 }
142141 my @hot = $db->db_readwrite_multiple($dbh, qq~
143142 SELECT fc.file_name,
144143 COUNT(*) AS ct,
145144 MAX(fc.file_changes_id) AS latest_id,
146145 s.server_name
147146 FROM FILE_CHANGES fc
148147 LEFT JOIN SERVERS s ON s.server_id = fc.server_id
149148 WHERE fc.date_time >= DATE_SUB(NOW(), INTERVAL $win MINUTE)
150149 $glob_sql
151150 GROUP BY fc.file_name
152151 HAVING ct >= $thr
153152 ORDER BY ct DESC
154153 LIMIT 5
155154 ~, __FILE__, __LINE__);
156155 next unless @hot;
157156
158157 # Fire once with the top offender's info
159158 my $top = $hot[0];
160159 my $fake_row = {
161160 id => $top->{latest_id},
162161 file_name => $top->{file_name},
163162 server_name => $top->{server_name} // 'local',
164163 date_time => POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime),
165164 ts_epoch => time,
166165 status => 'frequency_alert',
167166 };
168167 my $payload = _build_payload($rule, $fake_row, 'file');
169168 # Enrich summary with count + threshold
170169 my $extra = "frequency alert: $top->{file_name} changed $top->{ct} times in the last $win min (threshold: $thr)";
171170 if (ref($payload->{generic_webhook}) eq 'HASH') {
172171 $payload->{generic_webhook}{frequency_summary} = $extra;
173172 $payload->{generic_webhook}{frequency_count} = $top->{ct};
174173 $payload->{generic_webhook}{frequency_window} = $win;
175174 $payload->{generic_webhook}{summary} = $extra;
176175 }
177176 my ($status, $http_code, $body, $err) = _deliver($rule, $payload);
178177 _log_delivery($rule, $fake_row, 'file', $status, $http_code, $body, $err);
179178 if ($status eq 'sent') {
180179 $delivered++;
181180 $db->db_readwrite($dbh, qq~
182181 UPDATE ALERT_RULES
183182 SET fire_count = fire_count + 1,
184183 last_fired_at = NOW(),
185184 last_frequency_fire_at = NOW()
186185 WHERE alert_rule_id = $rule->{alert_rule_id}
187186 ~, __FILE__, __LINE__);
188187 } else {
189188 $failed++;
190189 }
191190}
192191
193192# ---- Advance cursor atomically --------------------------------------
194193if ($file_max > $cursor{file}) {
195194 $db->db_readwrite($dbh,
196195 "UPDATE ALERT_CURSOR SET last_seen_id = $file_max WHERE source_kind = 'file'",
197196 __FILE__, __LINE__);
198197}
199198if ($schema_max > $cursor{schema}) {
200199 $db->db_readwrite($dbh,
201200 "UPDATE ALERT_CURSOR SET last_seen_id = $schema_max WHERE source_kind = 'schema'",
202201 __FILE__, __LINE__);
203202}
204203
205204# ---- Pin-drift alerts (Feature 5 wave 4) ---------------------------
206205# For each Named Release with alert_delivery_url set, check whether any
207206# pin's current-latest SHA differs from the pinned SHA. Log the drift
208207# to PIN_DRIFT_LOG (with notified=0) and, if not yet notified, POST an
209208# alert to the release's delivery URL, then set notified=1.
210209foreach my $rel ($db->db_readwrite_multiple($dbh, q~
211210 SELECT named_release_id, name, alert_delivery_kind, alert_delivery_url
212211 FROM NAMED_RELEASES
213212 WHERE alert_delivery_kind IS NOT NULL
214213 AND alert_delivery_kind != 'none'
215214 AND alert_delivery_url IS NOT NULL
216215 AND alert_delivery_url != ''
217216~, __FILE__, __LINE__)) {
218217
219218 my $rid = $rel->{named_release_id};
220219 my @drifts;
221220 foreach my $pin ($db->db_readwrite_multiple($dbh, qq~
222221 SELECT p.pin_id, p.file_name, p.blob_sha AS pinned_sha,
223222 fc.server_id
224223 FROM NAMED_RELEASE_PINS p
225224 LEFT JOIN FILE_CHANGES fc ON fc.file_changes_id = p.file_changes_id
226225 WHERE p.named_release_id = $rid
227226 ~, __FILE__, __LINE__)) {
228227 my $q_file = $dbh->quote($pin->{file_name});
229228 my $sid = int($pin->{server_id} || 0);
230229 my $cur = $db->db_readwrite($dbh, qq~
231230 SELECT blob_sha, file_changes_id FROM FILE_CHANGES
232231 WHERE server_id = $sid AND file_name = $q_file
233232 ORDER BY file_changes_id DESC LIMIT 1
234233 ~, __FILE__, __LINE__);
235234 my $cur_sha = $cur ? $cur->{blob_sha} : '';
236235 next unless $cur_sha && $cur_sha ne $pin->{pinned_sha};
237236
238237 # Have we already logged + notified this (pin, current_sha) tuple?
239238 my $q_pinned = $dbh->quote($pin->{pinned_sha});
240239 my $q_current = $dbh->quote($cur_sha);
241240 my $seen = $db->db_readwrite($dbh, qq~
242241 SELECT drift_id, notified FROM PIN_DRIFT_LOG
243242 WHERE pin_id = $pin->{pin_id}
244243 AND current_sha = $q_current
245244 ORDER BY drift_id DESC LIMIT 1
246245 ~, __FILE__, __LINE__);
247246 if ($seen && $seen->{notified}) {
248247 next; # already notified for this exact drift state
249248 }
250249
251250 # Log the drift
252251 my $q_fname = $dbh->quote($pin->{file_name});
253252 my $cid = int($cur->{file_changes_id} || 0);
254253 $db->db_readwrite($dbh, qq~
255254 INSERT INTO PIN_DRIFT_LOG
256255 (named_release_id, pin_id, file_name, pinned_sha, current_sha, file_changes_id, notified)
257256 VALUES
258257 ($rid, $pin->{pin_id}, $q_fname, $q_pinned, $q_current, $cid, 0)
259258 ~, __FILE__, __LINE__);
260259 push @drifts, {
261260 file_name => $pin->{file_name},
262261 pinned_sha => $pin->{pinned_sha},
263262 current_sha => $cur_sha,
264263 change_id => $cid,
265264 };
266265 }
267266
268267 next unless @drifts;
269268
270269 # Build a summary payload
271270 my $summary = sprintf('Named release "%s" -- %d pinned file(s) have drifted', $rel->{name}, scalar @drifts);
272271 my $public_url = $cfg->settings('public_url') || 'https://watchtower.3dshawn.com';
273272 my $files_list = join("\n", map {
274273 " * $_->{file_name} (pinned: " . substr($_->{pinned_sha}, 0, 12) .
275274 " -> current: " . substr($_->{current_sha}, 0, 12) . ")"
276275 } @drifts);
277276 my $link = "$public_url/restore_release.cgi?release_id=$rid";
278277
279278 my $slack_body = {
280279 text => "*DriftSense pin drift*: $summary",
281280 attachments => [{
282281 color => '#f43f5e',
283282 fields => [
284283 { title => 'Release', value => $rel->{name}, short => 1 },
285284 { title => 'Drifted files', value => scalar(@drifts), short => 1 },
286285 { title => 'Details', value => $files_list, short => 0 },
287286 ],
288287 actions => [{ type => 'button', text => 'Restore all pinned', url => $link }],
289288 }],
290289 };
291290 my $discord_body = {
292291 content => "**DriftSense pin drift**: $summary",
293292 embeds => [{
294293 title => $rel->{name},
295294 url => $link,
296295 color => 16005694,
297296 description => $files_list,
298297 }],
299298 };
300299 my $generic_body = {
301300 source => 'drift_sense',
302301 alert_type => 'pin_drift',
303302 release_id => $rid,
304303 release_name => $rel->{name},
305304 drifted_count=> scalar @drifts,
306305 summary => $summary,
307306 files => \@drifts,
308307 restore_url => $link,
309308 };
310309
311310 my $body_ref = $rel->{alert_delivery_kind} eq 'slack' ? $slack_body
312311 : $rel->{alert_delivery_kind} eq 'discord' ? $discord_body
313312 : $generic_body;
314313
315314 my ($code, $rbody, $err) = MODS::Webhook::post_json(
316315 $rel->{alert_delivery_url}, $body_ref,
317316 timeout => 8,
318317 user_agent => 'DriftSense/1.0 (pin-drift)',
319318 );
320319
321320 if (!$err && $code >= 200 && $code < 400) {
322321 # Mark all just-logged rows as notified
323322 foreach my $d (@drifts) {
324323 my $q_current = $dbh->quote($d->{current_sha});
325324 $db->db_readwrite($dbh, qq~
326325 UPDATE PIN_DRIFT_LOG SET notified = 1
327326 WHERE named_release_id = $rid
328327 AND file_name = @{[ $dbh->quote($d->{file_name}) ]}
329328 AND current_sha = $q_current
330329 AND notified = 0
331330 ~, __FILE__, __LINE__);
332331 }
333332 $delivered++;
334333 print "[$ts] pin-drift alert delivered for release '$rel->{name}' (" . scalar(@drifts) . " drifted)\n";
335334 } else {
336335 $failed++;
337336 print "[$ts] pin-drift alert FAILED for release '$rel->{name}': " . ($err // "HTTP $code") . "\n";
338337 }
339338
340339 # Bookkeep the check timestamp
341340 $db->db_readwrite($dbh, qq~
342341 UPDATE NAMED_RELEASES SET last_pin_drift_check_at = NOW()
343342 WHERE named_release_id = $rid
344343 ~, __FILE__, __LINE__);
345344}
346345
347346$db->db_disconnect($dbh);
348347print "[$ts] processed +$total_new new changes: $delivered delivered, $skipped skipped, $failed failed\n";
349348exit 0;
350349
351350#---------------------------------------------------------------------
352351sub _process_row {
353352 my ($row, $kind) = @_;
354353 foreach my $rule (@rules) {
355354 next unless _rule_matches($rule, $row, $kind);
356355
357356 # Rate limit check
358357 if ($rule->{rate_limit_per_min} > 0) {
359358 my $rlq = $db->db_readwrite($dbh, qq~
360359 SELECT COUNT(*) AS n FROM ALERT_DELIVERIES
361360 WHERE alert_rule_id = $rule->{alert_rule_id}
362361 AND fired_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)
363362 AND delivery_status IN ('sent','pending')
364363 ~, __FILE__, __LINE__);
365364 if ($rlq && $rlq->{n} >= $rule->{rate_limit_per_min}) {
366365 _log_delivery($rule, $row, $kind, 'skipped', undef, undef,
367366 "rate limit ($rule->{rate_limit_per_min}/min) reached");
368367 $skipped++;
369368 next;
370369 }
371370 }
372371
373372 # Build payload + POST
374373 my $payload = _build_payload($rule, $row, $kind);
375374 my ($status, $http_code, $body, $err) = _deliver($rule, $payload);
376375 _log_delivery($rule, $row, $kind, $status, $http_code, $body, $err);
377376
378377 if ($status eq 'sent') { $delivered++; }
379378 elsif ($status eq 'skipped') { $skipped++; }
380379 else { $failed++; }
381380
382381 # Bump the rule's fire_count + last_fired_at
383382 if ($status eq 'sent') {
384383 $db->db_readwrite($dbh, qq~
385384 UPDATE ALERT_RULES
386385 SET fire_count = fire_count + 1,
387386 last_fired_at = NOW()
388387 WHERE alert_rule_id = $rule->{alert_rule_id}
389388 ~, __FILE__, __LINE__);
390389 }
391390 }
392391}
393392
394393#---------------------------------------------------------------------
395394sub _rule_matches {
396395 my ($rule, $row, $kind) = @_;
397396
398397 # Kind filter
399398 return 0 if $rule->{match_kind} ne 'either' && $rule->{match_kind} ne $kind;
400399
401400 # ts-only exclusion (files only)
402401 return 0 if $kind eq 'file' && $rule->{exclude_ts_only} && $row->{is_ts_only};
403402
404403 # Path glob
405404 my $glob = $rule->{match_path_glob} // '';
406405 if (length $glob) {
407406 my $target = $kind eq 'file'
408407 ? ($row->{file_name} // '')
409408 : (($row->{database_name} // '') . '.' . ($row->{table_name} // ''));
410409 my $re = _glob_to_regex($glob);
411410 return 0 unless $target =~ $re;
412411 }
413412
414413 # Status filter (files only)
415414 if ($kind eq 'file' && $rule->{match_status_list}) {
416415 my %allowed = map { s/^\s+|\s+$//g; $_ => 1 }
417416 split /,/, $rule->{match_status_list};
418417 return 0 unless $allowed{ $row->{status} // '' };
419418 }
420419
421420 return 1;
422421}
423422
424423sub _glob_to_regex {
425424 my $g = shift;
426425 my $re = quotemeta $g;
427426 # Handle glob patterns: escaped meta chars back to regex meaning
428427 $re =~ s{\\\*\\\*}{.*}g; # ** -> .*
429428 $re =~ s{\\\*}{[^/]*}g; # * -> [^/]*
430429 $re =~ s{\\\?}{.}g; # ? -> .
431430 return qr/^$re$/;
432431}
433432
434433#---------------------------------------------------------------------
435434sub _build_payload {
436435 my ($rule, $row, $kind) = @_;
437436
438437 my $where = $kind eq 'file'
439438 ? ($row->{file_name} // '(unknown file)')
440439 : (($row->{database_name} // '?') . '.' . ($row->{table_name} // '?'));
441440 my $when_utc = $row->{date_time} || $row->{change_datetime} || '';
442441 my $server = $row->{server_name} || 'local';
443442 my $status_h = $kind eq 'file' ? ($row->{status} // '') : 'DDL change';
444443 my $link_id = $row->{id};
445444 my $link = "$public_url/diff.cgi?kind=$kind&id=$link_id";
446445
447446 my $summary = $kind eq 'file'
448447 ? "$status_h: $where on $server"
449448 : "$where changed on $server";
450449
451450 my $slack = {
452451 text => "*DriftSense*: $summary",
453452 attachments => [{
454453 color => $kind eq 'schema' ? '#f59e0b' : '#14b8a6',
455454 fields => [
456455 { title => 'What', value => $where, short => 0 },
457456 { title => 'When', value => $when_utc, short => 1 },
458457 { title => 'Server', value => $server, short => 1 },
459458 ],
460459 actions => [{
461460 type => 'button', text => 'View diff', url => $link,
462461 }],
463462 }],
464463 };
465464 my $discord = {
466465 content => "**DriftSense**: $summary",
467466 embeds => [{
468467 title => $where,
469468 url => $link,
470469 color => $kind eq 'schema' ? 16101915 : 1349286, # amber, teal
471470 fields => [
472471 { name => 'When', value => $when_utc || '?', inline => \1 },
473472 { name => 'Server', value => $server, inline => \1 },
474473 ],
475474 }],
476475 };
477476 my $generic = {
478477 source => 'drift_sense',
479478 rule_id => $rule->{alert_rule_id},
480479 rule_name => $rule->{rule_name},
481480 kind => $kind,
482481 summary => $summary,
483482 target => $where,
484483 server => $server,
485484 when_utc => $when_utc,
486485 ts_epoch => $row->{ts_epoch},
487486 diff_url => $link,
488487 status => $status_h,
489488 };
490489
491490 return {
492491 slack => $slack,
493492 discord => $discord,
494493 generic_webhook => $generic,
495494 summary => $summary,
496495 };
497496}
498497
499498#---------------------------------------------------------------------
500499sub _deliver {
501500 my ($rule, $payload) = @_;
502501 my $kind = $rule->{delivery_kind};
503502 my $url = $rule->{delivery_url};
504503
505504 if ($kind eq 'email') {
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");
505 return ('skipped', undef, undef, 'email delivery not yet implemented');
548506 }
549507 unless (length($url // '')) {
550508 return ('failed', undef, undef, "no delivery URL configured");
551509 }
552510
553511 my $body_ref = $kind eq 'slack' ? $payload->{slack}
554512 : $kind eq 'discord' ? $payload->{discord}
555513 : $payload->{generic_webhook};
556514
557515 # MODS::Webhook handles JSON encoding + curl transport; returns
558516 # (http_code, response_body, error_message).
559517 my ($code, $rbody, $err) = MODS::Webhook::post_json(
560518 $url, $body_ref,
561519 timeout => 8,
562520 user_agent => 'DriftSense/1.0 (alert-dispatch)',
563521 );
564522 $rbody //= '';
565523 $rbody = substr($rbody, 0, 500) if length($rbody) > 500;
566524
567525 if (!$err && $code >= 200 && $code < 400) {
568526 return ('sent', $code, $rbody, undef);
569527 }
570528 return ('failed', $code, $rbody, $err || "HTTP $code");
571529}
572530
573531#---------------------------------------------------------------------
574532sub _log_delivery {
575533 my ($rule, $row, $kind, $status, $http_code, $body, $err) = @_;
576534 my $summary = substr(($rule->{rule_name} // '') . ' :: ' . ($row->{file_name}
577535 || ($row->{database_name} . '.' . $row->{table_name})
578536 || '?'), 0, 490);
579537 my $q_summary = $dbh->quote($summary);
580538 my $q_status = $dbh->quote($status);
581539 my $q_body = $dbh->quote($body // '');
582540 my $q_err = $dbh->quote($err // '');
583541 my $q_code = defined($http_code) ? int($http_code) : 'NULL';
584542
585543 $db->db_readwrite($dbh, qq~
586544 INSERT INTO ALERT_DELIVERIES
587545 (alert_rule_id, source_kind, source_id, match_summary,
588546 delivery_status, http_status, http_response, error_message)
589547 VALUES
590548 ($rule->{alert_rule_id}, '$kind', $row->{id}, $q_summary,
591549 $q_status, $q_code, $q_body, $q_err)
592550 ~, __FILE__, __LINE__);
593551}
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.
Cancel Logged to RESTORE_LOG regardless of outcome.