From 0eb480489ed93ed3408ef159e44c8f259d19e9db Mon Sep 17 00:00:00 2001 From: shuki Date: Wed, 4 Mar 2026 19:10:18 +0200 Subject: [PATCH] Add per-schedule toggle to skip suspended cPanel accounts Adds SKIP_SUSPENDED config key and --skip-suspended CLI flag that excludes suspended accounts (detected via /var/cpanel/suspended/) from backups. Follows the same pattern as the existing SYSBACKUP toggle across all layers: config, schedule loader, cron builder, CLI flag parsing, and WHM UI (table toggle, AJAX handler, form card). Co-Authored-By: Claude Opus 4.6 --- bin/gniza | 29 ++++++--- etc/schedule.conf.example | 3 + lib/accounts.sh | 20 +++++- lib/schedule.sh | 5 ++ whm/gniza-whm/lib/GnizaWHM/Config.pm | 2 +- whm/gniza-whm/lib/GnizaWHM/Cron.pm | 3 + whm/gniza-whm/schedules.cgi | 92 +++++++++++++++++++++++++++- 7 files changed, 142 insertions(+), 12 deletions(-) diff --git a/bin/gniza b/bin/gniza index d49c693..3994867 100755 --- a/bin/gniza +++ b/bin/gniza @@ -105,6 +105,9 @@ cmd_backup() { local run_sysbackup=false has_flag sysbackup "$@" && run_sysbackup=true + local skip_suspended=false + has_flag skip-suspended "$@" && skip_suspended=true + local single_account="" single_account=$(get_opt account "$@" 2>/dev/null) || true @@ -142,7 +145,7 @@ cmd_backup() { account_exists "$single_account" || die "Account does not exist: $single_account" accounts="$single_account" else - accounts=$(get_backup_accounts) + accounts=$(get_backup_accounts "$skip_suspended") fi if [[ -z "$accounts" ]]; then @@ -290,7 +293,7 @@ cmd_restore() { account) local name="${1:-}" shift 2>/dev/null || true - [[ -z "$name" ]] && die "Usage: gniza restore account [--remote=NAME] [--timestamp=TS] [--force]" + [[ -z "$name" ]] && die "Usage: gniza restore account [--remote=NAME] [--timestamp=TS] [--strategy=merge|terminate] [--force]" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" load_config "$config_file" @@ -301,11 +304,18 @@ cmd_restore() { _restore_load_remote "$remote_flag" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" - local force=false - has_flag force "$@" && force=true + local strategy; strategy=$(get_opt strategy "$@" 2>/dev/null) || strategy="" + local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude="" + # Backward compat: --force maps to strategy=merge + if [[ -z "$strategy" ]] && has_flag force "$@"; then + strategy="merge" + fi + if [[ -n "$strategy" ]] && [[ "$strategy" != "merge" ]] && [[ "$strategy" != "terminate" ]]; then + die "Invalid strategy: $strategy (must be 'merge' or 'terminate')" + fi _test_connection - restore_full_account "$name" "$timestamp" "$force" + restore_full_account "$name" "$timestamp" "$strategy" "$exclude" ;; files) local name="${1:-}" @@ -322,9 +332,10 @@ cmd_restore() { local subpath; subpath=$(get_opt path "$@" 2>/dev/null) || subpath="" local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp="" + local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude="" _test_connection - restore_files "$name" "$subpath" "$timestamp" + restore_files "$name" "$subpath" "$timestamp" "$exclude" ;; database) local name="${1:-}" @@ -1099,10 +1110,14 @@ _schedule_run() { if [[ "${SCHEDULE_SYSBACKUP:-}" == "yes" ]]; then args+=(--sysbackup) fi + if [[ "${SCHEDULE_SKIP_SUSPENDED:-}" == "yes" ]]; then + args+=(--skip-suspended) + fi echo "Running schedule '$name'..." echo " Remotes: ${SCHEDULE_REMOTES:-(all)}" echo " Sysbackup: ${SCHEDULE_SYSBACKUP:-no}" + echo " Skip suspended: ${SCHEDULE_SKIP_SUSPENDED:-no}" echo "" # Exec replaces this process with the backup command @@ -1509,7 +1524,7 @@ ${C_BOLD}Usage:${C_RESET} gniza [options] ${C_BOLD}Commands:${C_RESET} - backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] [--sysbackup] + backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] [--sysbackup] [--skip-suspended] restore account [--remote=NAME] [--timestamp=TS] [--force] restore files [--remote=NAME] [--path=subpath] [--timestamp=TS] restore database [] [--remote=NAME] [--timestamp=TS] diff --git a/etc/schedule.conf.example b/etc/schedule.conf.example index 37219f0..44078cc 100644 --- a/etc/schedule.conf.example +++ b/etc/schedule.conf.example @@ -19,3 +19,6 @@ REMOTES="" # Comma-separated remote names (e.g. "nas,offs # ── System Backup ───────────────────────────────────────────── SYSBACKUP="" # "yes" to run system backup after account backups # Backs up WHM/cPanel config, packages, cron jobs + +# ── Suspended Accounts ─────────────────────────────────────── +SKIP_SUSPENDED="" # "yes" to skip cPanel suspended accounts diff --git a/lib/accounts.sh b/lib/accounts.sh index 8070757..80b0ef6 100644 --- a/lib/accounts.sh +++ b/lib/accounts.sh @@ -42,9 +42,27 @@ filter_accounts() { printf '%s\n' "${filtered[@]}" } +is_suspended() { + local user="$1" + [[ -e "/var/cpanel/suspended/$user" ]] +} + get_backup_accounts() { + local skip_suspended="${1:-false}" local all; all=$(get_all_accounts) - filter_accounts "$all" + local filtered; filtered=$(filter_accounts "$all") + if [[ "$skip_suspended" == "true" ]]; then + while IFS= read -r acc; do + [[ -z "$acc" ]] && continue + if is_suspended "$acc"; then + log_info "Skipping suspended account: $acc" + continue + fi + echo "$acc" + done <<< "$filtered" + else + echo "$filtered" + fi } get_account_homedir() { diff --git a/lib/schedule.sh b/lib/schedule.sh index 0433207..4339771 100644 --- a/lib/schedule.sh +++ b/lib/schedule.sh @@ -54,6 +54,7 @@ load_schedule() { SCHEDULE_CRON="" SCHEDULE_REMOTES="" SCHEDULE_SYSBACKUP="" + SCHEDULE_SKIP_SUSPENDED="" # shellcheck disable=SC1090 source "$conf" || { @@ -64,6 +65,7 @@ load_schedule() { # Map REMOTES to SCHEDULE_REMOTES to avoid conflicts SCHEDULE_REMOTES="${REMOTES:-}" SCHEDULE_SYSBACKUP="${SYSBACKUP:-}" + SCHEDULE_SKIP_SUSPENDED="${SKIP_SUSPENDED:-}" log_debug "Loaded schedule '$name': ${SCHEDULE} at ${SCHEDULE_TIME:-02:00}, remotes=${SCHEDULE_REMOTES:-all}" } @@ -143,6 +145,9 @@ build_cron_line() { if [[ "${SCHEDULE_SYSBACKUP:-}" == "yes" ]]; then extra_flags+=" --sysbackup" fi + if [[ "${SCHEDULE_SKIP_SUSPENDED:-}" == "yes" ]]; then + extra_flags+=" --skip-suspended" + fi echo "$cron_expr /usr/local/bin/gniza backup${extra_flags} >/dev/null 2>&1" } diff --git a/whm/gniza-whm/lib/GnizaWHM/Config.pm b/whm/gniza-whm/lib/GnizaWHM/Config.pm index a623fb6..d5b56b2 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Config.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Config.pm @@ -21,7 +21,7 @@ our @REMOTE_KEYS = qw( ); our @SCHEDULE_KEYS = qw( - SCHEDULE SCHEDULE_TIME SCHEDULE_DAY SCHEDULE_CRON REMOTES SYSBACKUP + SCHEDULE SCHEDULE_TIME SCHEDULE_DAY SCHEDULE_CRON REMOTES SYSBACKUP SKIP_SUSPENDED ); my %MAIN_KEY_SET = map { $_ => 1 } @MAIN_KEYS; diff --git a/whm/gniza-whm/lib/GnizaWHM/Cron.pm b/whm/gniza-whm/lib/GnizaWHM/Cron.pm index f9885f9..83d721a 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Cron.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Cron.pm @@ -87,6 +87,9 @@ sub install_schedule { if (($conf->{SYSBACKUP} // '') eq 'yes') { $extra_flags .= " --sysbackup"; } + if (($conf->{SKIP_SUSPENDED} // '') eq 'yes') { + $extra_flags .= " --skip-suspended"; + } my $cmd_line = "$cron_expr $GNIZA_BIN backup${extra_flags} >/dev/null 2>&1"; # Read current crontab, strip existing entry for this schedule, append new diff --git a/whm/gniza-whm/schedules.cgi b/whm/gniza-whm/schedules.cgi index f3d3b58..f196e6c 100644 --- a/whm/gniza-whm/schedules.cgi +++ b/whm/gniza-whm/schedules.cgi @@ -23,8 +23,9 @@ 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 'toggle_sysbackup') { handle_toggle_sysbackup() } -elsif ($action eq 'run_now') { handle_run_now() } +elsif ($action eq 'toggle_sysbackup') { handle_toggle_sysbackup() } +elsif ($action eq 'toggle_skip_suspended') { handle_toggle_skip_suspended() } +elsif ($action eq 'run_now') { handle_run_now() } else { handle_list() } exit; @@ -48,7 +49,7 @@ sub handle_list { if (@schedules) { print qq{
\n}; - print qq{\n}; + print qq{\n}; print qq{\n}; for my $name (@schedules) { my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($name), 'schedule'); @@ -65,6 +66,9 @@ sub handle_list { my $sysbackup_on = (($conf->{SYSBACKUP} // '') eq 'yes'); my $sysbackup_checked = $sysbackup_on ? ' checked' : ''; + my $skip_suspended_on = (($conf->{SKIP_SUSPENDED} // '') eq 'yes'); + my $skip_suspended_checked = $skip_suspended_on ? ' checked' : ''; + print qq{}; print qq{}; print qq{}; @@ -72,6 +76,9 @@ sub handle_list { print qq{}; print qq{}; print qq{}; + print qq{}; print qq{
NameTypeTimeDayRemotesSys BackupActiveActions
NameTypeTimeDayRemotesSys BackupSkip SuspendedActiveActions
$esc_name$esc_sched$esc_time$esc_day$esc_remotes}; + print qq{}; + print qq{}; print qq{}; print qq{}; @@ -141,6 +148,25 @@ function gnizaToggleSysbackup(el) { el.disabled = false; }); } +function gnizaToggleSkipSuspended(el) { + var name = el.getAttribute('data-schedule'); + el.disabled = true; + var fd = new FormData(); + fd.append('action', 'toggle_skip_suspended'); + 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; + }); +} \n}; # Action buttons @@ -400,6 +426,9 @@ sub handle_run_now { if (($conf->{SYSBACKUP} // '') eq 'yes') { push @cmd, '--sysbackup'; } + if (($conf->{SKIP_SUSPENDED} // '') eq 'yes') { + push @cmd, '--skip-suspended'; + } my $log_file = "/var/log/gniza/cron-${name}.log"; @@ -524,6 +553,51 @@ sub handle_toggle_sysbackup { } } +# ── Toggle Skip Suspended ────────────────────────────────────── + +sub handle_toggle_skip_suspended { + 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 $conf_path = GnizaWHM::UI::schedule_conf_path($name); + unless (-f $conf_path) { + _json_response(0, 0, "Schedule '$name' not found.", $new_csrf); + } + + my $conf = GnizaWHM::Config::parse($conf_path, 'schedule'); + my $is_on = (($conf->{SKIP_SUSPENDED} // '') eq 'yes'); + + # Toggle the value + $conf->{SKIP_SUSPENDED} = $is_on ? '' : 'yes'; + my ($ok, $err) = GnizaWHM::Config::write($conf_path, $conf, \@GnizaWHM::Config::SCHEDULE_KEYS); + + if ($ok) { + # Reinstall cron so --skip-suspended flag is updated + GnizaWHM::Cron::install_schedule($name); + my $new_state = $is_on ? 0 : 1; + my $label = $new_state ? 'enabled' : 'disabled'; + _json_response(1, $new_state, "Skip suspended $label for '$name'.", $new_csrf); + } else { + _json_response(0, $is_on ? 1 : 0, "Failed to update config: $err", $new_csrf); + } +} + sub _json_response { my ($ok, $active, $message, $csrf) = @_; # Escape for JSON @@ -636,6 +710,18 @@ sub render_schedule_form { print qq{

After all account backups complete, also back up WHM/cPanel config, installed packages, and cron jobs.

\n}; print qq{\n\n}; + # Skip Suspended toggle + my $skip_suspended_val = $conf->{SKIP_SUSPENDED} // ''; + my $skip_suspended_checked = ($skip_suspended_val eq 'yes') ? ' checked' : ''; + print qq{
\n
\n}; + print qq{

Suspended Accounts

\n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{

Exclude cPanel accounts that are currently suspended from this backup schedule.

\n}; + print qq{
\n
\n}; + # Submit print qq{
\n}; my $btn_label = $is_edit ? 'Save Changes' : 'Create Schedule';