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/servers.cgi
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-10 18:57:31
Captured SHAe9d45d9480706e8505bd05d4fe0efe2d53a15ddf78a08531e8e98aeb1337e635
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size14615 bytes
Current SHA45efe1058831
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. +13 additions, -357 deletions, 12 unchanged context lines.
11#!/usr/bin/perl
2#======================================================================
3# DriftSense -- /servers.cgi
4#
5# List, add, edit, and bulk-import monitored servers. Includes a
6# "Test connection" mini-endpoint at ?op=test&id=N that runs an SSH
7# probe and reports pass/fail.
8#
9# Modes:
10# GET /servers.cgi -> list + add/edit form
11# GET /servers.cgi?edit=N -> edit mode
12# GET /servers.cgi?bulk=1 -> bulk import form
13# GET /servers.cgi?op=test&id=N -> AJAX SSH probe (returns JSON)
14# POST /servers.cgi op=create/update -> single-server upsert
15# POST /servers.cgi op=bulk_import -> multi-line paste + one key
16# POST /servers.cgi op=delete -> soft delete (status=disabled)
17#======================================================================
18use strict;
19use warnings;
20use CGI ();
21use JSON::PP ();
22use IPC::Open3;
23use Symbol 'gensym';
24use Fcntl ();
25use POSIX ();
26use MODS::Config;
27use MODS::DBConnect;
28use MODS::Template;
29use MODS::PageWrapper;
30use MODS::Crypto;
31
32my $cgi = CGI->new;
33my $db = MODS::DBConnect->new;
34my $tpl = MODS::Template->new;
35my $method = $ENV{REQUEST_METHOD} || 'GET';
36my $op = $cgi->param('op') || '';
37
38$|=1;
39my $dbh = $db->db_connect or die "DriftSense: cannot connect to storage DB\n";
40
41# ---- AJAX: test connection ------------------------------------------
42if ($op eq 'test') {
43 my $sid = int($cgi->param('id') || 0);
44 print "Content-Type: application/json\nCache-Control: no-cache\n\n";
45 my $row = $db->db_readwrite($dbh, qq~
46 SELECT server_id, server_name, kind, ssh_host, ssh_user, ssh_port,
47 ssh_key_path, ssh_options, ssh_key_blob
48 FROM SERVERS WHERE server_id = $sid LIMIT 1
49 ~, __FILE__, __LINE__);
50 unless ($row && $row->{server_id}) {
51 print JSON::PP::encode_json({ ok => \0, error => 'server not found' });
52 exit;
53 }
54 if ($row->{kind} eq 'local') {
55 print JSON::PP::encode_json({ ok => \1, kind => 'local', message => 'local scanner, no SSH needed' });
56 exit;
57 }
58 my ($ok, $msg) = _ssh_probe($row);
59 print JSON::PP::encode_json({ ok => $ok ? \1 : \0, message => $msg });
60 exit;
61}
62
63# ---- POST actions ---------------------------------------------------
64if ($method eq 'POST') {
65 if ($op eq 'create' || $op eq 'update') {
66 my $sid = $op eq 'update' ? int($cgi->param('server_id') || 0) : 0;
67 my $data = _form_data($cgi);
68 _upsert_server($dbh, $sid, $data);
69 }
70 elsif ($op eq 'bulk_import') {
71 my $hosts_raw = $cgi->param('hosts') || '';
72 my $user = _clean($cgi->param('ssh_user'), 64, 'root');
73 my $port = int($cgi->param('ssh_port') || 22) || 22;
74 my $scan_path = _clean($cgi->param('scan_path'), 500, '/etc');
75 my $ignore = _clean($cgi->param('ignore_list'), 1000, '');
76 my $ftypes = _clean($cgi->param('file_type_filter'), 500, '');
77 my $key_pem = $cgi->param('ssh_key_blob_plain') || '';
78 my $key_enc = length $key_pem ? MODS::Crypto::encrypt($key_pem) : '';
79
80 my $created = 0;
81 foreach my $line (split /\r?\n/, $hosts_raw) {
82 $line =~ s/^\s+|\s+$//g;
83 next unless length $line;
84 # Accept optional label:host or "label host" or just host
85 my ($label, $host);
86 if ($line =~ /^([^\s,|=]+)\s*[|=]\s*(\S+)$/) {
87 ($label, $host) = ($1, $2);
88 } else {
89 $host = $line;
90 $label = $host;
91 }
92 _upsert_server($dbh, 0, {
93 server_name => $label,
94 kind => 'ssh_agent',
95 ssh_host => $host,
96 ssh_user => $user,
97 ssh_port => $port,
98 ssh_key_blob => $key_enc,
99 ssh_key_enc => $key_enc ? 'aes-256-cbc-pbkdf2' : undef,
100 status => 'active',
101 });
102 # Fetch newly-assigned id + attach the base scan
103 my $new = $db->db_readwrite($dbh,
104 "SELECT server_id FROM SERVERS WHERE server_name = " . $dbh->quote($label) . " LIMIT 1",
105 __FILE__, __LINE__);
106 my $new_sid = $new && $new->{server_id};
107 if ($new_sid && length $scan_path) {
108 my $q_path = $dbh->quote($scan_path);
109 my $q_ignore = $dbh->quote($ignore);
110 my $q_ftypes = $dbh->quote($ftypes);
111 my $q_name = $dbh->quote("$label -- $scan_path");
112 $db->db_readwrite($dbh, qq~
113 INSERT INTO FILE_MONITOR_SETTINGS
114 (server_id, scan_path, ignore_list, file_type_filter, status, scan_name)
115 VALUES
116 ($new_sid, $q_path, $q_ignore, $q_ftypes, 1, $q_name)
117 ~, __FILE__, __LINE__);
118 }
119 $created++;
120 }
121 print "Status: 302 Found\nLocation: /servers.cgi?imported=$created\n\n";
122 $db->db_disconnect($dbh);
123 exit;
124 }
125 elsif ($op eq 'delete') {
126 my $sid = int($cgi->param('server_id') || 0);
127 $db->db_readwrite($dbh,
128 "UPDATE SERVERS SET status='disabled' WHERE server_id = $sid",
129 __FILE__, __LINE__);
130 }
131
132 my $nc = int(rand() * 10_000_000);
133 print "Status: 302 Found\nLocation: /servers.cgi?nc=$nc\n\n";
134 $db->db_disconnect($dbh);
135 exit;
136}
137
138# ---- GET: page render -----------------------------------------------
139print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
140
141my $bulk_mode = $cgi->param('bulk') ? 1 : 0;
142my $edit_id = int($cgi->param('edit') || 0);
143my $imported = int($cgi->param('imported') || 0);
144
2use strict; use warnings; use CGI ();
3use MODS::Config; use MODS::DBConnect; use MODS::Template; use MODS::PageWrapper;
4my $db = MODS::DBConnect->new; my $tpl = MODS::Template->new;
5$|=1; print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
6my $dbh = $db->db_connect or die "DB connect failed\n";
1457my @rows = $db->db_readwrite_multiple($dbh, q~
146 SELECT server_id, server_name, kind, hostname, ip_address, role, status,
147 ssh_host, ssh_user, ssh_port, ssh_key_path, ssh_options,
148 ssh_key_blob IS NOT NULL AS has_key_blob,
149 DATE_FORMAT(last_heartbeat_at, '%b %d %H:%i') AS heartbeat_h,
150 UNIX_TIMESTAMP(last_heartbeat_at) AS heartbeat_epoch,
151 DATE_FORMAT(last_agent_scan_at, '%b %d %H:%i') AS last_scan_h,
152 UNIX_TIMESTAMP(last_agent_scan_at) AS last_scan_epoch,
153 last_agent_error,
8 SELECT server_id, server_name, hostname, ip_address, role, agent_version, status,
9 DATE_FORMAT(last_heartbeat_at, '%b %d, %Y %H:%i') AS heartbeat_h,
15410 DATE_FORMAT(created_at, '%b %d, %Y') AS created_h
155 FROM SERVERS
156 WHERE status != 'disabled'
157 ORDER BY kind ASC, server_id ASC
11 FROM SERVERS ORDER BY (status='active') DESC, server_id ASC
15812~, __FILE__, __LINE__);
15913foreach my $r (@rows) {
160 $r->{status_pill} = $r->{status} eq 'active' ? 'pill-ok'
161 : $r->{status} eq 'offline' ? 'pill-bad'
162 : 'pill-mute';
163 $r->{kind_pill} = $r->{kind} eq 'ssh_agent' ? 'pill-info' : 'pill-mute';
164 $r->{heartbeat_h} //= 'never';
165 $r->{heartbeat_epoch} //= 0;
166 $r->{last_scan_h} //= 'never';
167 $r->{last_scan_epoch} //= 0;
168 $r->{edit_url} = "/servers.cgi?edit=$r->{server_id}";
169 $r->{test_url} = "/servers.cgi?op=test&id=$r->{server_id}";
170 $r->{host_display} = $r->{ssh_host} || $r->{hostname} || 'localhost';
171 $r->{key_display} = $r->{has_key_blob} ? 'encrypted-blob'
172 : ($r->{ssh_key_path} ? _shorten($r->{ssh_key_path}, 40) : '-');
173 $r->{err_short} = $r->{last_agent_error} ? _shorten($r->{last_agent_error}, 80) : '';
174}
175
176# Edit-mode prefill
177my $edit = {};
178if ($edit_id) {
179 ($edit) = grep { $_->{server_id} == $edit_id } @rows;
180 $edit ||= {};
14 $r->{status_pill} = $r->{status} eq 'active' ? 'pill-ok' :
15 $r->{status} eq 'offline' ? 'pill-bad' : 'pill-mute';
16 $r->{heartbeat_h} //= 'never';
18117}
182my $is_edit = $edit_id && $edit && $edit->{server_id} ? 1 : 0;
183
184my %form = (
185 server_id => $edit->{server_id} // '',
186 server_name => $edit->{server_name} // '',
187 kind => $edit->{kind} // 'ssh_agent',
188 ssh_host => $edit->{ssh_host} // '',
189 ssh_user => $edit->{ssh_user} // 'root',
190 ssh_port => $edit->{ssh_port} // 22,
191 ssh_key_path=> $edit->{ssh_key_path}// '',
192 ssh_options => $edit->{ssh_options} // '',
193 has_key_blob=> $edit->{has_key_blob}? 1 : 0,
194);
195$form{"k_local_sel"} = ($form{kind} eq 'local') ? 'selected' : '';
196$form{"k_ssh_sel"} = ($form{kind} eq 'ssh_agent') ? 'selected' : '';
197$form{form_op} = $is_edit ? 'update' : 'create';
198
19918$db->db_disconnect($dbh);
200
201my $tvars = {
202 rows => \@rows,
203 has_rows => scalar(@rows) ? 1 : 0,
204 total => scalar @rows,
205 is_edit => $is_edit,
206 bulk_mode => $bulk_mode,
207 imported => $imported,
208 has_imported => $imported ? 1 : 0,
209 crypto_ready => MODS::Crypto::is_ready() ? 1 : 0,
210 %form,
211};
21219
213my @body = $tpl->template('servers.html', $tvars);
20my @body = $tpl->template('servers.html',
21 { rows => \@rows, has_rows => scalar(@rows) ? 1 : 0, total => scalar @rows });
21422MODS::PageWrapper->new->wrapper(
21523 page_title => 'Servers & agents', page_key => 'servers',
21624 body_html => join('', @body), userinfo => { display_name => 'Operator' },
21725);
218
219#---------------------------------------------------------------------
220sub _form_data {
221 my ($cgi) = @_;
222 my $data = {
223 server_name => _clean($cgi->param('server_name'), 120, ''),
224 kind => _one_of($cgi->param('kind'), 'ssh_agent', 'local', 'ssh_agent'),
225 ssh_host => _clean($cgi->param('ssh_host'), 255, undef),
226 ssh_user => _clean($cgi->param('ssh_user'), 64, 'root'),
227 ssh_port => int($cgi->param('ssh_port') || 22) || 22,
228 ssh_key_path => _clean($cgi->param('ssh_key_path'), 500, ''),
229 ssh_options => _clean($cgi->param('ssh_options'), 500, ''),
230 status => 'active',
231 };
232 # If a fresh PEM was pasted, encrypt + store
233 my $pem = $cgi->param('ssh_key_blob_plain') || '';
234 if (length $pem) {
235 my $enc = MODS::Crypto::encrypt($pem);
236 if ($enc) {
237 $data->{ssh_key_blob} = $enc;
238 $data->{ssh_key_enc} = 'aes-256-cbc-pbkdf2';
239 }
240 }
241 return $data;
242}
243
244sub _upsert_server {
245 my ($dbh, $sid, $d) = @_;
246 my $q = sub { $dbh->quote($_[0] // '') };
247 my @cols = qw(server_name kind ssh_host ssh_user ssh_port ssh_key_path ssh_options status);
248 push @cols, 'ssh_key_blob', 'ssh_key_enc' if exists $d->{ssh_key_blob};
249 if ($sid) {
250 my @sets;
251 push @sets, "server_name = " . $q->($d->{server_name});
252 push @sets, "kind = " . $q->($d->{kind});
253 push @sets, "ssh_host = " . $q->($d->{ssh_host});
254 push @sets, "ssh_user = " . $q->($d->{ssh_user});
255 push @sets, "ssh_port = " . int($d->{ssh_port} || 22);
256 push @sets, "ssh_key_path = " . $q->($d->{ssh_key_path});
257 push @sets, "ssh_options = " . $q->($d->{ssh_options});
258 push @sets, "status = " . $q->($d->{status});
259 if (exists $d->{ssh_key_blob}) {
260 push @sets, "ssh_key_blob = " . $q->($d->{ssh_key_blob});
261 push @sets, "ssh_key_enc = " . $q->($d->{ssh_key_enc});
262 }
263 my $sql = "UPDATE SERVERS SET " . join(', ', @sets) . " WHERE server_id = $sid";
264 $db->db_readwrite($dbh, $sql, __FILE__, __LINE__);
265 } else {
266 my @vals;
267 push @vals, $q->($d->{server_name});
268 push @vals, $q->($d->{kind});
269 push @vals, $q->($d->{ssh_host});
270 push @vals, $q->($d->{ssh_user});
271 push @vals, int($d->{ssh_port} || 22);
272 push @vals, $q->($d->{ssh_key_path});
273 push @vals, $q->($d->{ssh_options});
274 push @vals, $q->($d->{status});
275 my @col_list = qw(server_name kind ssh_host ssh_user ssh_port ssh_key_path ssh_options status);
276 if (exists $d->{ssh_key_blob}) {
277 push @col_list, 'ssh_key_blob', 'ssh_key_enc';
278 push @vals, $q->($d->{ssh_key_blob}), $q->($d->{ssh_key_enc});
279 }
280 # role has ENUM default -- always set to 'agent' for ssh_agent
281 # so the constraint is satisfied.
282 my $role = ($d->{kind} && $d->{kind} eq 'ssh_agent') ? 'agent' : 'central';
283 push @col_list, 'role';
284 push @vals, $q->($role);
285 my $sql = "INSERT INTO SERVERS (" . join(',', @col_list) . ") VALUES (" . join(',', @vals) . ")";
286 $db->db_readwrite($dbh, $sql, __FILE__, __LINE__);
287 }
288}
289
290sub _ssh_probe {
291 my ($row) = @_;
292 my $host = $row->{ssh_host} or return (0, 'no ssh_host');
293 my $user = $row->{ssh_user} || 'root';
294 my $port = $row->{ssh_port} || 22;
295 my $key_path = $row->{ssh_key_path} || '';
296 my $opts = $row->{ssh_options} || '';
297 my $tmp_key;
298
299 if ($row->{ssh_key_blob}) {
300 my $pem = eval { MODS::Crypto::decrypt($row->{ssh_key_blob}) };
301 if ($pem) {
302 my $t = POSIX::strftime('%s', localtime);
303 my $p = "/tmp/.ds_probe_key_${t}_$$_" . int(rand(1_000_000));
304 if (sysopen(my $fh, $p,
305 Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL(), 0600)) {
306 print $fh $pem; close $fh;
307 $key_path = $p;
308 $tmp_key = $p;
309 }
310 }
311 }
312
313 my @argv = ('/usr/bin/ssh',
314 '-o', 'BatchMode=yes',
315 '-o', 'StrictHostKeyChecking=no',
316 '-o', 'UserKnownHostsFile=/dev/null',
317 '-o', 'LogLevel=ERROR',
318 '-o', 'ConnectTimeout=6',
319 '-p', $port);
320 push @argv, '-i', $key_path if $key_path && -r $key_path;
321 if ($opts) {
322 foreach my $o (split /\s+/, $opts) {
323 push @argv, '-o', $o if length $o;
324 }
325 }
326 push @argv, "$user\@$host", 'echo drift_probe_ok && uname -n';
327
328 my ($wtr, $rdr, $err_fh);
329 $err_fh = gensym;
330 my $pid = eval { open3($wtr, $rdr, $err_fh, @argv) };
331 if ($@ || !$pid) {
332 unlink $tmp_key if $tmp_key;
333 return (0, "spawn: $@");
334 }
335 close $wtr;
336 my $out = do { local $/; <$rdr> // '' };
337 my $err = do { local $/; <$err_fh> // '' };
338 close $rdr; close $err_fh;
339 waitpid $pid, 0;
340 my $rc = $? >> 8;
341 unlink $tmp_key if $tmp_key;
342
343 if ($rc == 0 && $out =~ /drift_probe_ok/) {
344 my $hostname = ($out =~ /drift_probe_ok\s+(\S+)/) ? $1 : $host;
345 return (1, "OK -- reached $hostname");
346 }
347 $err =~ s/\s+/ /g; $err =~ s/^\s+|\s+$//g;
348 $err ||= "ssh exit $rc";
349 return (0, $err);
350}
351
352sub _clean {
353 my ($s, $max, $default) = @_;
354 return $default unless defined $s;
355 $s =~ s/[\r\n]/ /g;
356 $s =~ s/^\s+|\s+$//g;
357 $s = substr($s, 0, $max) if defined $max && length($s) > $max;
358 return length($s) ? $s : $default;
359}
360sub _one_of {
361 my ($v, $default, @allowed) = @_;
362 $v //= '';
363 return (grep { $_ eq $v } @allowed) ? $v : $default;
364}
365sub _shorten {
366 my ($s, $max) = @_;
367 $max //= 60; $s //= '';
368 return length($s) > $max ? '...' . substr($s, -($max - 3)) : $s;
369}
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.