Files
gniza4cp/CLAUDE.md
shuki a162536585 Rename product from gniza to gniza4cp across entire codebase
- CLI binary: bin/gniza -> bin/gniza4cp
- Install path: /usr/local/gniza4cp/
- Config path: /etc/gniza4cp/
- Log path: /var/log/gniza4cp/
- WHM plugin: gniza4cp-whm/
- cPanel plugin: cpanel/gniza4cp/
- AdminBin: Gniza4cp::Restore
- Perl modules: Gniza4cpWHM::*, Gniza4cpCPanel::*
- DaisyUI theme: gniza4cp
- All internal references, branding, paths updated
- Git remote updated to gniza4cp repo
2026-03-05 21:03:30 +02:00

45 KiB

agents.md — gniza4cp Development Guide

Reference for AI coding agents working on gniza4cp. Describes architecture, conventions, and key patterns.

Project Overview

gniza4cp is a Bash CLI tool for cPanel server backup and disaster recovery. It runs pkgacct to export accounts, gzips SQL files, and transfers everything to one or more remote destinations using hardlink-based incremental snapshots. Supports three remote types: SSH (rsync with --link-dest), Amazon S3 / S3-compatible (via rclone), and Google Drive (via rclone).

Language: Bash (bash 4+, set -euo pipefail) Target environment: CentOS/AlmaLinux cPanel servers, running as root Install path: /usr/local/gniza4cp/ with symlink at /usr/local/bin/gniza4cp

Repository Structure

bin/gniza4cp                  # CLI entrypoint — command routing, argument parsing
lib/
├── constants.sh           # Version, exit codes, color codes, default values
├── utils.sh               # die(), require_root(), timestamp(), human_size/duration(), validate_timestamp/account_name()
├── logging.sh             # Per-run log files (LOG_FILE), log_info/warn/error/debug
├── config.sh              # _safe_source_config(), load_config(), validate_config()
├── locking.sh             # flock-based acquire_lock(), release_lock()
├── ssh.sh                 # build_ssh_opts(), remote_exec(), test_ssh_connection()
├── rclone.sh              # Rclone transport layer for S3/GDrive remotes
├── accounts.sh            # get_all_accounts(), filter_accounts(), is_suspended(), get_backup_accounts(), account_exists()
├── pkgacct.sh             # run_pkgacct(), gzip_sql_files(), cleanup_pkgacct()
├── snapshot.sh            # get_snapshot_dir(), list_remote_snapshots(), finalize
├── transfer.sh            # rsync_to_remote(), transfer_pkgacct/homedir(), finalize_snapshot()
├── retention.sh           # enforce_retention() — prune old snapshots
├── verify.sh              # verify_account_backup(), verify_all_accounts()
├── notify.sh              # send_notification(), send_backup_report()
├── restore.sh             # restore_full_account/files/database/mailbox/server()
├── remotes.sh             # Multi-remote: list_remotes(), load_remote(), get_target_remotes()
└── schedule.sh            # Cron: decoupled schedules from schedules.d/
etc/
├── gniza4cp.conf.example     # Main config template
├── remote.conf.example    # Remote destination config template
└── schedule.conf.example  # Schedule config template
scripts/
├── install.sh             # Install to /usr/local/gniza4cp, create dirs/symlinks, WHM + cPanel plugins
└── uninstall.sh           # Remove install dir, symlink, cron entries, WHM + cPanel plugins
tests/
└── test_utils.sh          # Unit tests for utils.sh, accounts.sh, config.sh
whm/
├── gniza4cp-whm.conf         # WHM AppConfig registration
└── gniza4cp-whm/
    ├── index.cgi          # Dashboard — overview, quick links, auto-redirect if unconfigured
    ├── setup.cgi          # 3-step setup wizard (SSH key → remote → schedule)
    ├── settings.cgi       # Main config editor (local settings only)
    ├── remotes.cgi        # Remote CRUD — add/edit/delete, SSH key guidance on add
    ├── schedules.cgi      # Schedule CRUD — add/edit/delete with remote checkboxes
    ├── restore.cgi        # Restore workflow — 4-step form (account → snapshot → confirm → execute)
    ├── assets/
    │   ├── gniza4cp-whm.css          # Built Tailwind/DaisyUI CSS (committed, ~58KB)
    │   ├── gniza4cp-logo.svg         # SVG logo (embedded as data URI in page header)
    │   └── src/
    │       ├── input.css           # Tailwind v4 entry point with DaisyUI plugin
    │       ├── safelist.html       # Class safelist for Tailwind content scanner
    │       └── package.json        # Build toolchain (tailwindcss + daisyui)
    └── lib/Gniza4cpWHM/
        ├── Config.pm      # Pure Perl config parser/writer (KEY="value" files)
        ├── Validator.pm   # Input validation (mirrors lib/config.sh)
        ├── Cron.pm        # Cron read + allowlisted gniza4cp schedule commands
        ├── Runner.pm      # Pattern-based safe CLI command runner for WHM
        └── UI.pm          # Nav, flash, CSRF, HTML escaping, CSS delivery
cpanel/
├── gniza4cp/
│   ├── index.live.cgi     # Category grid — 8 restore type cards
│   ├── restore.live.cgi   # Multi-step restore workflow (4 steps)
│   ├── install.json       # cPanel plugin registration (Files section)
│   ├── assets/
│   │   ├── gniza4cp-whm.css  # Built CSS (copy of WHM CSS)
│   │   └── gniza4cp-logo.svg # Logo (copy of WHM logo)
│   └── lib/Gniza4cpCPanel/
│       └── UI.pm          # Page wrapper, CSRF, flash, CSS delivery
└── admin/Gniza4cp/
    ├── Restore            # AdminBin module (runs as root, privilege escalation)
    └── Restore.conf       # AdminBin config (mode=full)

Architecture

Global-Swapping Pattern (Multi-Remote)

All library functions (ssh.sh, rclone.sh, transfer.sh, snapshot.sh, retention.sh) read globals like REMOTE_TYPE, REMOTE_HOST, REMOTE_PORT, REMOTE_USER, REMOTE_KEY, REMOTE_BASE, BWLIMIT, RETENTION_COUNT, RSYNC_EXTRA_OPTS, plus cloud-specific globals (S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_ENDPOINT, S3_BUCKET, GDRIVE_SERVICE_ACCOUNT_FILE, GDRIVE_ROOT_FOLDER_ID).

Rather than passing remote context through function arguments, remotes.sh provides:

  • _save_remote_globals() — snapshot current globals
  • load_remote(name) — source /etc/gniza4cp/remotes.d/<name>.conf, overriding REMOTE_* globals
  • _restore_remote_globals() — restore saved snapshot

This keeps the change set minimal — no existing function signatures needed modification.

Critical pattern: Always call _save_remote_globals() before a remote loop, load_remote() inside the loop, and _restore_remote_globals() after the loop:

_save_remote_globals
while IFS= read -r rname; do
    load_remote "$rname"
    # ... existing functions use current REMOTE_* globals ...
done <<< "$remotes"
_restore_remote_globals

Backup Flow

cmd_backup()
├── require_cmd(rsync, ssh, hostname, gzip)
├── load_config() + validate_config() + init_logging()
├── get_target_remotes(--remote flag)
├── _save_remote_globals()
├── Test connectivity to all targets upfront (SSH or rclone)
├── For each account:
│   ├── run_pkgacct()              # ONCE
│   ├── gzip_sql_files()           # ONCE
│   ├── For each remote:
│   │   ├── load_remote(name)
│   │   └── _backup_to_current_remote()
│   │       ├── clean_partial_snapshots()
│   │       ├── get_latest_snapshot()
│   │       ├── transfer_pkgacct()
│   │       ├── transfer_homedir()
│   │       ├── finalize_snapshot()
│   │       └── enforce_retention()
│   ├── _restore_remote_globals()
│   └── cleanup_pkgacct()
└── send_backup_report()

Command Routing

bin/gniza4cp main() parses the first arg and routes to cmd_*() functions. Each command handles its own --config, --remote, --account flags via get_opt() and has_flag().

Commands: backup, restore, list, verify, status, remote, schedule, version, help

Config Hierarchy

  1. lib/constants.shDEFAULT_* readonly values
  2. /etc/gniza4cp/gniza4cp.conf — main config: local settings only (accounts, logging, notifications)
  3. /etc/gniza4cp/remotes.d/<name>.conf — per-remote config (REMOTE_*, retention, transfer)
  4. /etc/gniza4cp/schedules.d/<name>.conf — per-schedule config (timing, target remotes)
  5. CLI flags (--debug, --config=PATH)

Snapshot Layout

  • Remote path: $REMOTE_BASE/<hostname>/accounts/<user>/snapshots/<timestamp>
  • Timestamp format: YYYY-MM-DDTHHMMSS (UTC)
  • pkgacct content lives directly in the snapshot root (no pkgacct/ wrapper)
  • homedir/ subdirectory sits alongside the pkgacct content
<timestamp>/
├── mysql/        ← pkgacct: SQL dumps (*.sql.gz)
├── mysql.sql     ← pkgacct: database grants
├── cp/           ← pkgacct: cPanel metadata
├── ...           ← other pkgacct files
└── homedir/      ← home directory

SSH remotes: In-progress snapshots have .partial suffix. latest symlink points to newest completed snapshot. Uses rsync --link-dest for deduplication.

Cloud remotes (S3/GDrive): Cloud storage has no atomic rename or symlinks. Instead:

  • Uploads go directly to <timestamp>/ (no .partial suffix)
  • A .complete marker file is created on success
  • latest.txt text file stores the newest timestamp (replaces symlink)
  • Directories without .complete are treated as partials and purged on next run

Backward compat: Old snapshots may have a pkgacct/ subdirectory. Verify and restore detect the format automatically (test -d "$snap_path/pkgacct" for SSH, rclone_exists for cloud) and adjust paths accordingly. transfer_pkgacct() link-dest also handles old-format previous snapshots.

Decoupled Schedules

Schedules are independent from remotes. Each schedule lives in /etc/gniza4cp/schedules.d/<name>.conf and defines when backups run and which remotes to target. This allows multiple schedules targeting different sets of remotes.

Cron entries are tagged with # gniza4cp:<name> comment lines. install_schedules() strips old tagged lines and appends new ones. Format:

# gniza4cp:nightly
0 2 * * * /usr/local/bin/gniza4cp backup --remote=nas,offsite >> /var/log/gniza4cp/cron-nightly.log 2>&1

Comma-Separated Remote Targeting

get_target_remotes() accepts comma-separated remote names via --remote=nas,offsite. It splits on commas, verifies each remote exists, and outputs one name per line. This enables both CLI usage and schedule configs targeting multiple remotes.

cPanel User Restore Plugin

Allows cPanel account owners to restore their own data (files, databases, email, etc.) without WHM admin access.

Privilege escalation: Uses cPanel's AdminBin framework. CGIs run as the logged-in cPanel user; the AdminBin module (cpanel/admin/Gniza4cp/Restore) runs as root. The account parameter is always forced to $ENV{'REMOTE_USER'} (cPanel-authenticated), never from user input.

CGI file naming: cPanel Jupiter theme uses .live.cgi extension for CGI files (e.g., index.live.cgi, restore.live.cgi).

Security model:

  • Account isolation: AdminBin forces the authenticated username — users can only restore their own data
  • No --terminate: AdminBin never passes the terminate flag, preventing destructive full restores
  • Remote filtering: USER_RESTORE_REMOTES config controls which remotes users can access ("all", comma-separated names, or empty to disable)
  • Strict regex validation on all arguments (mirrors Gniza4cpWHM::Runner patterns)
  • Path traversal prevention: path regex uses negative lookahead to reject ..qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/
  • Remote name regex: qr/^[a-zA-Z0-9_-]+$/ (rejects special characters)
  • Per-user CSRF tokens at /tmp/.gniza4cp-cpanel-csrf-$user (symlink-safe I/O)
  • Symlink-safe file operations: _safe_write uses unlink + O_CREAT|O_EXCL with fallback; _safe_read rejects symlinks via -l check
  • Flash message type validated against allowlist (success, error, info, warning)

Install locations:

  • CGIs: /usr/local/cpanel/base/frontend/jupiter/gniza4cp/
  • AdminBin: /usr/local/cpanel/bin/admin/Gniza4cp/ (Restore is 0700, Restore.conf is 0600)
  • Plugin registration: via install_plugin with tar.gz archive containing install.json
  • Assets: CSS and logo copied to gniza4cp/assets/ alongside CGIs
  • install.json also copied to CGI directory for uninstall_plugin to reference

Restore categories (8 types):

Type Label AdminBin List Action AdminBin Restore Action
account Full Backup RESTORE_ACCOUNT
files Home Directory LIST_FILES RESTORE_FILES
database Databases LIST_DATABASES RESTORE_DATABASE
dbusers Database Users LIST_DBUSERS RESTORE_DBUSERS
cron Cron Jobs LIST_CRON RESTORE_CRON
domains Domains LIST_DNS RESTORE_DOMAINS
ssl Certificates LIST_SSL RESTORE_SSL
mailbox Email Accounts LIST_MAILBOXES RESTORE_MAILBOX

Workflow: Category grid (index.live.cgi) → 4-step restore (restore.live.cgi):

  1. Select remote + snapshot timestamp (AJAX-loaded dropdowns)
  2. Select specific items (database, mailbox, file path, etc.) — skipped for account and cron types
  3. Confirmation summary with CSRF token
  4. Execute via AdminBin, display results

cPanel plugin registration: install.json is an array of plugin definitions passed to install_plugin/uninstall_plugin inside a tar.gz archive (with the icon file included). Required JSON fields per cPanel's Cpanel::Themes::Assets::Link: type ("link"), id (lowercase identifier), name, group_id (section: "files", "domains", etc.), uri (CGI path), feature (for Feature Manager), order (integer), icon (path relative to staging dir). The feature key (gniza4cp_restore) allows admins to enable/disable per cPanel package.

Gniza4cpCPanel::UI

Function Description
esc($str) HTML-escape a string
get_current_user() Returns $ENV{'REMOTE_USER'}
_safe_write($file, $content) Symlink-safe write: unlink + O_CREAT|O_EXCL (0600 perms)
_safe_read($file) Symlink-safe read: rejects symlinks (-l check)
page_header($title) Inline CSS + data-theme="gniza4cp" wrapper + logo (base64 data URI)
page_footer() Close wrapper div
set_flash($type, $text) Store flash message at /tmp/.gniza4cp-cpanel-flash-$user
get_flash() Read and consume flash message
render_flash() Render flash as HTML alert (type validated against allowlist)
generate_csrf_token() Generate 64-char hex token from /dev/urandom, store at /tmp/.gniza4cp-cpanel-csrf-$user
verify_csrf_token($token) Validate + delete (single-use), 1-hour expiry, constant-time comparison
csrf_hidden_field() Generate CSRF token + hidden input
render_errors(\@errors) Render error list as HTML
_unwrap_layers($css) Strip @layer wrappers from Tailwind CSS
_scope_to_container($css) Scope CSS rules to [data-theme="gniza4cp"] container

AdminBin Module (Gniza4cp::Restore)

Runs as root via cPanel's AdminBin framework. Each action validates inputs with strict regex patterns before executing gniza4cp CLI via IPC::Open3 (list execution, no shell).

Validation patterns:

Pattern Regex Used for
$ACCOUNT_RE qr/^[a-z][a-z0-9_-]*$/ cPanel usernames
$REMOTE_RE qr/^[a-zA-Z0-9_-]+$/ Remote names
$DBNAME_RE qr/^[a-zA-Z0-9_]+$/ Database/DB user names
$EMAIL_RE qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/ Email addresses
$DOMAIN_RE qr/^[a-zA-Z0-9._-]+$/ Domain names
$TS_RE qr/^\d{4}-\d{2}-\d{2}T\d{6}$/ Timestamps
path qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/ File paths (rejects ..)
exclude qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/ Exclude patterns

Actions: LIST_ALLOWED_REMOTES, LIST_SNAPSHOTS, LIST_DATABASES, LIST_MAILBOXES, LIST_FILES, LIST_DBUSERS, LIST_CRON, LIST_DNS, LIST_SSL, RESTORE_ACCOUNT, RESTORE_FILES, RESTORE_DATABASE, RESTORE_MAILBOX, RESTORE_CRON, RESTORE_DBUSERS, RESTORE_DOMAINS, RESTORE_SSL

Remote filtering: _get_allowed_remotes() reads USER_RESTORE_REMOTES from /etc/gniza4cp/gniza4cp.conf. Returns "all" (default), comma-separated names, or empty string (disabled). _is_remote_allowed() and _get_filtered_remotes() enforce this on every action.

Called from CGI via: Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'ACTION', @args)

Coding Conventions

Bash Style

  • set -euo pipefail at top of entrypoint
  • Functions use local for all variables
  • Error paths: log_error + return 1 (library) or die "message" (CLI)
  • Guard-include pattern for constants: [[ -n "${_GNIZA4CP_CONSTANTS_LOADED:-}" ]] && return 0
  • ((count++)) || true to avoid set -e traps on zero-to-one arithmetic

Naming

  • Libraries: lib/<module>.sh — each file focuses on one responsibility
  • Public functions: snake_case (e.g., transfer_pkgacct, list_remote_snapshots)
  • Private/helper functions: _prefixed (e.g., _backup_to_current_remote, _save_remote_globals)
  • CLI commands: cmd_<name>() in bin/gniza4cp
  • Constants: UPPER_SNAKE_CASE, prefixed with DEFAULT_ for defaults
  • Globals: UPPER_SNAKE_CASE (e.g., REMOTE_HOST, LOG_LEVEL)

Error Handling

  • Single account failures don't abort the run (continue loop)
  • In multi-remote mode, failure on one remote doesn't block others
  • rsync retries with exponential backoff: sleep $((attempt * 10))
  • Exit codes: 0 OK, 1 fatal, 2 locked, 5 partial failure
  • Lock via flock on /var/run/gniza4cp.lock

cPanel API Policy

All cPanel operations MUST use native cPanel APIs (UAPI, cpapi2, whmapi1). Never use raw system commands (mysql, crontab -u, rndc, direct cp to cPanel paths) when a cPanel API exists. No fallbacks — if an API call fails, log the error and return failure.

Operation API Command
Create database UAPI uapi --user=$user Mysql create_database name=$dbname
Create DB user UAPI uapi --user=$user Mysql create_user name=$dbuser password=$pass
Set DB privileges UAPI uapi --user=$user Mysql set_privileges_on_database user=$dbuser database=$dbname privileges=ALL
Get DB prefix UAPI uapi --user=$user Mysql get_restrictionsprefix: field
List DB users UAPI uapi --user=$user Mysql list_users
Create mailbox UAPI uapi --user=$user Email add_pop email=$mailuser domain=$domain password=$pass quota=0
Add cron job cpapi2 cpapi2 --user=$user Cron add_line minute=... hour=... day=... month=... weekday=... command=...
List cron jobs cpapi2 cpapi2 --user=$user Cron listcron
Remove cron job cpapi2 cpapi2 --user=$user Cron remove_line linekey=$key
Install SSL cert whmapi1 whmapi1 installssl domain=$domain crt=$cert key=$key cab=$cab
Create DNS zone whmapi1 whmapi1 adddns domain=$domain trueowner=$user
Add DNS record whmapi1 whmapi1 addzonerecord domain=$domain name=$name type=$type address=$value ttl=$ttl
Rebuild Apache script /usr/local/cpanel/scripts/rebuildhttpdconf
Rebuild user domains script /usr/local/cpanel/scripts/updateuserdomains

Prefix handling: cPanel enforces a DB name/user prefix per account (e.g., username_). Get it via uapi Mysql get_restrictions. Names that don't match the prefix must be skipped with a warning — UAPI will reject them.

Allowed exceptions (no cPanel API exists):

  • SQL dump import: mysql $dbname < dump.sql — only place raw mysql is permitted
  • cPanel user config: cp to /var/cpanel/users/$user — no API for wholesale config file replacement
  • Userdata files: cp to /var/cpanel/userdata/$user/ — no API for writing raw userdata

Forbidden (cPanel APIs exist — always use them):

  • mysql for CREATE DATABASE, CREATE USER, GRANT — use UAPI Mysql
  • crontab -u — use cpapi2 Cron
  • cp to /var/named/ + rndc reload — use whmapi1 adddns/addzonerecord
  • cp to /var/cpanel/ssl/ + checkallsslcerts — use whmapi1 installssl
  • Direct mailbox file creation — use UAPI Email add_pop first

Security

CLI (Bash):

  • Config parsing: _safe_source_config() in lib/config.sh reads KEY=VALUE lines via regex without source/eval — prevents command injection from malicious config files
  • Password handling: SSH passwords passed via sshpass -e (environment variable SSHPASS), never -p (visible in process list)
  • File permissions: umask 077 set at startup in bin/gniza4cp; install.sh sets config dirs to chmod 700
  • Safe rm: ${var:?} pattern prevents rm -rf ""/\* expansion on empty variables (SC2115)
  • Input validation: validate_timestamp() and validate_account_name() enforce strict regex patterns. Account names: ^[a-z][a-z0-9_-]{0,15}$. Timestamps: ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}$
  • RSYNC_EXTRA_OPTS validation: Both Perl (Validator.pm) and Bash (validate_config) reject shell metacharacters (^[a-zA-Z0-9 ._=/,-]+$)

WHM Plugin:

  • CSRF: All POST endpoints require CSRF token via verify_csrf_token(). Single-use tokens stored at /var/cpanel/.gniza4cp-whm-csrf/token. AJAX endpoints (e.g., SMTP test) return a new token in JSON responses; JS updates both the AJAX variable and the main form hidden field to keep them in sync
  • HTML escaping: All user-controlled output passed through esc() (HTML entity encoding)
  • Runner path traversal: Gniza4cpWHM::Runner rejects --account and --path values containing ..
  • Config file I/O: Gniza4cpWHM::Config::save() uses flock(LOCK_EX) with single file handle (open +< then seek+truncate) to prevent TOCTOU races
  • Safe file I/O: _safe_write() uses unlink + O_CREAT|O_EXCL with plain-write fallback; _safe_read() rejects symlinks. Used for CSRF token and flash message files
  • Upgrade path: _ensure_dir() removes stale plain files left by older versions before creating directories (old versions stored CSRF/flash as plain files at the directory path)

cPanel User Plugin:

  • Account isolation: AdminBin forces $ENV{'REMOTE_USER'} as the account — users can only restore their own data
  • No --terminate: AdminBin never passes the terminate flag, preventing destructive full restores
  • Remote filtering: USER_RESTORE_REMOTES config controls which remotes users can access
  • Strict regex validation: All AdminBin arguments validated against regex patterns (see AdminBin Module section)
  • Path traversal prevention: Path regex uses negative lookahead: qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/
  • CSRF: Per-user single-use tokens at /tmp/.gniza4cp-cpanel-csrf-$user, generated from /dev/urandom (64-char hex), 1-hour expiry, constant-time comparison
  • Symlink-safe I/O: _safe_write() (unlink + O_CREAT|O_EXCL with fallback) and _safe_read() (rejects symlinks) for all /tmp/ files
  • Flash type validation: render_flash() validates type against allowlist (success, error, info, warning)
  • Command execution: gniza4cp CLI called via IPC::Open3 as list (no shell interpolation)

SSH/Rsync (REMOTE_TYPE=ssh)

  • All SSH operations go through build_ssh_opts() / remote_exec() in ssh.sh
  • rsync uses: -aHAX --numeric-ids --delete --rsync-path="rsync --fake-super" --link-dest=<prev>
  • --fake-super stores real uid/gid/permissions as xattrs on the remote, preserving ownership even when the remote user is not root
  • Bandwidth limiting: --bwlimit=$BWLIMIT
  • Extra opts: $RSYNC_EXTRA_OPTS (split by word)

Rclone Transport (REMOTE_TYPE=s3 or gdrive)

  • All cloud operations go through lib/rclone.sh
  • _is_rclone_mode() returns true when REMOTE_TYPE is s3 or gdrive
  • Each library function (snapshot, transfer, retention, verify, restore) checks _is_rclone_mode at the top and dispatches to rclone_* equivalents
  • Temp rclone config generated per-operation from stored globals (_build_rclone_config()), cleaned up after
  • S3 path: remote:${S3_BUCKET}${REMOTE_BASE}/<hostname>/accounts/...
  • GDrive path: remote:${REMOTE_BASE}/<hostname>/accounts/...
  • Bandwidth limiting: --bwlimit=${BWLIMIT}k
  • Retries with exponential backoff mirror rsync behavior

Configuration Files

Main Config (/etc/gniza4cp/gniza4cp.conf)

Contains only local settings. Remote destinations are configured in remotes.d/.

Variable Required Default Description
TEMP_DIR No /usr/local/gniza4cp/workdir Local working directory
INCLUDE_ACCOUNTS No (all) Comma-separated account list
EXCLUDE_ACCOUNTS No nobody Comma-separated exclusions
LOG_DIR No /var/log/gniza4cp Log directory
LOG_LEVEL No info debug|info|warn|error
LOG_RETAIN No 90 Days to keep log files
NOTIFY_EMAIL No (disabled) Notification email
NOTIFY_ON No failure always|failure|never
LOCK_FILE No /var/run/gniza4cp.lock Lock file path
SSH_TIMEOUT No 30 SSH connection timeout (seconds)
SSH_RETRIES No 3 rsync retry attempts
RSYNC_EXTRA_OPTS No (empty) Extra rsync options
USER_RESTORE_REMOTES No all Remotes for cPanel user restore (all, comma-separated names, or empty to disable)

Remote Config (/etc/gniza4cp/remotes.d/<name>.conf)

Common (all types):

Variable Required Default Description
REMOTE_TYPE No ssh ssh, s3, or gdrive
REMOTE_BASE No /backups Remote base directory/path
BWLIMIT No 0 Bandwidth limit KB/s
RETENTION_COUNT No 30 Snapshots to keep

SSH-specific (REMOTE_TYPE=ssh):

Variable Required Default Description
REMOTE_HOST Yes Remote hostname/IP
REMOTE_PORT No 22 SSH port
REMOTE_USER No root SSH user
REMOTE_AUTH_METHOD No key key or password
REMOTE_KEY Yes (key) SSH private key path
REMOTE_PASSWORD Yes (password) SSH password (requires sshpass)
RSYNC_EXTRA_OPTS No (empty) Extra rsync options

S3-specific (REMOTE_TYPE=s3):

Variable Required Default Description
S3_ACCESS_KEY_ID Yes S3 access key
S3_SECRET_ACCESS_KEY Yes S3 secret key
S3_REGION No us-east-1 AWS region
S3_ENDPOINT No (empty) Custom endpoint for S3-compatible services
S3_BUCKET Yes Bucket name

Google Drive-specific (REMOTE_TYPE=gdrive):

Variable Required Default Description
GDRIVE_SERVICE_ACCOUNT_FILE Yes Path to service account JSON key file
GDRIVE_ROOT_FOLDER_ID No (empty) Root folder ID

Schedule Config (/etc/gniza4cp/schedules.d/<name>.conf)

Schedules are decoupled from remotes. Each schedule targets one or more remotes.

Variable Required Default Description
SCHEDULE Yes hourly|daily|weekly|monthly|custom
SCHEDULE_TIME No 02:00 HH:MM (24-hour)
SCHEDULE_DAY Conditional Hours interval (1-23) for hourly, day-of-week (0-6) for weekly, day-of-month (1-28) for monthly
SCHEDULE_CRON Conditional Full 5-field cron expr (for custom only)
REMOTES No (all) Comma-separated remote names to target
SYSBACKUP No (empty) yes to include system backup (/scripts/pkgacct)
SKIP_SUSPENDED No (empty) yes to skip cPanel suspended accounts

Key Functions Reference

rclone.sh

Function Description
_is_rclone_mode() True if REMOTE_TYPE is s3 or gdrive
_build_rclone_config() Generate temp rclone.conf from current globals, return path
_cleanup_rclone_config(path) Remove temp config file
_rclone_remote_path(subpath) Build remote:bucket/base/subpath or remote:base/subpath
_rclone_cmd(subcmd, args...) Run rclone with temp config, manage lifecycle
rclone_to_remote(src, dest) Upload directory to remote (mirrors rsync_to_remote)
rclone_from_remote(src, dest) Download from remote to local directory
rclone_list_remote_snapshots(user) List snapshots with .complete marker
rclone_get_latest_snapshot(user) Read latest.txt or fall back to sorted list
rclone_finalize_snapshot(user, ts) Create .complete marker + write latest.txt
rclone_clean_partial_snapshots(user) Purge dirs without .complete
rclone_resolve_snapshot(user, ts) Check dir exists + has .complete
rclone_ensure_dir(path) Create remote directory
rclone_purge(path) Recursive delete
rclone_exists(path) Check if remote path exists
rclone_size(path) Get size via rclone size --json
rclone_list_files(path) List files via rclone lsf
rclone_list_dirs(path) List directories via rclone lsf --dirs-only
rclone_cat(path) Read remote file content
rclone_rcat(path, content) Write content to remote file
test_rclone_connection() Verify credentials with rclone lsd

remotes.sh

Function Description
list_remotes() List remote names from remotes.d/*.conf
has_remotes() Check if any remote configs exist
load_remote(name) Source config, override REMOTE_/S3_/GDRIVE_* globals
validate_remote(name) Load + validate a remote config (dispatches by REMOTE_TYPE)
get_target_remotes(flag) Resolve --remote=NAME[,NAME2] (comma-separated) or return all; errors if none configured
_save_remote_globals() Save current REMOTE_/S3_/GDRIVE_* globals
_restore_remote_globals() Restore saved globals

schedule.sh

Reads schedules from /etc/gniza4cp/schedules.d/ (decoupled from remotes).

Function Description
list_schedules() List schedule names from schedules.d/*.conf
has_schedules() Check if any schedule configs exist
load_schedule(name) Source config, set SCHEDULE/SCHEDULE_REMOTES globals
schedule_to_cron(name) Convert SCHEDULE vars to 5-field cron expression
build_cron_line(name) Full cron line with gniza4cp command, --remote= flag, and log redirect
install_schedules() Strip old gniza4cp cron entries, add new from all schedules.d/
show_schedules() Display current gniza4cp cron entries
remove_schedules() Remove all gniza4cp cron entries

restore.sh

All restore functions dispatch by _is_rclone_mode — using rclone_from_remote for cloud or rsync for SSH.

Function Description
restore_full_account(user, ts, terminate, exclude) Full account restore from snapshot. If terminate=true, removes existing account via /scripts/removeacct before restoring. Otherwise merges with --force.
restore_files(user, ts, path) Restore specific files/directories
restore_database(user, ts, dbname) Restore a MySQL database from snapshot
restore_mailbox(user, email, ts) Restore a mailbox (parses email → mail/domain/user path)
list_snapshot_databases(user, ts) List *.sql.gz in snapshot's mysql/ dir (SSH: remote_exec, cloud: rclone_list_files)
list_snapshot_mailboxes(user, ts) List domain/user dirs in mail/ (SSH: remote_exec, cloud: rclone_list_dirs)
_rsync_download(src, dest) Download helper — dispatches rclone_from_remote or rsync
_detect_pkgacct_base(user, ts) Detect old vs new snapshot format (SSH or cloud)

bin/gniza4cp (CLI helpers)

Function Description
get_opt(name, args...) Extract --name=VALUE from args
has_flag(name, args...) Check for --name boolean flag
_backup_to_current_remote(user, ts) Transfer + finalize + retention for one account on current remote
_restore_load_remote(flag) Load remote context for restore (always requires --remote)
_list_current_remote(account) Display listing for current remote context
_test_connection() Dispatch test_rclone_connection or test_ssh_connection by type
_status_ssh_and_disk() Connection test + disk/storage usage display (SSH: df, cloud: rclone about)
cmd_remote() Remote management: list, delete
cmd_schedule() Schedule CRUD: add, delete, list, install, show, remove

Gniza4cpWHM::UI (WHM plugin)

Function Description
is_configured() True if any remote configs exist in remotes.d/
detect_ssh_keys() Scan /root/.ssh/ for key files, return arrayref of hashes
render_ssh_guidance() HTML block: detected keys + keygen/ssh-copy-id instructions
has_remotes() Check if /etc/gniza4cp/remotes.d/ has .conf files
list_remotes() Return sorted list of remote names
has_schedules() Check if /etc/gniza4cp/schedules.d/ has .conf files
list_schedules() Return sorted list of schedule names
schedule_conf_path($name) Return path to schedule config file
esc($str) HTML-escape a string
render_nav($page) DaisyUI tab navigation bar (tabs-lift)
set_flash($type, $text) Store flash message for next page load
render_flash() Render and consume stored flash message
csrf_hidden_field() Generate CSRF token + hidden input
verify_csrf_token($token) Validate submitted CSRF token
render_errors(\@errors) Render error list as HTML
page_header($title) Inline CSS + data-theme="light" wrapper + page title
page_footer() Close the data-theme wrapper div
_unwrap_layers($css) Strip @layer wrappers from Tailwind CSS for WHM compatibility
get_cpanel_accounts() Parse /etc/trueuserdomains for account list
test_ssh_connection(%args) Test SSH connection via ssh (accepts named args or positional for backward compat)
test_rclone_connection(%args) Test S3/GDrive connection via rclone (generates temp config, runs rclone lsd)

Gniza4cpWHM::Runner (WHM plugin)

Pattern-based command runner for safe CLI execution from the WHM UI. Each allowed command has regex patterns per argument position.

Function Description
run($cmd, $subcmd, \@args, \%opts) Validate against allowlist and execute gniza4cp CLI

Allowed commands: restore account/files/database/mailbox/list-databases/list-mailboxes, list. Named option patterns: --remote, --timestamp, --path, --account, --terminate, --exclude. Path traversal prevention: --account and --path values containing .. are rejected.

Gniza4cpWHM::Config

Pure Perl config parser/writer. Uses flock(LOCK_EX) with single file handle for TOCTOU-safe reads and writes.

Function/Array Description
parse($filepath, $type) Parse KEY="value" config file, returns hashref. $type: main, remote, or schedule
save($filepath, \%values, \@allowed_keys) Write config preserving comments/structure. Uses flock(LOCK_EX) for atomic read+write
escape_value($string) Strip unsafe characters for double-quoted bash config values
escape_password($string) Strip single quotes only (for single-quoted password values)
@MAIN_KEYS Main config keys (local settings only, no REMOTE_*)
@REMOTE_KEYS Remote config keys (REMOTE_TYPE, SSH, S3, GDrive, transfer, retention — no SCHEDULE*)
@SCHEDULE_KEYS Schedule config keys (SCHEDULE, SCHEDULE_TIME, SCHEDULE_DAY, SCHEDULE_CRON, REMOTES, SYSBACKUP, SKIP_SUSPENDED)

Gniza4cpWHM::Validator

Function Description
validate_main_config(\%data) Validate main config values
validate_remote_config(\%data) Validate remote config by REMOTE_TYPE (ssh: host+key/password, s3: credentials+bucket, gdrive: service account)
validate_remote_name($name) Validate remote name (alphanumeric + hyphens/underscores)
validate_schedule_config(\%data) Validate schedule config (SCHEDULE required, conditional fields)
validate_schedule_name($name) Validate schedule name

Testing

Run existing tests:

bash tests/test_utils.sh

Tests cover (40 tests):

  • timestamp() format, human_size(), human_duration(), require_cmd()
  • filter_accounts() — exclusions, inclusions, include+exclude combo
  • validate_config() — LOG_LEVEL, NOTIFY_ON, SSH_TIMEOUT, SSH_RETRIES, RSYNC_EXTRA_OPTS
  • validate_timestamp() — valid format, end-of-year, garbage, spaces/colons, empty string
  • validate_account_name() — valid names, uppercase, leading digit, path traversal, empty, special chars
  • _safe_source_config() — double-quoted, single-quoted, bare, numeric values, malicious file injection

Tests use a simple assert_eq/assert_ok/assert_fail framework defined in test_utils.sh.

Common Tasks

Adding a new library function

  1. Add to the appropriate lib/<module>.sh
  2. Functions are automatically available — libraries are sourced in bin/gniza4cp
  3. Use local for all variables, log_* for output, return 1 for errors

Adding a new command

  1. Add cmd_<name>() function in bin/gniza4cp
  2. Add routing in main() case statement
  3. Update cmd_usage() help text
  4. Update README.md commands table

Adding a new config variable

  1. Add DEFAULT_<NAME> to lib/constants.sh
  2. Add to load_config() in lib/config.sh with fallback
  3. Add validation in validate_config() if needed
  4. Add to etc/gniza4cp.conf.example
  5. Document in README.md and this file

Making a function remote-aware

If a function needs to work across multiple remotes, wrap calls in the save/load/restore pattern:

_save_remote_globals
while IFS= read -r rname; do
    load_remote "$rname"
    your_function_here    # uses current REMOTE_* globals
done <<< "$remotes"
_restore_remote_globals

Adding a new WHM plugin page

  1. Create whm/gniza4cp-whm/<name>.cgi following the pattern of existing CGIs
  2. Use same boilerplate: shebang, use lib, Whostmgr::HTMLInterface, Cpanel::Form, Gniza4cpWHM::UI
  3. Route by $form->{'action'} or similar param
  4. Use Gniza4cpWHM::UI::page_header(), render_nav(), render_flash(), csrf_hidden_field(), page_footer()
  5. Validate POST with verify_csrf_token(), redirect with 302 after success
  6. No AppConfig change needed — url=/cgi/gniza4cp-whm/ covers all CGIs in the directory
  7. Add any new DaisyUI/Tailwind classes to assets/src/safelist.html and rebuild CSS
  8. Add the page to @NAV_ITEMS in UI.pm if it should appear in the tab bar

Adding a new cPanel plugin page

  1. Create cpanel/gniza4cp/<name>.live.cgi (note .live.cgi extension for Jupiter theme)
  2. Use same boilerplate: shebang, use lib pointing to CGI lib dir, Cpanel::Form, Gniza4cpCPanel::UI
  3. For privilege escalation, call AdminBin: Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'ACTION', @args)
  4. Use Gniza4cpCPanel::UI::page_header(), csrf_hidden_field(), page_footer()
  5. Validate POST with verify_csrf_token(), redirect with 302 after success
  6. For new AdminBin actions: add the action method to cpanel/admin/Gniza4cp/Restore and to _actions() list
  7. Add the CGI copy command to scripts/install.sh in the cPanel section
  8. CSS is shared with WHM — same gniza4cp-whm.css file, same DaisyUI classes

WHM CSS Policy

NEVER write custom CSS. Always use Tailwind utility classes and DaisyUI components exclusively. All styling must be done through class attributes in HTML — no custom CSS rules, no <style> blocks (except the auto-generated inline delivery in page_header()), no CSS files other than the Tailwind build output.

WHM Theme & Color Palette

The WHM plugin uses a custom DaisyUI theme named gniza4cp (defined in assets/src/input.css). Light-only, no dark mode.

Role OKLCH Value Approx Color
Primary oklch(38.2% 0.145 259.4) Deep navy blue
Primary content oklch(100% 0 0) White
Secondary oklch(69.5% 0.169 47.8) Warm copper/orange
Secondary content oklch(100% 0 0) White
Accent / Warning oklch(86.4% 0.177 90.8) Soft gold/yellow
Accent content / Warning content oklch(30.9% 0.116 258.9) Dark navy
Neutral / Base content oklch(30.9% 0.116 258.9) Dark navy
Neutral content oklch(100% 0 0) White
Base 100 transparent Transparent (inherits WHM background)
Base 200 oklch(97% 0 0) Near-white gray
Base 300 oklch(89.8% 0 0) Light gray
Info oklch(69% 0.083 217.5) Muted blue
Success oklch(65% 0.25 140) Vivid green
Error oklch(57.7% 0.245 27.3) Red

Typography: 'Helvetica Neue', Helvetica, Arial, sans-serif Border radius: 0.5rem (boxes/selectors), 0.25rem (fields) Base font size: 1.6rem (set on the data-theme wrapper to match WHM's sizing)

WHM CSS Build System (Tailwind v4 + DaisyUI v5)

All WHM pages use Tailwind CSS v4 with DaisyUI v5 for styling. The CSS is built from source and committed.

Build:

cd whm/gniza4cp-whm/assets && npm install && npm run build:css

Key files:

  • assets/src/input.css — Tailwind entry point with DaisyUI plugin config
  • assets/src/safelist.html — Class safelist (required because Tailwind v4 scanner doesn't recognize .cgi/.pm file extensions)
  • assets/gniza4cp-whm.css — Built output (committed to repo)

WHM CSS delivery quirks:

  • WHM's CGI directory cannot serve static files directly
  • WHM URLs require session token prefix (/cpsessXXXXX/)
  • CSS is inlined via <style> tag in page_header() — reads from disk, strips @layer wrappers, embeds inline
  • Tailwind v4 wraps all CSS in @layer directives which have lower specificity than WHM's un-layered CSS — _unwrap_layers() strips these
  • @import "tailwindcss" important; adds !important to all utilities so they override WHM's styles

Adding new CSS classes:

  1. Add the class to assets/src/safelist.html (since Tailwind can't scan .cgi/.pm files)
  2. Rebuild: cd whm/gniza4cp-whm/assets && npm run build:css
  3. Commit the updated gniza4cp-whm.css

Install / Uninstall Scripts

install.sh (scripts/install.sh) — must be run as root. Detects whether running from a local clone or downloads via git. Installs to /usr/local/gniza4cp/.

Install steps:

  1. Copy bin/, lib/, etc/ to /usr/local/gniza4cp/
  2. Create symlink /usr/local/bin/gniza4cp/usr/local/gniza4cp/bin/gniza4cp
  3. Create working directory /usr/local/gniza4cp/workdir
  4. Create config directories /etc/gniza4cp/remotes.d/ and /etc/gniza4cp/schedules.d/ (mode 0700)
  5. Copy example configs to /etc/gniza4cp/
  6. Create log directory /var/log/gniza4cp/
  7. If WHM detected: copy whm/gniza4cp-whm/ to CGI dir, register via register_appconfig
  8. If cPanel detected: copy CGIs + lib + assets to Jupiter theme dir, install AdminBin module, register via install_plugin

uninstall.sh (scripts/uninstall.sh) — must be run as root. Also installed to /usr/local/gniza4cp/uninstall.sh.

Uninstall steps:

  1. Remove symlink and install directory
  2. Remove gniza4cp cron entries (lines matching # gniza4cp:)
  3. If WHM plugin exists: unregister via unregister_appconfig, remove directory
  4. If cPanel plugin exists: unregister via uninstall_plugin, remove CGI directory and AdminBin module
  5. Print manual cleanup instructions for /etc/gniza4cp/, /var/log/gniza4cp/, /var/run/gniza4cp.lock

cPanel plugin registration quirk: Both install_plugin and uninstall_plugin expect a tar.gz archive containing install.json — not a raw JSON file path. Passing a JSON file directly prints usage help and does nothing. The scripts create a temporary tar.gz:

PLUGIN_TMPDIR="$(mktemp -d)"
cp "$SOURCE_DIR/cpanel/gniza4cp/install.json" "$PLUGIN_TMPDIR/"
tar -czf "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json
/usr/local/cpanel/scripts/install_plugin "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz"
rm -rf "$PLUGIN_TMPDIR"

install.json is also copied to the CGI directory ($CPANEL_BASE/gniza4cp/install.json) so the uninstall script can find it.

Upgrade Considerations

CSRF/flash storage migration (WHM): Older versions stored CSRF tokens and flash messages as plain files at /var/cpanel/.gniza4cp-whm-csrf and /var/cpanel/.gniza4cp-whm-flash. Current versions use these as directories containing token files. _ensure_dir() in Gniza4cpWHM::UI handles this automatically — it removes stale plain files before creating directories. Without this, CSRF token writes fail silently and all form submissions show "Invalid or expired form token."

CSRF token write robustness: generate_csrf_token() uses _safe_write() (O_CREAT|O_EXCL) with a fallback to plain open '>' write. This ensures the token is always persisted even if the O_EXCL approach fails (e.g., race conditions, filesystem quirks).

SMTP test + form token sync (WHM settings.cgi): The SMTP test AJAX endpoint consumes the CSRF token and returns a new one. The JS handler updates both the AJAX variable (gniza4cpCsrf) and the main form's hidden gniza4cp_csrf field. Without this sync, submitting the main form after an SMTP test would always fail CSRF validation.

Repository

URL
Git (SSH) gitea:shukivaknin/gniza4cp.git (uses Host gitea from ~/.ssh/config)
Git (HTTPS) https://git.linux-hosting.co.il/shukivaknin/gniza4cp.git
Web UI https://git.linux-hosting.co.il/shukivaknin/gniza4cp/