Diff -- /var/www/vhosts/3dshawn.com/site1/servers.cgi
Diff
/var/www/vhosts/3dshawn.com/site1/servers.cgi
modified on local at 2026-07-11 23:15:13
Added
+357
lines
Removed
-13
lines
Context
12
unchanged
Blobs
from e9d45d948070
to 45efe1058831
to 45efe1058831
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | #!/usr/bin/perl |
| 2 | use strict; use warnings; use CGI (); | |
| 3 | use MODS::Config; use MODS::DBConnect; use MODS::Template; use MODS::PageWrapper; | |
| 4 | my $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"; | |
| 6 | my $dbh = $db->db_connect or die "DB connect failed\n"; | |
| 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 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | use CGI (); | |
| 21 | use JSON::PP (); | |
| 22 | use IPC::Open3; | |
| 23 | use Symbol 'gensym'; | |
| 24 | use Fcntl (); | |
| 25 | use POSIX (); | |
| 26 | use MODS::Config; | |
| 27 | use MODS::DBConnect; | |
| 28 | use MODS::Template; | |
| 29 | use MODS::PageWrapper; | |
| 30 | use MODS::Crypto; | |
| 31 | ||
| 32 | my $cgi = CGI->new; | |
| 33 | my $db = MODS::DBConnect->new; | |
| 34 | my $tpl = MODS::Template->new; | |
| 35 | my $method = $ENV{REQUEST_METHOD} || 'GET'; | |
| 36 | my $op = $cgi->param('op') || ''; | |
| 37 | ||
| 38 | $|=1; | |
| 39 | my $dbh = $db->db_connect or die "DriftSense: cannot connect to storage DB\n"; | |
| 40 | ||
| 41 | # ---- AJAX: test connection ------------------------------------------ | |
| 42 | if ($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 --------------------------------------------------- | |
| 64 | if ($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 ----------------------------------------------- | |
| 139 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; | |
| 140 | ||
| 141 | my $bulk_mode = $cgi->param('bulk') ? 1 : 0; | |
| 142 | my $edit_id = int($cgi->param('edit') || 0); | |
| 143 | my $imported = int($cgi->param('imported') || 0); | |
| 144 | ||
| 7 | 145 | my @rows = $db->db_readwrite_multiple($dbh, q~ |
| 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, | |
| 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, | |
| 10 | 154 | DATE_FORMAT(created_at, '%b %d, %Y') AS created_h |
| 11 | FROM SERVERS ORDER BY (status='active') DESC, server_id ASC | |
| 155 | FROM SERVERS | |
| 156 | WHERE status != 'disabled' | |
| 157 | ORDER BY kind ASC, server_id ASC | |
| 12 | 158 | ~, __FILE__, __LINE__); |
| 13 | 159 | foreach my $r (@rows) { |
| 14 | $r->{status_pill} = $r->{status} eq 'active' ? 'pill-ok' : | |
| 15 | $r->{status} eq 'offline' ? 'pill-bad' : 'pill-mute'; | |
| 16 | $r->{heartbeat_h} //= 'never'; | |
| 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 | |
| 177 | my $edit = {}; | |
| 178 | if ($edit_id) { | |
| 179 | ($edit) = grep { $_->{server_id} == $edit_id } @rows; | |
| 180 | $edit ||= {}; | |
| 17 | 181 | } |
| 182 | my $is_edit = $edit_id && $edit && $edit->{server_id} ? 1 : 0; | |
| 183 | ||
| 184 | my %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 | ||
| 18 | 199 | $db->db_disconnect($dbh); |
| 19 | 200 | |
| 20 | my @body = $tpl->template('servers.html', | |
| 21 | { rows => \@rows, has_rows => scalar(@rows) ? 1 : 0, total => scalar @rows }); | |
| 201 | my $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 | }; | |
| 212 | ||
| 213 | my @body = $tpl->template('servers.html', $tvars); | |
| 22 | 214 | MODS::PageWrapper->new->wrapper( |
| 23 | 215 | page_title => 'Servers & agents', page_key => 'servers', |
| 24 | 216 | body_html => join('', @body), userinfo => { display_name => 'Operator' }, |
| 25 | 217 | ); |
| 218 | ||
| 219 | #--------------------------------------------------------------------- | |
| 220 | sub _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 | ||
| 244 | sub _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 | ||
| 290 | sub _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 | ||
| 352 | sub _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 | } | |
| 360 | sub _one_of { | |
| 361 | my ($v, $default, @allowed) = @_; | |
| 362 | $v //= ''; | |
| 363 | return (grep { $_ eq $v } @allowed) ? $v : $default; | |
| 364 | } | |
| 365 | sub _shorten { | |
| 366 | my ($s, $max) = @_; | |
| 367 | $max //= 60; $s //= ''; | |
| 368 | return length($s) > $max ? '...' . substr($s, -($max - 3)) : $s; | |
| 369 | } |