Files
gniza4cp/whm/gniza-whm/setup.cgi
shuki bde1fe0822 Fix JSON control character escaping in connection test responses
SSH stderr can contain \r and other control characters that break
JSON parsing. Strip all control chars after escaping known ones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 03:03:55 +02:00

534 lines
21 KiB
Perl

#!/usr/local/cpanel/3rdparty/bin/perl
# gniza WHM Plugin — Setup Wizard
# 3-step wizard: SSH Key → Remote Destination → Schedule
use strict;
use warnings;
use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib';
use Whostmgr::HTMLInterface ();
use Cpanel::Form ();
use File::Copy ();
use GnizaWHM::Config;
use GnizaWHM::Validator;
use GnizaWHM::Cron;
use GnizaWHM::UI;
my $form = Cpanel::Form::parseform();
my $method = $ENV{'REQUEST_METHOD'} // 'GET';
my $step = $form->{'step'} // '1';
if ($step eq 'test') { handle_test_connection() }
elsif ($step eq '2') { handle_step2() }
elsif ($step eq '3') { handle_step3() }
else { handle_step1() }
exit;
# ── Test Connection (JSON) ────────────────────────────────────
sub handle_test_connection {
print "Content-Type: application/json\r\n\r\n";
my $host = $form->{'host'} // '';
my $port = $form->{'port'} || '22';
my $user = $form->{'user'} || 'root';
my $key = $form->{'key'} // '';
if ($host eq '' || $key eq '') {
print qq({"success":false,"message":"Host and SSH key path are required."});
exit;
}
my ($ok, $err) = GnizaWHM::UI::test_ssh_connection($host, $port, $user, $key);
if ($ok) {
print qq({"success":true,"message":"SSH connection successful."});
} else {
$err //= 'Unknown error';
$err =~ s/\\/\\\\/g;
$err =~ s/"/\\"/g;
$err =~ s/\n/\\n/g;
$err =~ s/\r/\\r/g;
$err =~ s/\t/\\t/g;
$err =~ s/[\x00-\x1f]//g;
print qq({"success":false,"message":"SSH connection failed: $err"});
}
exit;
}
# ── Step 1: SSH Key ──────────────────────────────────────────
sub handle_step1 {
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Setup Wizard', '', '/cgi/gniza-whm/setup.cgi');
print GnizaWHM::UI::page_header('gniza Setup Wizard');
render_steps_indicator(1);
my $keys = GnizaWHM::UI::detect_ssh_keys();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 1: SSH Key</h2>\n};
print qq{<p>gniza uses SSH keys to connect to remote backup destinations. An SSH key must be set up before adding a remote.</p>\n};
if (@$keys) {
print qq{<div class="my-4">\n};
print qq{<p><strong>Existing keys found:</strong></p>\n};
print qq{<table class="table table-zebra w-full">\n};
print qq{<thead><tr><th></th><th>Type</th><th>Path</th><th>Public Key</th></tr></thead>\n};
print qq{<tbody>\n};
my $first = 1;
for my $k (@$keys) {
my $checked = $first ? ' checked' : '';
my $pub = $k->{has_pub} ? 'Available' : 'Missing';
my $esc_path = GnizaWHM::UI::esc($k->{path});
my $esc_type = GnizaWHM::UI::esc($k->{type});
print qq{<tr>};
print qq{<td><input type="radio" class="radio radio-sm" name="selected_key" value="$esc_path" form="step1form"$checked></td>};
print qq{<td>$esc_type</td>};
print qq{<td><code>$esc_path</code></td>};
print qq{<td>$pub</td>};
print qq{</tr>\n};
$first = 0;
}
print qq{</tbody>\n</table>\n};
print qq{<div class="flex items-center gap-3 mt-3">\n};
print qq{ <input type="radio" class="radio radio-sm" name="selected_key" value="_custom" form="step1form" id="key_custom_radio">\n};
print qq{ <label for="key_custom_path">Custom path:</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="key_custom_path" name="custom_key_path" form="step1form" placeholder="/root/.ssh/id_ed25519" onfocus="document.getElementById('key_custom_radio').checked=true">\n};
print qq{</div>\n};
print qq{</div>\n};
print qq{<form id="step1form" method="GET" action="setup.cgi">\n};
print qq{<input type="hidden" name="step" value="2">\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm" onclick="return gnizaPrepStep2()">Next: Configure Remote</button>\n};
print qq{ <a href="index.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
} else {
print qq{<div class="alert alert-info mb-4">No SSH keys found in <code>/root/.ssh/</code>. You need to create one first.</div>\n};
}
# Always show key generation instructions
print qq{<div class="mt-5">\n};
print qq{<p><strong>Generate a new SSH key</strong> (if needed):</p>\n};
print qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""</pre>\n};
print qq{<p><strong>Copy the public key</strong> to the remote server:</p>\n};
print qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host</pre>\n};
print qq{<p class="text-xs text-base-content/60 mt-2">Run these commands in WHM &rarr; Server Configuration &rarr; Terminal, or via SSH.</p>\n};
print qq{</div>\n};
unless (@$keys) {
print qq{<form method="GET" action="setup.cgi" class="mt-4">\n};
print qq{<input type="hidden" name="step" value="2">\n};
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="key_path_manual">Key path:</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="key_path_manual" name="key_path" value="/root/.ssh/id_ed25519">\n};
print qq{</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Next: Configure Remote</button>\n};
print qq{ <a href="index.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
}
print qq{</div>\n</div>\n};
# JS to resolve selected key into key_path param
print <<'JS';
<script>
function gnizaPrepStep2() {
var form = document.getElementById('step1form');
var radios = document.querySelectorAll('input[name="selected_key"]');
var selected = '';
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) { selected = radios[i].value; break; }
}
if (selected === '_custom') {
selected = document.querySelector('input[name="custom_key_path"]').value;
}
if (!selected) { alert('Please select an SSH key.'); return false; }
var hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = 'key_path'; hidden.value = selected;
form.appendChild(hidden);
return true;
}
</script>
JS
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Step 2: Remote Destination ───────────────────────────────
sub handle_step2 {
my @errors;
my $key_path = $form->{'key_path'} // '/root/.ssh/id_ed25519';
if ($method eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
push @errors, 'Invalid or expired form token. Please try again.';
}
my $name = $form->{'remote_name'} // '';
my $name_err = GnizaWHM::Validator::validate_remote_name($name);
push @errors, $name_err if $name_err;
if (!@errors && -f GnizaWHM::UI::remote_conf_path($name)) {
push @errors, "A remote named '$name' already exists.";
}
my %data;
for my $key (@GnizaWHM::Config::REMOTE_KEYS) {
$data{$key} = $form->{$key} // '';
}
if (!@errors) {
my $validation_errors = GnizaWHM::Validator::validate_remote_config(\%data);
push @errors, @$validation_errors;
}
if (!@errors) {
my ($ssh_ok, $ssh_err) = GnizaWHM::UI::test_ssh_connection(
$data{REMOTE_HOST},
$data{REMOTE_PORT} || '22',
$data{REMOTE_USER} || 'root',
$data{REMOTE_KEY},
);
push @errors, "SSH connection test failed: $ssh_err" unless $ssh_ok;
}
if (!@errors) {
my $dest = GnizaWHM::UI::remote_conf_path($name);
my $example = GnizaWHM::UI::remote_example_path();
if (-f $example) {
File::Copy::copy($example, $dest)
or do { push @errors, "Failed to create remote file: $!"; goto RENDER_STEP2; };
}
my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::REMOTE_KEYS);
if ($ok) {
print "Status: 302 Found\r\n";
print "Location: setup.cgi?step=3&remote_name=" . _uri_escape($name) . "\r\n\r\n";
exit;
} else {
push @errors, "Failed to save remote config: $err";
}
}
}
RENDER_STEP2:
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Setup Wizard', '', '/cgi/gniza-whm/setup.cgi');
print GnizaWHM::UI::page_header('gniza Setup Wizard');
render_steps_indicator(2);
if (@errors) {
print GnizaWHM::UI::render_errors(\@errors);
}
my $conf = {};
my $name_val = '';
if ($method eq 'POST') {
for my $key (@GnizaWHM::Config::REMOTE_KEYS) {
$conf->{$key} = $form->{$key} // '';
}
$name_val = GnizaWHM::UI::esc($form->{'remote_name'} // '');
} else {
$conf->{REMOTE_KEY} = $key_path;
$conf->{REMOTE_PORT} = '22';
$conf->{REMOTE_USER} = 'root';
$conf->{REMOTE_BASE} = '/backups';
$conf->{RETENTION_COUNT} = '30';
$conf->{BWLIMIT} = '0';
}
print qq{<form method="POST" action="setup.cgi">\n};
print qq{<input type="hidden" name="step" value="2">\n};
print qq{<input type="hidden" name="key_path" value="} . GnizaWHM::UI::esc($key_path) . qq{">\n};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 2: Remote Destination</h2>\n};
print qq{<p>Configure the remote server where backups will be stored.</p>\n};
_wiz_field('remote_name', $name_val, 'Remote Name', 'Letters, digits, hyphens, underscores');
_wiz_field_conf($conf, 'REMOTE_HOST', 'Hostname / IP', 'Required');
_wiz_field_conf($conf, 'REMOTE_PORT', 'SSH Port', 'Default: 22');
_wiz_field_conf($conf, 'REMOTE_USER', 'SSH User', 'Default: root');
_wiz_field_conf($conf, 'REMOTE_KEY', 'SSH Private Key', 'Absolute path, required');
_wiz_field_conf($conf, 'REMOTE_BASE', 'Remote Base Dir', 'Default: /backups');
_wiz_field_conf($conf, 'BWLIMIT', 'Bandwidth Limit', 'KB/s, 0 = unlimited');
_wiz_field_conf($conf, 'RETENTION_COUNT', 'Snapshots to Keep', 'Default: 30');
_wiz_field_conf($conf, 'RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'Optional');
print qq{</div>\n</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Next: Set Schedule</button>\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" id="test-conn-btn" onclick="gnizaTestConnection()">Test Connection</button>\n};
print qq{ <a href="setup.cgi" class="btn btn-ghost btn-sm">Back</a>\n};
print qq{</div>\n};
print qq{</form>\n};
print <<'JS';
<script>
function gnizaTestConnection() {
var host = document.getElementById('REMOTE_HOST').value;
var port = document.getElementById('REMOTE_PORT').value;
var user = document.getElementById('REMOTE_USER').value;
var key = document.getElementById('REMOTE_KEY').value;
var btn = document.getElementById('test-conn-btn');
if (!host || !key) {
gnizaToast('error', 'Host and SSH key path are required.');
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Testing\u2026';
var fd = new FormData();
fd.append('step', 'test');
fd.append('host', host);
fd.append('port', port);
fd.append('user', user);
fd.append('key', key);
fetch('setup.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
gnizaToast(data.success ? 'success' : 'error', data.message);
})
.catch(function(err) {
gnizaToast('error', 'Request failed: ' + err.toString());
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = 'Test Connection';
});
}
function gnizaToast(type, msg) {
var toast = document.getElementById('gniza-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'gniza-toast';
toast.style.cssText = 'position:fixed;top:12px;right:12px;z-index:9999;display:flex;flex-direction:column;gap:8px;max-width:400px';
document.body.appendChild(toast);
}
var el = document.createElement('div');
el.className = 'alert alert-' + type;
el.style.cssText = 'transition:opacity .3s;box-shadow:0 4px 12px rgba(0,0,0,.15)';
el.textContent = msg;
toast.appendChild(el);
setTimeout(function() { el.style.opacity = '0'; }, type === 'error' ? 6000 : 3000);
setTimeout(function() { el.remove(); }, type === 'error' ? 6500 : 3500);
}
</script>
JS
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Step 3: Schedule (writes to schedules.d/) ────────────────
sub handle_step3 {
my $remote_name = $form->{'remote_name'} // '';
my @errors;
my $name_err = GnizaWHM::Validator::validate_remote_name($remote_name);
if ($name_err) {
GnizaWHM::UI::set_flash('error', 'Invalid remote name. Please start the wizard again.');
print "Status: 302 Found\r\n";
print "Location: setup.cgi\r\n\r\n";
exit;
}
my $remote_conf_path = GnizaWHM::UI::remote_conf_path($remote_name);
unless (-f $remote_conf_path) {
GnizaWHM::UI::set_flash('error', "Remote '$remote_name' not found. Please start the wizard again.");
print "Status: 302 Found\r\n";
print "Location: setup.cgi\r\n\r\n";
exit;
}
if ($method eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
push @errors, 'Invalid or expired form token. Please try again.';
}
my $schedule = $form->{SCHEDULE} // '';
if ($schedule ne '' && !@errors) {
# Create a schedule config in schedules.d/ targeting this remote
my $sched_name = $remote_name;
my %data = (
SCHEDULE => $schedule,
SCHEDULE_TIME => $form->{SCHEDULE_TIME} // '02:00',
SCHEDULE_DAY => $form->{SCHEDULE_DAY} // '',
SCHEDULE_CRON => $form->{SCHEDULE_CRON} // '',
REMOTES => $remote_name,
);
my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data);
push @errors, @$validation_errors;
if (!@errors) {
my $sched_path = GnizaWHM::UI::schedule_conf_path($sched_name);
my $example = GnizaWHM::UI::schedule_example_path();
if (-f $example) {
File::Copy::copy($example, $sched_path)
or do { push @errors, "Failed to create schedule file: $!"; };
}
if (!@errors) {
my ($ok, $err) = GnizaWHM::Config::write($sched_path, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
push @errors, "Failed to save schedule: $err" unless $ok;
}
}
if (!@errors) {
my ($ok, $stdout, $stderr) = GnizaWHM::Cron::install_schedules();
if (!$ok) {
push @errors, "Schedule saved but cron install failed: $stderr";
}
}
}
if (!@errors) {
GnizaWHM::UI::set_flash('success', 'Setup complete! Your first remote destination is configured.');
print "Status: 302 Found\r\n";
print "Location: index.cgi\r\n\r\n";
exit;
}
}
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Setup Wizard', '', '/cgi/gniza-whm/setup.cgi');
print GnizaWHM::UI::page_header('gniza Setup Wizard');
render_steps_indicator(3);
if (@errors) {
print GnizaWHM::UI::render_errors(\@errors);
}
my $esc_name = GnizaWHM::UI::esc($remote_name);
# Load existing schedule data from schedules.d/ if it exists, else from POST
my $conf = {};
if (@errors && $method eq 'POST') {
for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) {
$conf->{$key} = $form->{$key} // '';
}
} elsif (-f GnizaWHM::UI::schedule_conf_path($remote_name)) {
$conf = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($remote_name), 'schedule');
}
print qq{<form method="POST" action="setup.cgi">\n};
print qq{<input type="hidden" name="step" value="3">\n};
print qq{<input type="hidden" name="remote_name" value="$esc_name">\n};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 3: Backup Schedule</h2>\n};
print qq{<p>Set up an automatic backup schedule for remote <strong>$esc_name</strong>. You can skip this and configure it later.</p>\n};
my $sched = $conf->{SCHEDULE} // '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="SCHEDULE">Schedule Type</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="SCHEDULE" name="SCHEDULE" onchange="gnizaScheduleChange()">\n};
for my $opt ('', 'hourly', 'daily', 'weekly', 'monthly', 'custom') {
my $sel = ($sched eq $opt) ? ' selected' : '';
my $display = $opt eq '' ? '(none)' : $opt;
print qq{ <option value="} . GnizaWHM::UI::esc($opt) . qq{"$sel>$display</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
_wiz_field_conf($conf, 'SCHEDULE_TIME', 'Time (HH:MM)', 'Default: 02:00');
print qq{<div id="gniza-schedule-day">\n};
_wiz_field_conf($conf, 'SCHEDULE_DAY', 'Day', 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)');
print qq{</div>\n};
print qq{<div id="gniza-schedule-cron">\n};
_wiz_field_conf($conf, 'SCHEDULE_CRON', 'Cron Expression', '5-field cron (for custom only)');
print qq{</div>\n};
print qq{</div>\n</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Finish Setup</button>\n};
print qq{ <a href="index.cgi" class="btn btn-ghost btn-sm">Skip</a>\n};
print qq{</div>\n};
print qq{</form>\n};
print <<'JS';
<script>
function gnizaScheduleChange() {
var sel = document.getElementById('SCHEDULE').value;
var dayDiv = document.getElementById('gniza-schedule-day');
var cronDiv = document.getElementById('gniza-schedule-cron');
dayDiv.style.display = (sel === 'hourly' || sel === 'weekly' || sel === 'monthly') ? '' : 'none';
cronDiv.style.display = (sel === 'custom') ? '' : 'none';
var dayLabel = dayDiv.querySelector('label');
var dayHint = dayDiv.querySelector('.text-xs');
if (sel === 'hourly') {
if (dayLabel) dayLabel.textContent = 'Every N hours';
if (dayHint) dayHint.textContent = '1-23 (default: 1 = every hour)';
} else {
if (dayLabel) dayLabel.textContent = 'Day';
if (dayHint) dayHint.textContent = 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)';
}
}
gnizaScheduleChange();
</script>
JS
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Shared Helpers ───────────────────────────────────────────
sub render_steps_indicator {
my ($current) = @_;
my @labels = ('SSH Key', 'Remote', 'Schedule');
print qq{<ul class="steps mb-6 w-full">\n};
for my $i (1..3) {
my $class = 'step';
$class .= ' step-primary' if $i <= $current;
print qq{ <li class="$class">$labels[$i-1]</li>\n};
}
print qq{</ul>\n};
}
sub _wiz_field {
my ($name, $val, $label, $hint) = @_;
$val = GnizaWHM::UI::esc($val // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$name">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$name" name="$name" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}
sub _wiz_field_conf {
my ($conf, $key, $label, $hint) = @_;
_wiz_field($key, $conf->{$key}, $label, $hint);
}
sub _uri_escape {
my ($str) = @_;
$str =~ s/([^A-Za-z0-9._~-])/sprintf("%%%02X", ord($1))/ge;
return $str;
}