Files
gniza4cp/whm/gniza-whm/schedules.cgi
shuki 749881dd5d Fix Run Now backup process getting killed on CGI exit
The forked child stayed in the CGI's process group and got killed
when Apache cleaned up the CGI process. Now uses double-fork with
POSIX::setsid() to fully daemonize the backup process.

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

612 lines
24 KiB
Perl

#!/usr/local/cpanel/3rdparty/bin/perl
# gniza WHM Plugin — Schedule CRUD
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 POSIX ();
use GnizaWHM::Config;
use GnizaWHM::Validator;
use GnizaWHM::Cron;
use GnizaWHM::UI;
my $form = Cpanel::Form::parseform();
my $method = $ENV{'REQUEST_METHOD'} // 'GET';
my $action = $form->{'action'} // 'list';
# Route to handler
if ($action eq 'add') { handle_add() }
elsif ($action eq 'edit') { handle_edit() }
elsif ($action eq 'delete') { handle_delete() }
elsif ($action eq 'toggle_cron') { handle_toggle_cron() }
elsif ($action eq 'run_now') { handle_run_now() }
else { handle_list() }
exit;
# ── List ─────────────────────────────────────────────────────
sub handle_list {
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Schedules', '', '/cgi/gniza-whm/schedules.cgi');
print GnizaWHM::UI::page_header('Schedule Management');
print GnizaWHM::UI::render_nav('schedules.cgi');
print GnizaWHM::UI::render_flash();
# Configured schedules
my @schedules = GnizaWHM::UI::list_schedules();
my $cron_schedules = GnizaWHM::Cron::get_current_schedules();
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">Configured Schedules</h2>\n};
if (@schedules) {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<thead><tr><th>Name</th><th>Type</th><th>Time</th><th>Day</th><th>Remotes</th><th>Active</th><th>Actions</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (@schedules) {
my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($name), 'schedule');
my $esc_name = GnizaWHM::UI::esc($name);
my $esc_sched = GnizaWHM::UI::esc($conf->{SCHEDULE} // '');
my $esc_time = GnizaWHM::UI::esc($conf->{SCHEDULE_TIME} // '02:00');
my $esc_day = GnizaWHM::UI::esc($conf->{SCHEDULE_DAY} // '-');
my $esc_remotes = GnizaWHM::UI::esc($conf->{REMOTES} // '(all)');
$esc_remotes = '(all)' if $esc_remotes eq '';
my $in_cron = exists $cron_schedules->{$name};
my $checked = $in_cron ? ' checked' : '';
print qq{<tr class="hover">};
print qq{<td><strong>$esc_name</strong></td>};
print qq{<td>$esc_sched</td><td>$esc_time</td><td>$esc_day</td><td>$esc_remotes</td>};
print qq{<td>};
print qq{<input type="checkbox" class="toggle toggle-sm toggle-success" data-schedule="$esc_name" onchange="gnizaToggleCron(this)"$checked>};
print qq{</td>};
print qq{<td>};
print qq{<div class="flex items-center gap-2">};
print qq{<form method="POST" action="schedules.cgi" style="display:inline">};
print qq{<input type="hidden" name="action" value="run_now">};
print qq{<input type="hidden" name="name" value="$esc_name">};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<button type="submit" class="btn btn-secondary btn-sm" onclick="return confirm('Run backup for schedule $esc_name now?')">Run Now</button>};
print qq{</form>};
print qq{<a href="schedules.cgi?action=edit&amp;name=$esc_name" class="btn btn-ghost btn-sm">Edit</a>};
print qq{<form method="POST" action="schedules.cgi" style="display:inline">};
print qq{<input type="hidden" name="action" value="delete">};
print qq{<input type="hidden" name="name" value="$esc_name">};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Delete schedule $esc_name?')">Delete</button>};
print qq{</form>};
print qq{</div>};
print qq{</td>};
print qq{</tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No schedules configured. Add a schedule to define when backups run.</p>\n};
}
print qq{</div>\n</div>\n};
# CSRF token + AJAX toggle script
my $csrf_token = GnizaWHM::UI::generate_csrf_token();
print qq{<script>
var gnizaCsrf = '} . GnizaWHM::UI::esc($csrf_token) . qq{';
function gnizaToggleCron(el) {
var name = el.getAttribute('data-schedule');
el.disabled = true;
var fd = new FormData();
fd.append('action', 'toggle_cron');
fd.append('name', name);
fd.append('gniza_csrf', gnizaCsrf);
fetch('schedules.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
gnizaCsrf = d.csrf;
el.checked = d.active;
el.disabled = false;
})
.catch(function() {
el.checked = !el.checked;
el.disabled = false;
});
}
</script>\n};
# Action buttons
print qq{<div class="flex gap-2 mb-6">\n};
print qq{ <a href="schedules.cgi?action=add" class="btn btn-primary btn-sm">Add Schedule</a>\n};
print qq{</div>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Add ──────────────────────────────────────────────────────
sub handle_add {
my @errors;
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->{'schedule_name'} // '';
my $name_err = GnizaWHM::Validator::validate_schedule_name($name);
push @errors, $name_err if $name_err;
if (!@errors && -f GnizaWHM::UI::schedule_conf_path($name)) {
push @errors, "A schedule named '$name' already exists.";
}
my %data;
for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) {
$data{$key} = $form->{$key} // '';
}
if (!@errors) {
my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data);
push @errors, @$validation_errors;
}
if (!@errors) {
my $dest = GnizaWHM::UI::schedule_conf_path($name);
my $example = GnizaWHM::UI::schedule_example_path();
if (-f $example) {
File::Copy::copy($example, $dest)
or do { push @errors, "Failed to create schedule file: $!"; goto RENDER_ADD; };
}
my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
my ($cron_ok, $cron_err) = GnizaWHM::Cron::install_schedule($name);
if ($form->{'wizard'}) {
GnizaWHM::UI::set_flash('success', 'Setup complete! Your remote and schedule are configured.');
print "Status: 302 Found\r\n";
print "Location: index.cgi\r\n\r\n";
exit;
}
if ($cron_ok) {
GnizaWHM::UI::set_flash('success', "Schedule '$name' created and activated.");
} else {
GnizaWHM::UI::set_flash('warning', "Schedule '$name' created but cron activation failed: $cron_err");
}
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
} else {
push @errors, "Failed to save schedule config: $err";
}
}
}
RENDER_ADD:
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Add Schedule', '', '/cgi/gniza-whm/schedules.cgi');
print GnizaWHM::UI::page_header('Add Schedule');
print GnizaWHM::UI::render_nav('schedules.cgi');
if (@errors) {
print GnizaWHM::UI::render_errors(\@errors);
}
my $conf = {};
if ($method eq 'POST') {
for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) {
$conf->{$key} = $form->{$key} // '';
}
} elsif ($form->{'remote_name'}) {
$conf->{REMOTES} = $form->{'remote_name'};
}
my $name_val = GnizaWHM::UI::esc($form->{'schedule_name'} // '');
render_schedule_form($conf, $name_val, 0, $form->{'wizard'} ? 1 : 0);
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Edit ─────────────────────────────────────────────────────
sub handle_edit {
my $name = $form->{'name'} // '';
my @errors;
my $name_err = GnizaWHM::Validator::validate_schedule_name($name);
if ($name_err) {
GnizaWHM::UI::set_flash('error', "Invalid schedule name.");
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
my $conf_path = GnizaWHM::UI::schedule_conf_path($name);
unless (-f $conf_path) {
GnizaWHM::UI::set_flash('error', "Schedule '$name' not found.");
print "Status: 302 Found\r\n";
print "Location: schedules.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 %data;
for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) {
$data{$key} = $form->{$key} // '';
}
if (!@errors) {
my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data);
push @errors, @$validation_errors;
}
if (!@errors) {
my ($ok, $err) = GnizaWHM::Config::write($conf_path, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
# Re-install cron if it was active, so timing/remote changes take effect
my $cron_schedules = GnizaWHM::Cron::get_current_schedules();
if (exists $cron_schedules->{$name}) {
GnizaWHM::Cron::install_schedule($name);
}
GnizaWHM::UI::set_flash('success', "Schedule '$name' updated successfully.");
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
} else {
push @errors, "Failed to save schedule config: $err";
}
}
}
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Edit Schedule', '', '/cgi/gniza-whm/schedules.cgi');
print GnizaWHM::UI::page_header("Edit Schedule: " . GnizaWHM::UI::esc($name));
print GnizaWHM::UI::render_nav('schedules.cgi');
if (@errors) {
print GnizaWHM::UI::render_errors(\@errors);
}
my $conf;
if (@errors && $method eq 'POST') {
$conf = {};
for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) {
$conf->{$key} = $form->{$key} // '';
}
} else {
$conf = GnizaWHM::Config::parse($conf_path, 'schedule');
}
render_schedule_form($conf, GnizaWHM::UI::esc($name), 1);
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Delete ───────────────────────────────────────────────────
sub handle_delete {
if ($method ne 'POST') {
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.');
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
my $name = $form->{'name'} // '';
my $name_err = GnizaWHM::Validator::validate_schedule_name($name);
if ($name_err) {
GnizaWHM::UI::set_flash('error', 'Invalid schedule name.');
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
my $conf_path = GnizaWHM::UI::schedule_conf_path($name);
if (-f $conf_path) {
GnizaWHM::Cron::remove_schedule($name);
unlink $conf_path;
GnizaWHM::UI::set_flash('success', "Schedule '$name' deleted.");
} else {
GnizaWHM::UI::set_flash('error', "Schedule '$name' not found.");
}
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
# ── Run Now ──────────────────────────────────────────────────
sub handle_run_now {
if ($method ne 'POST') {
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.');
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
my $name = $form->{'name'} // '';
my $name_err = GnizaWHM::Validator::validate_schedule_name($name);
if ($name_err) {
GnizaWHM::UI::set_flash('error', 'Invalid schedule name.');
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
my $conf_path = GnizaWHM::UI::schedule_conf_path($name);
unless (-f $conf_path) {
GnizaWHM::UI::set_flash('error', "Schedule '$name' not found.");
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
my $conf = GnizaWHM::Config::parse($conf_path, 'schedule');
my $remotes = $conf->{REMOTES} // '';
$remotes =~ s/^\s+|\s+$//g;
# Build command
my @cmd = ('/usr/local/bin/gniza', 'backup');
if ($remotes ne '') {
push @cmd, "--remote=$remotes";
}
my $log_file = "/var/log/gniza/cron-${name}.log";
# Double-fork to fully detach from CGI process group
my $pid = fork();
if (!defined $pid) {
GnizaWHM::UI::set_flash('error', "Failed to fork backup process: $!");
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
if ($pid == 0) {
# First child: new session, then fork again
POSIX::setsid();
my $pid2 = fork();
exit 0 if $pid2; # first child exits immediately
# Grandchild: fully detached daemon
close STDIN;
open STDIN, '<', '/dev/null';
close STDOUT;
open STDOUT, '>>', $log_file;
close STDERR;
open STDERR, '>&', \*STDOUT;
exec @cmd;
exit 1;
}
waitpid($pid, 0); # Reap first child (exits immediately)
# Parent: redirect
GnizaWHM::UI::set_flash('success', "Backup started for schedule '$name'. Check Logs for progress.");
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
# ── Toggle Cron ──────────────────────────────────────────────
sub handle_toggle_cron {
if ($method ne 'POST') {
print "Status: 302 Found\r\n";
print "Location: schedules.cgi\r\n\r\n";
exit;
}
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
my $new_csrf = GnizaWHM::UI::generate_csrf_token();
_json_response(0, 0, 'Invalid or expired form token.', $new_csrf);
}
my $new_csrf = GnizaWHM::UI::generate_csrf_token();
my $name = $form->{'name'} // '';
my $name_err = GnizaWHM::Validator::validate_schedule_name($name);
if ($name_err) {
_json_response(0, 0, 'Invalid schedule name.', $new_csrf);
}
my $cron_schedules = GnizaWHM::Cron::get_current_schedules();
my $is_active = exists $cron_schedules->{$name};
if ($is_active) {
my ($ok, $err) = GnizaWHM::Cron::remove_schedule($name);
if ($ok) {
_json_response(1, 0, "Cron disabled for '$name'.", $new_csrf);
} else {
_json_response(0, 1, "Failed to remove cron: $err", $new_csrf);
}
} else {
my ($ok, $err) = GnizaWHM::Cron::install_schedule($name);
if ($ok) {
_json_response(1, 1, "Cron enabled for '$name'.", $new_csrf);
} else {
_json_response(0, 0, "Failed to install cron: $err", $new_csrf);
}
}
}
sub _json_response {
my ($ok, $active, $message, $csrf) = @_;
# Escape for JSON
$message =~ s/\\/\\\\/g;
$message =~ s/"/\\"/g;
my $active_str = $active ? 'true' : 'false';
my $ok_str = $ok ? 'true' : 'false';
print "Content-Type: application/json\r\n\r\n";
print qq({"ok":$ok_str,"active":$active_str,"message":"$message","csrf":"$csrf"});
exit;
}
# ── Shared Form Renderer ────────────────────────────────────
sub render_schedule_form {
my ($conf, $name_val, $is_edit, $wizard) = @_;
my $action_val = $is_edit ? 'edit' : 'add';
print qq{<form method="POST" action="schedules.cgi">\n};
print qq{<input type="hidden" name="action" value="$action_val">\n};
if ($wizard) {
print qq{<input type="hidden" name="wizard" value="1">\n};
}
print GnizaWHM::UI::csrf_hidden_field();
if ($is_edit) {
print qq{<input type="hidden" name="name" value="$name_val">\n};
}
# Schedule name
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">Schedule Identity</h2>\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="schedule_name">Schedule Name</label>\n};
if ($is_edit) {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" value="$name_val" disabled>\n};
} else {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="schedule_name" name="schedule_name" value="$name_val" required>\n};
print qq{ <span class="text-xs text-base-content/60 ml-2">Letters, digits, hyphens, underscores</span>\n};
}
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Schedule settings
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">Schedule Settings</h2>\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' : '';
print qq{ <option value="} . GnizaWHM::UI::esc($opt) . qq{"$sel>$opt</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
_sched_field($conf, 'SCHEDULE_TIME', 'Time (HH:MM)', 'Default: 02:00');
print qq{<div id="gniza-schedule-day">\n};
_sched_field($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};
_sched_field($conf, 'SCHEDULE_CRON', 'Cron Expression', '5-field cron (for custom only)');
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Target remotes
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">Target Remotes</h2>\n};
print qq{<p class="text-xs text-base-content/60 mb-3">Select which remotes this schedule targets. Leave all unchecked to target all remotes.</p>\n};
my @remotes = GnizaWHM::UI::list_remotes();
my %selected_remotes;
my $remotes_str = $conf->{REMOTES} // '';
for my $r (split /,/, $remotes_str) {
$r =~ s/^\s+|\s+$//g;
$selected_remotes{$r} = 1 if $r ne '';
}
if (@remotes) {
for my $rname (@remotes) {
my $esc_name = GnizaWHM::UI::esc($rname);
my $checked = $selected_remotes{$rname} ? ' checked' : '';
print qq{<label class="flex items-center gap-2 mb-1 cursor-pointer">\n};
print qq{ <input type="checkbox" class="checkbox checkbox-sm" name="remote_$esc_name" value="1"$checked>\n};
print qq{ <span class="text-sm">$esc_name</span>\n};
print qq{</label>\n};
}
# Hidden field to collect selected remotes via JS
print qq{<input type="hidden" name="REMOTES" id="remotes_hidden" value="} . GnizaWHM::UI::esc($remotes_str) . qq{">\n};
} else {
print qq{<p class="text-sm">No remotes configured. <a href="remotes.cgi?action=add" class="link">Add a remote</a> first.</p>\n};
}
print qq{</div>\n</div>\n};
# Submit
print qq{<div class="flex gap-2 mt-4">\n};
my $btn_label = $is_edit ? 'Save Changes' : 'Create Schedule';
print qq{ <button type="submit" class="btn btn-primary btn-sm" onclick="return gnizaCollectRemotes()">$btn_label</button>\n};
print qq{ <a href="schedules.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
# JS for schedule field visibility and remote collection
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 dayInput = dayDiv.querySelector('input');
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)';
}
}
function gnizaCollectRemotes() {
var checks = document.querySelectorAll('input[type="checkbox"][name^="remote_"]');
var selected = [];
for (var i = 0; i < checks.length; i++) {
if (checks[i].checked) {
selected.push(checks[i].name.replace('remote_', ''));
}
}
var hidden = document.getElementById('remotes_hidden');
if (hidden) { hidden.value = selected.join(','); }
return true;
}
gnizaScheduleChange();
</script>
JS
}
sub _sched_field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
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="$key">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}