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/_agent_scan.pl
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-11 23:03:27
Captured SHAe833d76d304c6db47c44560e638ca05c3f3b88c121df6079b48324d01b082e95
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size16577 bytes
Current SHAbf5b44533145
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. +5 additions, -52 deletions, 402 unchanged context lines.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- SSH agent scanner (Stealth Flavor 2)
44#
55# Cron entry point (NOT a CGI). For each active SERVERS row with
66# kind='ssh_agent', SSHes into the monitored host using the configured
77# restricted key and walks the paths in that server's FILE_MONITOR_SETTINGS.
88#
99# Monitored host runs zero DriftSense code -- only a single command-
1010# restricted authorized_keys entry. The wrapper on the remote side
1111# only allows 3 commands:
1212#
1313# drift-find <path> -> path\tsize\tmtime\n per file
1414# drift-hash <files> -> sha256sum output
1515# drift-read <file> -> raw bytes
1616#
1717# We drive it via SSH_ORIGINAL_COMMAND. If the wrapper isn't installed,
1818# we fall back to plain `find`, `sha256sum`, `cat` on the assumption
1919# that the SSH user has a normal shell (test-mode).
2020#
2121# Config: /etc/drift_sense/drift_sense.conf (via MODS::Config).
2222# Log: /var/log/drift_sense/agent_scan.log (via cron redirect).
2323#======================================================================
2424use strict;
2525use warnings;
2626use lib '/var/www/vhosts/3dshawn.com/site1';
2727use POSIX ();
2828use Digest::SHA qw(sha256_hex);
2929use Compress::Zlib qw(compress);
3030use IPC::Open3;
3131use Symbol 'gensym';
3232use MODS::Config;
3333use MODS::DBConnect;
34use MODS::Crypto;
35use Fcntl ();
3634
3735$| = 1;
3836my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
3937
4038my $cfg = MODS::Config->new;
4139my $db = MODS::DBConnect->new;
4240my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
4341
4442my $default_max_size = int($cfg->settings('max_file_size_bytes') || 10_485_760);
4543my $DEFAULT_TIMEOUT = 30;
46
47# ---- Global scan settings (SCAN_GLOBALS singleton) -----------------
48my $g_row = $db->db_readwrite($dbh, q~
49 SELECT global_ignore_list, global_file_type_filter, global_max_file_size_bytes
50 FROM SCAN_GLOBALS WHERE id = 1 LIMIT 1
51~, __FILE__, __LINE__);
52my $global_ignore = ($g_row && $g_row->{global_ignore_list}) // '';
53my $global_ftypes = ($g_row && $g_row->{global_file_type_filter}) // '';
54my $global_max_size = ($g_row && $g_row->{global_max_file_size_bytes}) || 0;
55$default_max_size = $global_max_size if $global_max_size;
5644
5745# ---- Fetch ssh_agent servers ----------------------------------------
5846my @servers = $db->db_readwrite_multiple($dbh, q~
5947 SELECT server_id, server_name, ssh_host, ssh_user, ssh_port,
60 ssh_key_path, ssh_options, ssh_key_blob, ssh_key_enc
48 ssh_key_path, ssh_options
6149 FROM SERVERS
6250 WHERE kind = 'ssh_agent'
6351 AND status = 'active'
6452~, __FILE__, __LINE__);
6553
6654unless (@servers) {
6755 print "[$ts] no active ssh_agent servers, exiting\n";
6856 $db->db_disconnect($dbh);
6957 exit 0;
7058}
7159
7260my $total_added = 0;
7361my $total_modified = 0;
7462my $total_deleted = 0;
7563
7664foreach my $server (@servers) {
7765 my $sid = $server->{server_id};
7866 my $host = $server->{ssh_host} || next;
7967 my $user = $server->{ssh_user} || 'root';
8068 my $port = $server->{ssh_port} || 22;
69 my $key = $server->{ssh_key_path} || '';
8170 my $opts = $server->{ssh_options} || '';
82
83 # Resolve key: prefer encrypted blob (decrypt to a mode-600 temp
84 # file for the SSH call), else fall back to on-disk ssh_key_path.
85 my $key_path = $server->{ssh_key_path} || '';
86 my $tmp_key;
87 if ($server->{ssh_key_blob}) {
88 my $pem = eval { MODS::Crypto::decrypt($server->{ssh_key_blob}) };
89 if ($pem) {
90 $tmp_key = _materialize_key($pem);
91 $key_path = $tmp_key if $tmp_key;
92 } else {
93 print "[$ts] server $server->{server_name}: key decrypt failed ($@)\n";
94 }
95 }
9671
97 my @ssh_base = _ssh_argv($user, $host, $port, $key_path, $opts);
72 my @ssh_base = _ssh_argv($user, $host, $port, $key, $opts);
9873
9974 # Fetch this server's active file monitors
10075 my @scans = $db->db_readwrite_multiple($dbh, qq~
10176 SELECT file_monitor_list_id, scan_path, ignore_list, file_type_filter,
10277 max_file_size_bytes, scan_name
10378 FROM FILE_MONITOR_SETTINGS
10479 WHERE status = 1
10580 AND server_id = $sid
10681 ~, __FILE__, __LINE__);
10782
10883 unless (@scans) {
10984 print "[$ts] server '$server->{server_name}' has no active monitors\n";
11085 next;
11186 }
11287
11388 my $server_err;
11489
11590 foreach my $scan (@scans) {
11691 my $path = $scan->{scan_path} or next;
11792 my $max_size = $scan->{max_file_size_bytes} || $default_max_size;
11893
119 # UNION global + per-monitor ignore lists
120 my $combined_ignore = join(',', grep { length } ($global_ignore, ($scan->{ignore_list} // '')));
121 my %ignore_pats = _build_ignore_patterns($combined_ignore);
122 # Per-monitor filter wins if set; else global
123 my $eff_ftypes = length($scan->{file_type_filter} // '')
124 ? $scan->{file_type_filter}
125 : $global_ftypes;
126 my %type_filter = _build_type_filter($eff_ftypes);
94 my %ignore_pats = _build_ignore_patterns($scan->{ignore_list});
95 my %type_filter = _build_type_filter($scan->{file_type_filter});
12796
12897 # ---- Remote listing -----------------------------------------
12998 my $remote_cmd = qq{drift-find $path};
13099 my ($rc, $out, $err) = _ssh_run(\@ssh_base, $remote_cmd);
131100 if ($rc != 0) {
132101 # Fallback: no wrapper -> plain shell
133102 my $shell = qq{find } . _sh_quote($path) . qq{ -type f -not -path '*/.*' -printf '%p\\t%s\\t%T@\\n' 2>/dev/null};
134103 ($rc, $out, $err) = _ssh_run(\@ssh_base, $shell);
135104 }
136105 if ($rc != 0) {
137106 $server_err = "list failed: $err";
138107 print "[$ts] server '$server->{server_name}' scan '$scan->{scan_name}': $server_err\n";
139108 next;
140109 }
141110
142111 # Parse listing: path\tsize\tmtime_epoch
143112 my %remote;
144113 foreach my $line (split /\n/, $out) {
145114 chomp $line;
146115 next unless length $line;
147116 my ($p, $sz, $mt) = split /\t/, $line, 3;
148117 next unless defined $p && defined $sz && defined $mt;
149118 $sz = int($sz);
150119 $mt = int($mt); # printf gives float mtime, truncate
151120
152121 # Ignore-glob filter (local side)
153122 my $skip = 0;
154123 for my $pat (keys %ignore_pats) {
155124 my $re = $ignore_pats{$pat};
156125 if ($p =~ $re) { $skip = 1; last; }
157126 }
158127 next if $skip;
159128
160129 # File-type filter (local side)
161130 if (%type_filter) {
162131 my $ext_ok = 0;
163132 for my $e (keys %type_filter) {
164133 if (lc(substr($p, -length($e))) eq $e) { $ext_ok = 1; last; }
165134 }
166135 next unless $ext_ok;
167136 }
168137
169138 next if $sz > $max_size;
170139 $remote{$p} = { size => $sz, mtime => $mt };
171140 }
172141
173142 # ---- Previous state ----------------------------------------
174143 my $prev_sth = $dbh->prepare(qq{
175144 SELECT fc.file_name, fc.blob_sha, fc.status,
176145 UNIX_TIMESTAMP(fc.date_time) AS mt_epoch
177146 FROM FILE_CHANGES fc
178147 WHERE fc.file_monitor_list_id = $scan->{file_monitor_list_id}
179148 AND fc.server_id = $sid
180149 AND fc.file_changes_id IN (
181150 SELECT MAX(inner_fc.file_changes_id)
182151 FROM FILE_CHANGES inner_fc
183152 WHERE inner_fc.file_monitor_list_id = $scan->{file_monitor_list_id}
184153 AND inner_fc.server_id = $sid
185154 GROUP BY inner_fc.file_name
186155 )
187156 });
188157 my %prev;
189158 if ($prev_sth && $prev_sth->execute) {
190159 while (my $r = $prev_sth->fetchrow_hashref) {
191160 $prev{$r->{file_name}} = $r;
192161 }
193162 $prev_sth->finish;
194163 }
195164
196165 # ---- Decide who needs a fresh hash --------------------------
197166 my @needs_hash;
198167 foreach my $p (keys %remote) {
199168 my $prev_row = $prev{$p};
200169 if (!$prev_row || ($prev_row->{status} // '') eq 'deleted') {
201170 push @needs_hash, $p;
202171 next;
203172 }
204173 if (($prev_row->{mt_epoch} // 0) != $remote{$p}{mtime}) {
205174 push @needs_hash, $p;
206175 }
207176 }
208177
209178 # ---- Batch hash over SSH -----------------------------------
210179 # `drift-hash <files>` returns sha256sum output: "<sha> <path>"
211180 my %fresh_sha;
212181 if (@needs_hash) {
213182 my $q_list = join(' ', map { _sh_quote($_) } @needs_hash);
214183 my $cmd = "drift-hash $q_list";
215184 my ($h_rc, $h_out, $h_err) = _ssh_run(\@ssh_base, $cmd);
216185 if ($h_rc != 0) {
217186 # Fallback: plain sha256sum
218187 $cmd = "sha256sum $q_list 2>/dev/null";
219188 ($h_rc, $h_out, $h_err) = _ssh_run(\@ssh_base, $cmd);
220189 }
221190 if ($h_rc == 0) {
222191 foreach my $line (split /\n/, $h_out) {
223192 chomp $line;
224193 if ($line =~ /^([0-9a-f]{64})\s+(.+)$/) {
225194 $fresh_sha{$2} = $1;
226195 }
227196 }
228197 }
229198 }
230199
231200 # ---- Process each remote file ------------------------------
232201 foreach my $p (keys %remote) {
233202 my $sha = $fresh_sha{$p};
234203 my $prev_row = $prev{$p};
235204
236205 # If we didn't rehash this file, previous SHA still valid
237206 if (!defined $sha && $prev_row && ($prev_row->{status} // '') ne 'deleted') {
238207 # Nothing changed since last tick -- move on
239208 next;
240209 }
241210
242211 # Content-addressable dedup: if we already have this SHA in
243212 # BLOB_STORE, don't re-fetch content over SSH.
244213 my $have = $db->db_readwrite($dbh,
245214 "SELECT blob_sha FROM BLOB_STORE WHERE blob_sha = " . $dbh->quote($sha) . " LIMIT 1",
246215 __FILE__, __LINE__);
247216
248217 my $status;
249218 if (!$prev_row) {
250219 $status = 'added';
251220 } else {
252221 $status = 'modified';
253222 if ($prev_row->{blob_sha} && $prev_row->{blob_sha} eq $sha) {
254223 # Same bytes, just an mtime touch -- flag ts-only
255224 $status = 'modified';
256225 }
257226 }
258227
259228 # Fetch + store blob only if we don't already have it
260229 if (!($have && $have->{blob_sha})) {
261230 my $cmd = 'drift-read ' . _sh_quote($p);
262231 my ($c_rc, $c_out, $c_err) = _ssh_run(\@ssh_base, $cmd);
263232 if ($c_rc != 0) {
264233 $cmd = 'cat ' . _sh_quote($p);
265234 ($c_rc, $c_out, $c_err) = _ssh_run(\@ssh_base, $cmd);
266235 }
267236 if ($c_rc != 0) {
268237 print "[$ts] fetch $p on $server->{server_name}: $c_err\n";
269238 next;
270239 }
271240 # Verify the fetched content matches the reported hash
272241 my $verify_sha = sha256_hex($c_out);
273242 if ($verify_sha ne $sha) {
274243 print "[$ts] $p: hash mismatch (list=$sha vs read=$verify_sha), using read\n";
275244 $sha = $verify_sha;
276245 }
277246 _store_blob($db, $dbh, $sha, $c_out);
278247 }
279248
280249 # Determine timestamp-only via prev SHA compare
281250 my $is_ts_only = 0;
282251 if ($prev_row && $prev_row->{blob_sha} && $prev_row->{blob_sha} eq $sha) {
283252 $is_ts_only = 1;
284253 }
285254
286255 # Insert FILE_CHANGES row keyed to this server
287256 my $q_file = $dbh->quote($p);
288257 my $q_sha = $dbh->quote($sha);
289258 my $q_stat = $dbh->quote($status);
290259 my $mt = $remote{$p}{mtime};
291260 $db->db_readwrite($dbh, qq{
292261 INSERT INTO FILE_CHANGES
293262 (file_monitor_list_id, server_id, container_target_id,
294263 file_name, blob_sha, is_ts_only, date_time, status)
295264 VALUES
296265 ($scan->{file_monitor_list_id}, $sid, NULL,
297266 $q_file, $q_sha, $is_ts_only,
298267 FROM_UNIXTIME($mt), $q_stat)
299268 }, __FILE__, __LINE__);
300269
301270 if ($is_ts_only) { }
302271 elsif ($status eq 'added') { $total_added++; }
303272 else { $total_modified++; }
304273 }
305274
306275 # ---- Delete detection --------------------------------------
307276 foreach my $p (keys %prev) {
308277 next if $remote{$p};
309278 next if ($prev{$p}{status} // '') eq 'deleted';
310279 my $q_file = $dbh->quote($p);
311280 $db->db_readwrite($dbh, qq{
312281 INSERT INTO FILE_CHANGES
313282 (file_monitor_list_id, server_id, file_name, status, date_time)
314283 VALUES
315284 ($scan->{file_monitor_list_id}, $sid, $q_file, 'deleted', NOW())
316285 }, __FILE__, __LINE__);
317286 $total_deleted++;
318287 }
319288
320289 $db->db_readwrite($dbh, qq{
321290 UPDATE FILE_MONITOR_SETTINGS
322291 SET last_scanned = NOW()
323292 WHERE file_monitor_list_id = $scan->{file_monitor_list_id}
324293 }, __FILE__, __LINE__);
325294 }
326295
327296 # Server-level bookkeeping
328297 my $q_err = $dbh->quote($server_err || '');
329298 $db->db_readwrite($dbh, qq{
330299 UPDATE SERVERS
331300 SET last_agent_scan_at = NOW(),
332301 last_agent_error = $q_err,
333302 last_heartbeat_at = NOW()
334303 WHERE server_id = $sid
335304 }, __FILE__, __LINE__);
336
337 # Clean up decrypted-key temp file
338 unlink $tmp_key if $tmp_key && $tmp_key =~ m!^/tmp/\.ds_agent_key_!;
339305}
340306
341307$db->db_disconnect($dbh);
342308print "[$ts] agent scan complete: +$total_added added, ~$total_modified modified, -$total_deleted deleted\n"
343309 if ($total_added + $total_modified + $total_deleted) > 0;
344310exit 0;
345311
346312#---------------------------------------------------------------------
347313sub _ssh_argv {
348314 my ($user, $host, $port, $key, $opts) = @_;
349315 my @a = ('/usr/bin/ssh',
350316 '-o', 'BatchMode=yes',
351317 '-o', 'StrictHostKeyChecking=no',
352318 '-o', 'UserKnownHostsFile=/dev/null',
353319 '-o', 'LogLevel=ERROR',
354320 '-o', 'ConnectTimeout=8',
355321 '-p', $port);
356322 if ($key && -r $key) {
357323 push @a, '-i', $key;
358324 }
359325 if ($opts) {
360326 # Space-separated -o key=value pairs
361327 foreach my $o (split /\s+/, $opts) {
362328 push @a, '-o', $o if length $o;
363329 }
364330 }
365331 push @a, "$user\@$host";
366332 return @a;
367333}
368334
369335sub _ssh_run {
370336 my ($base, $cmd) = @_;
371337 my @argv = (@$base, $cmd);
372338 my ($wtr, $rdr, $err_fh);
373339 $err_fh = gensym;
374340 my $pid = eval { open3($wtr, $rdr, $err_fh, @argv) };
375341 return (-1, '', $@) if $@ || !$pid;
376342 close $wtr;
377343 binmode $rdr;
378344 my $out = do { local $/; <$rdr> // '' };
379345 my $err = do { local $/; <$err_fh> // '' };
380346 close $rdr; close $err_fh;
381347 waitpid $pid, 0;
382348 my $rc = $? >> 8;
383349 return ($rc, $out, $err);
384}
385
386sub _materialize_key {
387 my ($pem) = @_;
388 return undef unless defined $pem && length $pem;
389 my $t = POSIX::strftime('%s', localtime);
390 my $path = "/tmp/.ds_agent_key_${t}_$$_" . int(rand(1_000_000));
391 sysopen(my $fh, $path,
392 Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL(),
393 0600) or return undef;
394 print $fh $pem;
395 close $fh;
396 return $path;
397350}
398351
399352sub _sh_quote {
400353 my $s = shift; $s //= '';
401354 # Single-quote and escape internal single quotes
402355 $s =~ s/'/'\\''/g;
403356 return "'$s'";
404357}
405358
406359sub _build_ignore_patterns {
407360 my $list = shift;
408361 my %out;
409362 return %out unless $list;
410363 for my $ig (split /[,\n]+/, $list) {
411364 $ig =~ s/^\s+|\s+$//g;
412365 next unless length $ig;
413366 my $re = quotemeta $ig;
414367 $re =~ s/\\\*/.*/g;
415368 $re =~ s/\\\?/./g;
416369 $out{$ig} = qr/$re/;
417370 }
418371 return %out;
419372}
420373
421374sub _build_type_filter {
422375 my $list = shift;
423376 my %out;
424377 return %out unless $list && length $list;
425378 for my $ext (split /,/, $list) {
426379 $ext =~ s/^\s+|\s+$//g;
427380 next unless length $ext;
428381 $ext = ".$ext" unless $ext =~ /^\./;
429382 $out{lc $ext} = 1;
430383 }
431384 return %out;
432385}
433386
434387sub _store_blob {
435388 my ($db, $dbh, $sha, $content) = @_;
436389 my $existing = $db->db_readwrite($dbh,
437390 "SELECT blob_sha FROM BLOB_STORE WHERE blob_sha = " . $dbh->quote($sha) . " LIMIT 1",
438391 __FILE__, __LINE__);
439392 if ($existing && $existing->{blob_sha}) {
440393 $db->db_readwrite($dbh,
441394 "UPDATE BLOB_STORE SET ref_count = ref_count + 1 WHERE blob_sha = " . $dbh->quote($sha),
442395 __FILE__, __LINE__);
443396 return;
444397 }
445398 my $gz = compress($content) // '';
446399 my $sth = $dbh->prepare(qq{
447400 INSERT INTO BLOB_STORE (blob_sha, content_gz, size_uncompressed, size_compressed, ref_count, first_seen_at)
448401 VALUES (?, ?, ?, ?, 1, NOW())
449402 });
450403 if ($sth) {
451404 $sth->execute($sha, $gz, length($content), length($gz));
452405 $sth->finish;
453406 }
454407}
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.