- 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
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 globalsload_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
lib/constants.sh—DEFAULT_*readonly values/etc/gniza4cp/gniza4cp.conf— main config: local settings only (accounts, logging, notifications)/etc/gniza4cp/remotes.d/<name>.conf— per-remote config (REMOTE_*, retention, transfer)/etc/gniza4cp/schedules.d/<name>.conf— per-schedule config (timing, target remotes)- 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.partialsuffix) - A
.completemarker file is created on success latest.txttext file stores the newest timestamp (replaces symlink)- Directories without
.completeare 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_REMOTESconfig controls which remotes users can access ("all", comma-separated names, or empty to disable) - Strict regex validation on all arguments (mirrors
Gniza4cpWHM::Runnerpatterns) - 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_writeusesunlink+O_CREAT|O_EXCLwith fallback;_safe_readrejects symlinks via-lcheck - 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 is0700, Restore.conf is0600) - Plugin registration: via
install_pluginwith tar.gz archive containinginstall.json - Assets: CSS and logo copied to
gniza4cp/assets/alongside CGIs install.jsonalso copied to CGI directory foruninstall_pluginto 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):
- Select remote + snapshot timestamp (AJAX-loaded dropdowns)
- Select specific items (database, mailbox, file path, etc.) — skipped for
accountandcrontypes - Confirmation summary with CSRF token
- 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 pipefailat top of entrypoint- Functions use
localfor all variables - Error paths:
log_error+return 1(library) ordie "message"(CLI) - Guard-include pattern for constants:
[[ -n "${_GNIZA4CP_CONSTANTS_LOADED:-}" ]] && return 0 ((count++)) || trueto avoidset -etraps 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>()inbin/gniza4cp - Constants:
UPPER_SNAKE_CASE, prefixed withDEFAULT_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:
0OK,1fatal,2locked,5partial failure - Lock via
flockon/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_restrictions → prefix: 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 rawmysqlis permitted - cPanel user config:
cpto/var/cpanel/users/$user— no API for wholesale config file replacement - Userdata files:
cpto/var/cpanel/userdata/$user/— no API for writing raw userdata
Forbidden (cPanel APIs exist — always use them):
mysqlfor CREATE DATABASE, CREATE USER, GRANT — use UAPI Mysqlcrontab -u— use cpapi2 Croncpto/var/named/+rndc reload— use whmapi1 adddns/addzonerecordcpto/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()inlib/config.shreads KEY=VALUE lines via regex withoutsource/eval— prevents command injection from malicious config files - Password handling: SSH passwords passed via
sshpass -e(environment variableSSHPASS), never-p(visible in process list) - File permissions:
umask 077set at startup inbin/gniza4cp;install.shsets config dirs tochmod 700 - Safe rm:
${var:?}pattern preventsrm -rf ""/\*expansion on empty variables (SC2115) - Input validation:
validate_timestamp()andvalidate_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::Runnerrejects--accountand--pathvalues containing.. - Config file I/O:
Gniza4cpWHM::Config::save()usesflock(LOCK_EX)with single file handle (open+<then seek+truncate) to prevent TOCTOU races - Safe file I/O:
_safe_write()usesunlink+O_CREAT|O_EXCLwith 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_REMOTESconfig 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_EXCLwith 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::Open3as list (no shell interpolation)
SSH/Rsync (REMOTE_TYPE=ssh)
- All SSH operations go through
build_ssh_opts()/remote_exec()inssh.sh - rsync uses:
-aHAX --numeric-ids --delete --rsync-path="rsync --fake-super" --link-dest=<prev> --fake-superstores 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 whenREMOTE_TYPEiss3orgdrive- Each library function (snapshot, transfer, retention, verify, restore) checks
_is_rclone_modeat the top and dispatches torclone_*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 combovalidate_config()— LOG_LEVEL, NOTIFY_ON, SSH_TIMEOUT, SSH_RETRIES, RSYNC_EXTRA_OPTSvalidate_timestamp()— valid format, end-of-year, garbage, spaces/colons, empty stringvalidate_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
- Add to the appropriate
lib/<module>.sh - Functions are automatically available — libraries are sourced in
bin/gniza4cp - Use
localfor all variables,log_*for output,return 1for errors
Adding a new command
- Add
cmd_<name>()function inbin/gniza4cp - Add routing in
main()case statement - Update
cmd_usage()help text - Update
README.mdcommands table
Adding a new config variable
- Add
DEFAULT_<NAME>tolib/constants.sh - Add to
load_config()inlib/config.shwith fallback - Add validation in
validate_config()if needed - Add to
etc/gniza4cp.conf.example - Document in
README.mdand 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
- Create
whm/gniza4cp-whm/<name>.cgifollowing the pattern of existing CGIs - Use same boilerplate: shebang,
use lib,Whostmgr::HTMLInterface,Cpanel::Form,Gniza4cpWHM::UI - Route by
$form->{'action'}or similar param - Use
Gniza4cpWHM::UI::page_header(),render_nav(),render_flash(),csrf_hidden_field(),page_footer() - Validate POST with
verify_csrf_token(), redirect with 302 after success - No AppConfig change needed —
url=/cgi/gniza4cp-whm/covers all CGIs in the directory - Add any new DaisyUI/Tailwind classes to
assets/src/safelist.htmland rebuild CSS - Add the page to
@NAV_ITEMSinUI.pmif it should appear in the tab bar
Adding a new cPanel plugin page
- Create
cpanel/gniza4cp/<name>.live.cgi(note.live.cgiextension for Jupiter theme) - Use same boilerplate: shebang,
use libpointing to CGI lib dir,Cpanel::Form,Gniza4cpCPanel::UI - For privilege escalation, call AdminBin:
Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'ACTION', @args) - Use
Gniza4cpCPanel::UI::page_header(),csrf_hidden_field(),page_footer() - Validate POST with
verify_csrf_token(), redirect with 302 after success - For new AdminBin actions: add the action method to
cpanel/admin/Gniza4cp/Restoreand to_actions()list - Add the CGI copy command to
scripts/install.shin the cPanel section - CSS is shared with WHM — same
gniza4cp-whm.cssfile, 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 configassets/src/safelist.html— Class safelist (required because Tailwind v4 scanner doesn't recognize.cgi/.pmfile 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 inpage_header()— reads from disk, strips@layerwrappers, embeds inline - Tailwind v4 wraps all CSS in
@layerdirectives which have lower specificity than WHM's un-layered CSS —_unwrap_layers()strips these @import "tailwindcss" important;adds!importantto all utilities so they override WHM's styles
Adding new CSS classes:
- Add the class to
assets/src/safelist.html(since Tailwind can't scan.cgi/.pmfiles) - Rebuild:
cd whm/gniza4cp-whm/assets && npm run build:css - 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:
- Copy
bin/,lib/,etc/to/usr/local/gniza4cp/ - Create symlink
/usr/local/bin/gniza4cp→/usr/local/gniza4cp/bin/gniza4cp - Create working directory
/usr/local/gniza4cp/workdir - Create config directories
/etc/gniza4cp/remotes.d/and/etc/gniza4cp/schedules.d/(mode0700) - Copy example configs to
/etc/gniza4cp/ - Create log directory
/var/log/gniza4cp/ - If WHM detected: copy
whm/gniza4cp-whm/to CGI dir, register viaregister_appconfig - 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:
- Remove symlink and install directory
- Remove gniza4cp cron entries (lines matching
# gniza4cp:) - If WHM plugin exists: unregister via
unregister_appconfig, remove directory - If cPanel plugin exists: unregister via
uninstall_plugin, remove CGI directory and AdminBin module - 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/ |