From 1459bd1b8ba43ed01cb4de8889dd6b1e887ff64d Mon Sep 17 00:00:00 2001 From: shuki Date: Wed, 4 Mar 2026 02:39:39 +0200 Subject: [PATCH] Initial commit: gniza backup & disaster recovery CLI + WHM plugin Full-featured cPanel backup tool with SSH, S3, and Google Drive support. Includes WHM plugin with Tailwind/DaisyUI UI, multi-remote management, decoupled schedules, and account restore workflows. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + CLAUDE.md | 577 ++++++++++ LICENSE | 21 + README.md | 381 +++++++ bin/gniza | 1336 ++++++++++++++++++++++ etc/gniza.conf.example | 27 + etc/remote.conf.example | 40 + etc/schedule.conf.example | 17 + lib/accounts.sh | 62 + lib/config.sh | 58 + lib/constants.sh | 51 + lib/locking.sh | 28 + lib/logging.sh | 57 + lib/notify.sh | 81 ++ lib/pkgacct.sh | 74 ++ lib/rclone.sh | 335 ++++++ lib/remotes.sh | 271 +++++ lib/restore.sh | 1375 +++++++++++++++++++++++ lib/retention.sh | 52 + lib/schedule.sh | 294 +++++ lib/snapshot.sh | 110 ++ lib/ssh.sh | 76 ++ lib/transfer.sh | 172 +++ lib/utils.sh | 51 + lib/verify.sh | 132 +++ scripts/install.sh | 72 ++ scripts/uninstall.sh | 45 + tests/test_utils.sh | 149 +++ whm/gniza-whm.conf | 7 + whm/gniza-whm/assets/gniza-whm.css | 2 + whm/gniza-whm/assets/package-lock.json | 1001 +++++++++++++++++ whm/gniza-whm/assets/package.json | 12 + whm/gniza-whm/assets/src/input.css | 11 + whm/gniza-whm/assets/src/safelist.html | 2 + whm/gniza-whm/index.cgi | 86 ++ whm/gniza-whm/lib/GnizaWHM/Config.pm | 142 +++ whm/gniza-whm/lib/GnizaWHM/Cron.pm | 229 ++++ whm/gniza-whm/lib/GnizaWHM/Runner.pm | 127 +++ whm/gniza-whm/lib/GnizaWHM/UI.pm | 586 ++++++++++ whm/gniza-whm/lib/GnizaWHM/Validator.pm | 284 +++++ whm/gniza-whm/remotes.cgi | 741 ++++++++++++ whm/gniza-whm/restore.cgi | 1017 +++++++++++++++++ whm/gniza-whm/schedules.cgi | 511 +++++++++ whm/gniza-whm/settings.cgi | 171 +++ whm/gniza-whm/setup.cgi | 523 +++++++++ 45 files changed, 11397 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/gniza create mode 100644 etc/gniza.conf.example create mode 100644 etc/remote.conf.example create mode 100644 etc/schedule.conf.example create mode 100644 lib/accounts.sh create mode 100644 lib/config.sh create mode 100644 lib/constants.sh create mode 100644 lib/locking.sh create mode 100644 lib/logging.sh create mode 100644 lib/notify.sh create mode 100644 lib/pkgacct.sh create mode 100644 lib/rclone.sh create mode 100644 lib/remotes.sh create mode 100644 lib/restore.sh create mode 100644 lib/retention.sh create mode 100644 lib/schedule.sh create mode 100644 lib/snapshot.sh create mode 100644 lib/ssh.sh create mode 100644 lib/transfer.sh create mode 100644 lib/utils.sh create mode 100644 lib/verify.sh create mode 100755 scripts/install.sh create mode 100755 scripts/uninstall.sh create mode 100755 tests/test_utils.sh create mode 100644 whm/gniza-whm.conf create mode 100644 whm/gniza-whm/assets/gniza-whm.css create mode 100644 whm/gniza-whm/assets/package-lock.json create mode 100644 whm/gniza-whm/assets/package.json create mode 100644 whm/gniza-whm/assets/src/input.css create mode 100644 whm/gniza-whm/assets/src/safelist.html create mode 100644 whm/gniza-whm/index.cgi create mode 100644 whm/gniza-whm/lib/GnizaWHM/Config.pm create mode 100644 whm/gniza-whm/lib/GnizaWHM/Cron.pm create mode 100644 whm/gniza-whm/lib/GnizaWHM/Runner.pm create mode 100644 whm/gniza-whm/lib/GnizaWHM/UI.pm create mode 100644 whm/gniza-whm/lib/GnizaWHM/Validator.pm create mode 100644 whm/gniza-whm/remotes.cgi create mode 100644 whm/gniza-whm/restore.cgi create mode 100644 whm/gniza-whm/schedules.cgi create mode 100644 whm/gniza-whm/settings.cgi create mode 100644 whm/gniza-whm/setup.cgi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b17e1a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +whm/gniza-whm/assets/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..23fdafb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,577 @@ +# agents.md — gniza Development Guide + +> Reference for AI coding agents working on gniza. Describes architecture, conventions, and key patterns. + +## Project Overview + +gniza 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/gniza/` with symlink at `/usr/local/bin/gniza` + +## Repository Structure + +``` +bin/gniza # 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() +├── logging.sh # Per-run log files (LOG_FILE), log_info/warn/error/debug +├── config.sh # 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(), 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/ +├── gniza.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/gniza, create dirs/symlinks +└── uninstall.sh # Remove install dir and symlink +tests/ +└── test_utils.sh # Unit tests for utils.sh, accounts.sh, config.sh +whm/ +├── gniza-whm.conf # WHM AppConfig registration +└── gniza-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/ + │ ├── gniza-whm.css # Built Tailwind/DaisyUI CSS (committed, ~58KB) + │ └── 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/GnizaWHM/ + ├── Config.pm # Pure Perl config parser/writer (KEY="value" files) + ├── Validator.pm # Input validation (mirrors lib/config.sh) + ├── Cron.pm # Cron read + allowlisted gniza schedule commands + ├── Runner.pm # Pattern-based safe CLI command runner for WHM + └── UI.pm # Nav, flash, CSRF, HTML escaping, CSS delivery +``` + +## 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/gniza/remotes.d/.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: + +```bash +_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() +├── 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/gniza` 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`, `init`, `version`, `help` + +### Config Hierarchy + +1. `lib/constants.sh` — `DEFAULT_*` readonly values +2. `/etc/gniza/gniza.conf` — main config: local settings only (accounts, logging, notifications) +3. `/etc/gniza/remotes.d/.conf` — per-remote config (REMOTE_*, retention, transfer) +4. `/etc/gniza/schedules.d/.conf` — per-schedule config (timing, target remotes) +5. CLI flags (`--debug`, `--config=PATH`) + +### Snapshot Layout + +- Remote path: `$REMOTE_BASE//accounts//snapshots/` +- Timestamp format: `YYYY-MM-DDTHHMMSS` (UTC) +- pkgacct content lives directly in the snapshot root (no `pkgacct/` wrapper) +- `homedir/` subdirectory sits alongside the pkgacct content + +``` +/ +├── 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 `/` (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/gniza/schedules.d/.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 `# gniza:` comment lines. `install_schedules()` strips old tagged lines and appends new ones. Format: + +``` +# gniza:nightly +0 2 * * * /usr/local/bin/gniza backup --remote=nas,offsite >> /var/log/gniza/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. + +## 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 "${_GNIZA_CONSTANTS_LOADED:-}" ]] && return 0` +- `((count++)) || true` to avoid `set -e` traps on zero-to-one arithmetic + +### Naming + +- Libraries: `lib/.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_()` in `bin/gniza` +- 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/gniza.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 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 + +### 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=` +- `--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}//accounts/...` +- GDrive path: `remote:${REMOTE_BASE}//accounts/...` +- Bandwidth limiting: `--bwlimit=${BWLIMIT}k` +- Retries with exponential backoff mirror rsync behavior + +## Configuration Files + +### Main Config (`/etc/gniza/gniza.conf`) + +Contains only local settings. Remote destinations are configured in `remotes.d/`. + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `TEMP_DIR` | No | `/usr/local/gniza/workdir` | Local working directory | +| `INCLUDE_ACCOUNTS` | No | (all) | Comma-separated account list | +| `EXCLUDE_ACCOUNTS` | No | `nobody` | Comma-separated exclusions | +| `LOG_DIR` | No | `/var/log/gniza` | 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/gniza.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 | + +### Remote Config (`/etc/gniza/remotes.d/.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/gniza/schedules.d/.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 | + +## 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/gniza/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 gniza command, `--remote=` flag, and log redirect | +| `install_schedules()` | Strip old gniza cron entries, add new from all `schedules.d/` | +| `show_schedules()` | Display current gniza cron entries | +| `remove_schedules()` | Remove all gniza 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)` | Full account restore from snapshot | +| `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/gniza (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) | +| `_init_remote(name)` | Interactive remote destination setup | +| `cmd_remote()` | Remote management: list, delete | +| `cmd_schedule()` | Schedule CRUD: add, delete, list, install, show, remove | + +### GnizaWHM::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/gniza/remotes.d/` has `.conf` files | +| `list_remotes()` | Return sorted list of remote names | +| `has_schedules()` | Check if `/etc/gniza/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_rclone_connection(%args)` | Test S3/GDrive connection via rclone (generates temp config, runs `rclone lsd`) | + +### GnizaWHM::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 gniza CLI | + +Allowed commands: `restore account/files/database/mailbox/list-databases/list-mailboxes`, `list`. +Named option patterns: `--remote`, `--timestamp`, `--path`, `--account`. + +### GnizaWHM::Config + +| Array | Description | +|-------|-------------| +| `@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) | + +### GnizaWHM::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 +bash tests/test_utils.sh +``` + +Tests cover: `timestamp()` format, `human_size()`, `human_duration()`, `require_cmd()`, `filter_accounts()`, `validate_config()`. + +Tests use a simple `assert_eq`/`assert_ok`/`assert_fail` framework defined in `test_utils.sh`. + +## Production + +| Server | Host | SSH Port | Hostname | +|--------|------|----------|----------| +| Production (cPanel) | `192.168.100.13` | `2223` | `181-79-81-251.cprapid.com` | + +## Common Tasks + +### Adding a new library function + +1. Add to the appropriate `lib/.sh` +2. Functions are automatically available — libraries are sourced in `bin/gniza` +3. Use `local` for all variables, `log_*` for output, `return 1` for errors + +### Adding a new command + +1. Add `cmd_()` function in `bin/gniza` +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_` 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/gniza.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: + +```bash +_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/gniza-whm/.cgi` following the pattern of existing CGIs +2. Use same boilerplate: shebang, `use lib`, `Whostmgr::HTMLInterface`, `Cpanel::Form`, `GnizaWHM::UI` +3. Route by `$form->{'action'}` or similar param +4. Use `GnizaWHM::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/gniza-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 + +### 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 `\n} + . qq{
\n} + . qq{

$title

\n}; +} + +sub _unwrap_layers { + my ($css) = @_; + # Loop until all @layer wrappers are removed (handles nesting) + while ($css =~ /\@layer\s/) { + # Remove @layer order declarations: @layer components; @layer theme, base; + $css =~ s/\@layer\s+[\w.,\s]+\s*;//g; + # Unwrap @layer name { ... } blocks, keeping inner contents + my $out = ''; + my $i = 0; + my $len = length($css); + while ($i < $len) { + if (substr($css, $i, 6) eq '@layer') { + my $brace = index($css, '{', $i); + if ($brace == -1) { $out .= substr($css, $i); last; } + my $semi = index($css, ';', $i); + if ($semi != -1 && $semi < $brace) { + $i = $semi + 1; + next; + } + my $depth = 1; + my $j = $brace + 1; + while ($j < $len && $depth > 0) { + my $c = substr($css, $j, 1); + $depth++ if $c eq '{'; + $depth-- if $c eq '}'; + $j++; + } + $out .= substr($css, $brace + 1, $j - $brace - 2); + $i = $j; + } else { + $out .= substr($css, $i, 1); + $i++; + } + } + $css = $out; + } + return $css; +} + +sub _scope_to_container { + my ($css) = @_; + # Step 1: Replace :root/:host with & so CSS variables and base styles + # attach to our container element (not lost as descendant selectors). + $css =~ s/:root,\s*:host/\&/g; + $css =~ s/:where\(:root,\s*\[data-theme[^\]]*\]\)/\&/g; + $css =~ s/:where\(:root\)/\&/g; + $css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g; + $css =~ s/\[data-theme=light\]/\&/g; + $css =~ s/:root:not\(span\)/\&/g; + $css =~ s/:root:has\(/\&:has(/g; + $css =~ s/:root\b/\&/g; + + # Step 2: Extract @keyframes and @property to keep at top level + my @top_level; + my $scoped = ''; + my $i = 0; + my $len = length($css); + while ($i < $len) { + if (substr($css, $i, 1) eq '@') { + if (substr($css, $i, 11) eq '@keyframes ' + || substr($css, $i, 10) eq '@property ') { + my $brace = index($css, '{', $i); + if ($brace == -1) { $scoped .= substr($css, $i); last; } + my $depth = 1; + my $j = $brace + 1; + while ($j < $len && $depth > 0) { + my $c = substr($css, $j, 1); + $depth++ if $c eq '{'; + $depth-- if $c eq '}'; + $j++; + } + push @top_level, substr($css, $i, $j - $i); + $i = $j; + next; + } + } + $scoped .= substr($css, $i, 1); + $i++; + } + + # Step 3: Wrap in container scope — & references resolve to this selector + return join('', @top_level) . '[data-theme="light"]{' . $scoped . '}'; +} + +sub page_footer { + return "
\n"; +} + +sub render_errors { + my ($errors) = @_; + return '' unless $errors && @$errors; + my $html = qq{
\n
    \n}; + for my $err (@$errors) { + $html .= '
  • ' . esc($err) . "
  • \n"; + } + $html .= "
\n
\n"; + return $html; +} + +1; diff --git a/whm/gniza-whm/lib/GnizaWHM/Validator.pm b/whm/gniza-whm/lib/GnizaWHM/Validator.pm new file mode 100644 index 0000000..6392c7e --- /dev/null +++ b/whm/gniza-whm/lib/GnizaWHM/Validator.pm @@ -0,0 +1,284 @@ +package GnizaWHM::Validator; +# Input validation mirroring lib/config.sh validate_config() +# and lib/remotes.sh validate_remote(). + +use strict; +use warnings; + +# validate_main_config(\%data, $has_remotes) +# Returns arrayref of error strings (empty = valid). +sub validate_main_config { + my ($data) = @_; + my @errors; + + if (defined $data->{NOTIFY_ON} && $data->{NOTIFY_ON} ne '') { + unless ($data->{NOTIFY_ON} =~ /^(always|failure|never)$/) { + push @errors, 'NOTIFY_ON must be always, failure, or never'; + } + } + + if (defined $data->{LOG_LEVEL} && $data->{LOG_LEVEL} ne '') { + unless ($data->{LOG_LEVEL} =~ /^(debug|info|warn|error)$/) { + push @errors, 'LOG_LEVEL must be debug, info, warn, or error'; + } + } + + if (defined $data->{LOG_RETAIN} && $data->{LOG_RETAIN} ne '') { + push @errors, _validate_positive_int('LOG_RETAIN', $data->{LOG_RETAIN}); + } + + if (defined $data->{SSH_TIMEOUT} && $data->{SSH_TIMEOUT} ne '') { + push @errors, _validate_non_negative_int('SSH_TIMEOUT', $data->{SSH_TIMEOUT}); + } + + if (defined $data->{SSH_RETRIES} && $data->{SSH_RETRIES} ne '') { + push @errors, _validate_positive_int('SSH_RETRIES', $data->{SSH_RETRIES}); + } + + if (defined $data->{INCLUDE_ACCOUNTS} && $data->{INCLUDE_ACCOUNTS} ne '') { + if ($data->{INCLUDE_ACCOUNTS} !~ /^[a-zA-Z0-9_, ]*$/) { + push @errors, 'INCLUDE_ACCOUNTS contains invalid characters'; + } + } + + if (defined $data->{EXCLUDE_ACCOUNTS} && $data->{EXCLUDE_ACCOUNTS} ne '') { + if ($data->{EXCLUDE_ACCOUNTS} !~ /^[a-zA-Z0-9_, ]*$/) { + push @errors, 'EXCLUDE_ACCOUNTS contains invalid characters'; + } + } + + if (defined $data->{RSYNC_EXTRA_OPTS} && $data->{RSYNC_EXTRA_OPTS} ne '') { + if ($data->{RSYNC_EXTRA_OPTS} !~ /^[a-zA-Z0-9 ._=\/-]*$/) { + push @errors, 'RSYNC_EXTRA_OPTS contains invalid characters'; + } + } + + if (defined $data->{TEMP_DIR} && $data->{TEMP_DIR} ne '') { + if ($data->{TEMP_DIR} !~ /^\/[\w\/.+-]*$/) { + push @errors, 'TEMP_DIR must be an absolute path'; + } + } + + if (defined $data->{LOG_DIR} && $data->{LOG_DIR} ne '') { + if ($data->{LOG_DIR} !~ /^\/[\w\/.+-]*$/) { + push @errors, 'LOG_DIR must be an absolute path'; + } + } + + if (defined $data->{LOCK_FILE} && $data->{LOCK_FILE} ne '') { + if ($data->{LOCK_FILE} !~ /^\/[\w\/.+-]*$/) { + push @errors, 'LOCK_FILE must be an absolute path'; + } + } + + # Filter out empty strings from helper returns + return [grep { $_ ne '' } @errors]; +} + +# validate_remote_config(\%data) +# Returns arrayref of error strings (empty = valid). +sub validate_remote_config { + my ($data) = @_; + my @errors; + + my $type = $data->{REMOTE_TYPE} // 'ssh'; + unless ($type =~ /^(ssh|s3|gdrive)$/) { + push @errors, 'REMOTE_TYPE must be ssh, s3, or gdrive'; + return [grep { $_ ne '' } @errors]; + } + + # Common validations + push @errors, _validate_positive_int('RETENTION_COUNT', $data->{RETENTION_COUNT}); + push @errors, _validate_non_negative_int('BWLIMIT', $data->{BWLIMIT}); + + if (defined $data->{REMOTE_BASE} && $data->{REMOTE_BASE} ne '') { + if ($data->{REMOTE_BASE} !~ /^\/[\w\/.+-]*$/) { + push @errors, 'REMOTE_BASE must be an absolute path'; + } + } + + if ($type eq 'ssh') { + # SSH-specific validation + if (!defined $data->{REMOTE_HOST} || $data->{REMOTE_HOST} eq '') { + push @errors, 'REMOTE_HOST is required'; + } elsif ($data->{REMOTE_HOST} !~ /^[a-zA-Z0-9._-]+$/) { + push @errors, 'REMOTE_HOST contains invalid characters'; + } + + my $auth_method = $data->{REMOTE_AUTH_METHOD} // 'key'; + if ($auth_method ne 'key' && $auth_method ne 'password') { + push @errors, 'REMOTE_AUTH_METHOD must be key or password'; + } + + if ($auth_method eq 'password') { + if (!defined $data->{REMOTE_PASSWORD} || $data->{REMOTE_PASSWORD} eq '') { + push @errors, 'REMOTE_PASSWORD is required for password authentication'; + } + } else { + if (!defined $data->{REMOTE_KEY} || $data->{REMOTE_KEY} eq '') { + push @errors, 'REMOTE_KEY is required'; + } elsif ($data->{REMOTE_KEY} !~ /^\/[\w\/.+-]+$/) { + push @errors, 'REMOTE_KEY must be an absolute path'; + } elsif (!-f $data->{REMOTE_KEY}) { + push @errors, "REMOTE_KEY file not found: $data->{REMOTE_KEY}"; + } + } + + push @errors, _validate_port($data->{REMOTE_PORT}); + + if (defined $data->{REMOTE_USER} && $data->{REMOTE_USER} ne '') { + if ($data->{REMOTE_USER} !~ /^[a-z_][a-z0-9_-]*$/) { + push @errors, 'REMOTE_USER contains invalid characters'; + } + } + + if (defined $data->{RSYNC_EXTRA_OPTS} && $data->{RSYNC_EXTRA_OPTS} ne '') { + if ($data->{RSYNC_EXTRA_OPTS} !~ /^[a-zA-Z0-9 ._=\/-]*$/) { + push @errors, 'RSYNC_EXTRA_OPTS contains invalid characters'; + } + } + } + elsif ($type eq 's3') { + if (!defined $data->{S3_ACCESS_KEY_ID} || $data->{S3_ACCESS_KEY_ID} eq '') { + push @errors, 'S3_ACCESS_KEY_ID is required'; + } + if (!defined $data->{S3_SECRET_ACCESS_KEY} || $data->{S3_SECRET_ACCESS_KEY} eq '') { + push @errors, 'S3_SECRET_ACCESS_KEY is required'; + } + if (!defined $data->{S3_BUCKET} || $data->{S3_BUCKET} eq '') { + push @errors, 'S3_BUCKET is required'; + } elsif ($data->{S3_BUCKET} !~ /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/) { + push @errors, 'S3_BUCKET contains invalid characters'; + } + if (defined $data->{S3_REGION} && $data->{S3_REGION} ne '') { + if ($data->{S3_REGION} !~ /^[a-z0-9-]+$/) { + push @errors, 'S3_REGION contains invalid characters'; + } + } + } + elsif ($type eq 'gdrive') { + if (!defined $data->{GDRIVE_SERVICE_ACCOUNT_FILE} || $data->{GDRIVE_SERVICE_ACCOUNT_FILE} eq '') { + push @errors, 'GDRIVE_SERVICE_ACCOUNT_FILE is required'; + } elsif ($data->{GDRIVE_SERVICE_ACCOUNT_FILE} !~ /^\/[\w\/.+-]+$/) { + push @errors, 'GDRIVE_SERVICE_ACCOUNT_FILE must be an absolute path'; + } elsif (!-f $data->{GDRIVE_SERVICE_ACCOUNT_FILE}) { + push @errors, "GDRIVE_SERVICE_ACCOUNT_FILE not found: $data->{GDRIVE_SERVICE_ACCOUNT_FILE}"; + } + } + + return [grep { $_ ne '' } @errors]; +} + +# validate_remote_name($name) +# Returns error string or empty string if valid. +sub validate_remote_name { + my ($name) = @_; + if (!defined $name || $name eq '') { + return 'Remote name is required'; + } + if ($name !~ /^[a-zA-Z0-9_-]+$/) { + return 'Remote name may only contain letters, digits, hyphens, and underscores'; + } + if (length($name) > 64) { + return 'Remote name is too long (max 64 characters)'; + } + return ''; +} + +# validate_schedule_config(\%data) +# Returns arrayref of error strings (empty = valid). +sub validate_schedule_config { + my ($data) = @_; + my @errors; + + my $schedule = $data->{SCHEDULE} // ''; + if ($schedule eq '') { + push @errors, 'SCHEDULE is required'; + } elsif ($schedule !~ /^(hourly|daily|weekly|monthly|custom)$/) { + push @errors, 'SCHEDULE must be hourly, daily, weekly, monthly, or custom'; + } + + my $stime = $data->{SCHEDULE_TIME} // ''; + if ($stime ne '' && $stime !~ /^([01]\d|2[0-3]):[0-5]\d$/) { + push @errors, 'SCHEDULE_TIME must be HH:MM (24-hour format)'; + } + + if ($schedule eq 'hourly') { + my $sday = $data->{SCHEDULE_DAY} // ''; + if ($sday ne '' && ($sday !~ /^\d+$/ || $sday < 1 || $sday > 23)) { + push @errors, 'SCHEDULE_DAY must be 1-23 (hours between backups) for hourly schedule'; + } + } elsif ($schedule eq 'weekly') { + my $sday = $data->{SCHEDULE_DAY} // ''; + if ($sday eq '' || $sday !~ /^[0-6]$/) { + push @errors, 'SCHEDULE_DAY must be 0-6 for weekly schedule'; + } + } elsif ($schedule eq 'monthly') { + my $sday = $data->{SCHEDULE_DAY} // ''; + if ($sday eq '' || $sday !~ /^\d+$/ || $sday < 1 || $sday > 28) { + push @errors, 'SCHEDULE_DAY must be 1-28 for monthly schedule'; + } + } elsif ($schedule eq 'custom') { + my $scron = $data->{SCHEDULE_CRON} // ''; + if ($scron eq '' || $scron !~ /^[\d*,\/-]+(\s+[\d*,\/-]+){4}$/) { + push @errors, 'SCHEDULE_CRON must be a valid 5-field cron expression'; + } + } + + my $remotes = $data->{REMOTES} // ''; + if ($remotes ne '' && $remotes !~ /^[a-zA-Z0-9_,-]+$/) { + push @errors, 'REMOTES must be comma-separated remote names (letters, digits, hyphens, underscores)'; + } + + return [grep { $_ ne '' } @errors]; +} + +# validate_schedule_name($name) +# Returns error string or empty string if valid. +sub validate_schedule_name { + my ($name) = @_; + if (!defined $name || $name eq '') { + return 'Schedule name is required'; + } + if ($name !~ /^[a-zA-Z0-9_-]+$/) { + return 'Schedule name may only contain letters, digits, hyphens, and underscores'; + } + if (length($name) > 64) { + return 'Schedule name is too long (max 64 characters)'; + } + return ''; +} + +# -- Private helpers -- + +sub _validate_port { + my ($val) = @_; + $val //= ''; + return '' if $val eq ''; + if ($val !~ /^\d+$/ || $val < 1 || $val > 65535) { + return 'REMOTE_PORT must be 1-65535'; + } + return ''; +} + +sub _validate_positive_int { + my ($name, $val) = @_; + $val //= ''; + return '' if $val eq ''; + if ($val !~ /^\d+$/ || $val < 1) { + return "$name must be a positive integer"; + } + return ''; +} + +sub _validate_non_negative_int { + my ($name, $val) = @_; + $val //= ''; + return '' if $val eq ''; + if ($val !~ /^\d+$/) { + return "$name must be a non-negative integer"; + } + return ''; +} + +1; diff --git a/whm/gniza-whm/remotes.cgi b/whm/gniza-whm/remotes.cgi new file mode 100644 index 0000000..4ae2b7c --- /dev/null +++ b/whm/gniza-whm/remotes.cgi @@ -0,0 +1,741 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza WHM Plugin — Remote Destination CRUD +use strict; +use warnings; + +use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib'; + +use Whostmgr::HTMLInterface (); +use Cpanel::Form (); +use File::Copy (); +use GnizaWHM::Config; +use GnizaWHM::Validator; +use GnizaWHM::UI; + +my $form = Cpanel::Form::parseform(); +my $method = $ENV{'REQUEST_METHOD'} // 'GET'; +my $action = $form->{'action'} // 'list'; + +# Route to handler +if ($action eq 'test') { handle_test_connection() } +elsif ($action eq 'add') { handle_add() } +elsif ($action eq 'edit') { handle_edit() } +elsif ($action eq 'delete') { handle_delete() } +else { handle_list() } + +exit; + +# ── Test Connection (JSON) ──────────────────────────────────── + +sub handle_test_connection { + print "Content-Type: application/json\r\n\r\n"; + + my $type = $form->{'remote_type'} || 'ssh'; + + if ($type eq 'ssh') { + my $host = $form->{'host'} // ''; + my $port = $form->{'port'} || '22'; + my $user = $form->{'user'} || 'root'; + my $auth_method = $form->{'auth_method'} || 'key'; + my $key = $form->{'key'} // ''; + my $password = $form->{'password'} // ''; + + if ($host eq '') { + print qq({"success":false,"message":"Host is required."}); + exit; + } + + if ($auth_method eq 'password') { + if ($password eq '') { + print qq({"success":false,"message":"Password is required."}); + exit; + } + } else { + if ($key eq '') { + print qq({"success":false,"message":"SSH key path is required."}); + exit; + } + } + + my ($ok, $err) = GnizaWHM::UI::test_ssh_connection( + host => $host, + port => $port, + user => $user, + auth_method => $auth_method, + key => $key, + password => $password, + ); + if ($ok) { + print qq({"success":true,"message":"SSH connection successful."}); + } else { + $err //= 'Unknown error'; + $err =~ s/\\/\\\\/g; + $err =~ s/"/\\"/g; + $err =~ s/\n/\\n/g; + print qq({"success":false,"message":"SSH connection failed: $err"}); + } + } + elsif ($type eq 's3' || $type eq 'gdrive') { + my %rclone_args = (type => $type); + if ($type eq 's3') { + $rclone_args{s3_access_key_id} = $form->{'S3_ACCESS_KEY_ID'} // ''; + $rclone_args{s3_secret_access_key} = $form->{'S3_SECRET_ACCESS_KEY'} // ''; + $rclone_args{s3_region} = $form->{'S3_REGION'} || 'us-east-1'; + $rclone_args{s3_endpoint} = $form->{'S3_ENDPOINT'} // ''; + $rclone_args{s3_bucket} = $form->{'S3_BUCKET'} // ''; + + if ($rclone_args{s3_access_key_id} eq '' || $rclone_args{s3_secret_access_key} eq '') { + print qq({"success":false,"message":"S3 access key and secret are required."}); + exit; + } + if ($rclone_args{s3_bucket} eq '') { + print qq({"success":false,"message":"S3 bucket is required."}); + exit; + } + } else { + $rclone_args{gdrive_service_account_file} = $form->{'GDRIVE_SERVICE_ACCOUNT_FILE'} // ''; + $rclone_args{gdrive_root_folder_id} = $form->{'GDRIVE_ROOT_FOLDER_ID'} // ''; + + if ($rclone_args{gdrive_service_account_file} eq '') { + print qq({"success":false,"message":"Service account file path is required."}); + exit; + } + } + + my ($ok, $err) = GnizaWHM::UI::test_rclone_connection(%rclone_args); + if ($ok) { + my $label = $type eq 's3' ? 'S3' : 'Google Drive'; + print qq({"success":true,"message":"$label connection successful."}); + } else { + $err //= 'Unknown error'; + $err =~ s/\\/\\\\/g; + $err =~ s/"/\\"/g; + $err =~ s/\n/\\n/g; + print qq({"success":false,"message":"Connection failed: $err"}); + } + } + else { + print qq({"success":false,"message":"Unknown remote type."}); + } + exit; +} + +# ── List ───────────────────────────────────────────────────── + +sub handle_list { + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Remotes', '', '/cgi/gniza-whm/remotes.cgi'); + + print GnizaWHM::UI::page_header('Remote Destinations'); + print GnizaWHM::UI::render_nav('remotes.cgi'); + print GnizaWHM::UI::render_flash(); + + my @remotes = GnizaWHM::UI::list_remotes(); + + print qq{
\n
\n}; + + if (@remotes) { + print qq{
\n}; + print qq{\n}; + print qq{\n}; + for my $name (@remotes) { + my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::remote_conf_path($name), 'remote'); + my $esc_name = GnizaWHM::UI::esc($name); + my $type = $conf->{REMOTE_TYPE} // 'ssh'; + my $retention = GnizaWHM::UI::esc($conf->{RETENTION_COUNT} // '30'); + + my ($type_label, $dest); + if ($type eq 's3') { + $type_label = 'S3'; + $dest = 's3://' . GnizaWHM::UI::esc($conf->{S3_BUCKET} // ''); + } elsif ($type eq 'gdrive') { + $type_label = 'GDrive'; + my $sa = $conf->{GDRIVE_SERVICE_ACCOUNT_FILE} // ''; + $sa =~ s{.*/}{}; + $dest = 'gdrive:' . GnizaWHM::UI::esc($sa); + } else { + $type_label = 'SSH'; + my $host = GnizaWHM::UI::esc($conf->{REMOTE_HOST} // ''); + my $port = GnizaWHM::UI::esc($conf->{REMOTE_PORT} // '22'); + $dest = "$host:$port"; + } + + print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{\n}; + } + print qq{\n
NameTypeDestinationRetentionActions
$esc_name$type_label$dest$retention}; + print qq{
}; + print qq{Edit}; + print qq{
}; + print qq{}; + print qq{}; + print GnizaWHM::UI::csrf_hidden_field(); + print qq{}; + print qq{
}; + print qq{
}; + print qq{
\n}; + } else { + print qq{

No remote destinations configured. Add a remote to enable multi-remote backups.

\n}; + print qq{

Remote configs are stored in /etc/gniza/remotes.d/.

\n}; + } + + print qq{
\n
\n}; + + print qq{
\n}; + print qq{ Add Remote\n}; + print qq{
\n}; + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Add ────────────────────────────────────────────────────── + +sub handle_add { + my @errors; + + if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + my $name = $form->{'remote_name'} // ''; + my $name_err = GnizaWHM::Validator::validate_remote_name($name); + push @errors, $name_err if $name_err; + + if (!@errors && -f GnizaWHM::UI::remote_conf_path($name)) { + push @errors, "A remote named '$name' already exists."; + } + + my %data; + for my $key (@GnizaWHM::Config::REMOTE_KEYS) { + $data{$key} = $form->{$key} // ''; + } + + if (!@errors) { + my $validation_errors = GnizaWHM::Validator::validate_remote_config(\%data); + push @errors, @$validation_errors; + } + + if (!@errors) { + my $type = $data{REMOTE_TYPE} || 'ssh'; + my ($conn_ok, $conn_err); + + if ($type eq 'ssh') { + ($conn_ok, $conn_err) = GnizaWHM::UI::test_ssh_connection( + host => $data{REMOTE_HOST}, + port => $data{REMOTE_PORT} || '22', + user => $data{REMOTE_USER} || 'root', + auth_method => $data{REMOTE_AUTH_METHOD} || 'key', + key => $data{REMOTE_KEY}, + password => $data{REMOTE_PASSWORD}, + ); + } else { + my %rclone_args = (type => $type); + if ($type eq 's3') { + $rclone_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID}; + $rclone_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY}; + $rclone_args{s3_region} = $data{S3_REGION} || 'us-east-1'; + $rclone_args{s3_endpoint} = $data{S3_ENDPOINT}; + $rclone_args{s3_bucket} = $data{S3_BUCKET}; + } else { + $rclone_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE}; + $rclone_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID}; + } + ($conn_ok, $conn_err) = GnizaWHM::UI::test_rclone_connection(%rclone_args); + } + push @errors, "Connection test failed: $conn_err" unless $conn_ok; + } + + if (!@errors) { + # Copy example template then write values + my $dest = GnizaWHM::UI::remote_conf_path($name); + my $example = GnizaWHM::UI::remote_example_path(); + if (-f $example) { + File::Copy::copy($example, $dest) + or do { push @errors, "Failed to create remote file: $!"; goto RENDER_ADD; }; + } + my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::REMOTE_KEYS); + if ($ok) { + GnizaWHM::UI::set_flash('success', "Remote '$name' created successfully."); + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } else { + push @errors, "Failed to save remote config: $err"; + } + } + } + + RENDER_ADD: + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Add Remote', '', '/cgi/gniza-whm/remotes.cgi'); + + print GnizaWHM::UI::page_header('Add Remote Destination'); + print GnizaWHM::UI::render_nav('remotes.cgi'); + + if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); + } + + # Pre-populate from POST if validation failed, else empty + my $conf = {}; + if ($method eq 'POST') { + for my $key (@GnizaWHM::Config::REMOTE_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } + } + my $name_val = GnizaWHM::UI::esc($form->{'remote_name'} // ''); + + render_remote_form($conf, $name_val, 0); + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Edit ───────────────────────────────────────────────────── + +sub handle_edit { + my $name = $form->{'name'} // ''; + my @errors; + + # Validate name + my $name_err = GnizaWHM::Validator::validate_remote_name($name); + if ($name_err) { + GnizaWHM::UI::set_flash('error', "Invalid remote name."); + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } + + my $conf_path = GnizaWHM::UI::remote_conf_path($name); + unless (-f $conf_path) { + GnizaWHM::UI::set_flash('error', "Remote '$name' not found."); + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } + + if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + my %data; + for my $key (@GnizaWHM::Config::REMOTE_KEYS) { + $data{$key} = $form->{$key} // ''; + } + + if (!@errors) { + my $validation_errors = GnizaWHM::Validator::validate_remote_config(\%data); + push @errors, @$validation_errors; + } + + if (!@errors) { + my $type = $data{REMOTE_TYPE} || 'ssh'; + my ($conn_ok, $conn_err); + + if ($type eq 'ssh') { + ($conn_ok, $conn_err) = GnizaWHM::UI::test_ssh_connection( + host => $data{REMOTE_HOST}, + port => $data{REMOTE_PORT} || '22', + user => $data{REMOTE_USER} || 'root', + auth_method => $data{REMOTE_AUTH_METHOD} || 'key', + key => $data{REMOTE_KEY}, + password => $data{REMOTE_PASSWORD}, + ); + } else { + my %rclone_args = (type => $type); + if ($type eq 's3') { + $rclone_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID}; + $rclone_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY}; + $rclone_args{s3_region} = $data{S3_REGION} || 'us-east-1'; + $rclone_args{s3_endpoint} = $data{S3_ENDPOINT}; + $rclone_args{s3_bucket} = $data{S3_BUCKET}; + } else { + $rclone_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE}; + $rclone_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID}; + } + ($conn_ok, $conn_err) = GnizaWHM::UI::test_rclone_connection(%rclone_args); + } + push @errors, "Connection test failed: $conn_err" unless $conn_ok; + } + + if (!@errors) { + my ($ok, $err) = GnizaWHM::Config::write($conf_path, \%data, \@GnizaWHM::Config::REMOTE_KEYS); + if ($ok) { + GnizaWHM::UI::set_flash('success', "Remote '$name' updated successfully."); + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } else { + push @errors, "Failed to save remote config: $err"; + } + } + } + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Edit Remote', '', '/cgi/gniza-whm/remotes.cgi'); + + print GnizaWHM::UI::page_header("Edit Remote: " . GnizaWHM::UI::esc($name)); + print GnizaWHM::UI::render_nav('remotes.cgi'); + + if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); + } + + # Load config (or re-use POST data on error) + my $conf; + if (@errors && $method eq 'POST') { + $conf = {}; + for my $key (@GnizaWHM::Config::REMOTE_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } + } else { + $conf = GnizaWHM::Config::parse($conf_path, 'remote'); + } + + render_remote_form($conf, GnizaWHM::UI::esc($name), 1); + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Delete ─────────────────────────────────────────────────── + +sub handle_delete { + if ($method ne 'POST') { + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } + + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.'); + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } + + my $name = $form->{'name'} // ''; + my $name_err = GnizaWHM::Validator::validate_remote_name($name); + if ($name_err) { + GnizaWHM::UI::set_flash('error', 'Invalid remote name.'); + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; + } + + my $conf_path = GnizaWHM::UI::remote_conf_path($name); + if (-f $conf_path) { + unlink $conf_path; + GnizaWHM::UI::set_flash('success', "Remote '$name' deleted."); + } else { + GnizaWHM::UI::set_flash('error', "Remote '$name' not found."); + } + + print "Status: 302 Found\r\n"; + print "Location: remotes.cgi\r\n\r\n"; + exit; +} + +# ── Shared Form Renderer ──────────────────────────────────── + +sub render_remote_form { + my ($conf, $name_val, $is_edit) = @_; + + my $action_val = $is_edit ? 'edit' : 'add'; + my $remote_type = $conf->{REMOTE_TYPE} // 'ssh'; + + print qq{
\n}; + print qq{\n}; + print GnizaWHM::UI::csrf_hidden_field(); + + if ($is_edit) { + print qq{\n}; + } + + # Remote name + print qq{
\n
\n}; + print qq{

Remote Identity

\n}; + print qq{
\n}; + print qq{ \n}; + if ($is_edit) { + print qq{ \n}; + } else { + print qq{ \n}; + print qq{ Letters, digits, hyphens, underscores\n}; + } + print qq{
\n}; + + # Remote type selector + my $ssh_checked = ($remote_type eq 'ssh') ? ' checked' : ''; + my $s3_checked = ($remote_type eq 's3') ? ' checked' : ''; + my $gdrive_checked = ($remote_type eq 'gdrive') ? ' checked' : ''; + + print qq{
\n}; + print qq{ \n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{
\n}; + + print qq{
\n
\n}; + + # ── SSH fields ──────────────────────────────────────────── + my $ssh_hidden = ($remote_type ne 'ssh') ? ' hidden' : ''; + + # SSH key guidance (add mode only) + print qq{
\n}; + unless ($is_edit) { + print GnizaWHM::UI::render_ssh_guidance(); + } + print qq{
\n}; + + my $auth_method = $conf->{REMOTE_AUTH_METHOD} // 'key'; + my $key_checked = ($auth_method ne 'password') ? ' checked' : ''; + my $pw_checked = ($auth_method eq 'password') ? ' checked' : ''; + my $key_hidden = ($auth_method eq 'password') ? ' hidden' : ''; + my $pw_hidden = ($auth_method ne 'password') ? ' hidden' : ''; + + print qq{
\n}; + print qq{
\n
\n}; + print qq{

SSH Connection

\n}; + _field($conf, 'REMOTE_HOST', 'Hostname / IP', 'Required'); + _field($conf, 'REMOTE_PORT', 'SSH Port', 'Default: 22'); + _field($conf, 'REMOTE_USER', 'SSH User', 'Default: root'); + + # Auth method toggle + print qq{
\n}; + print qq{ \n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{
\n}; + + # Key field + print qq{
\n}; + _field($conf, 'REMOTE_KEY', 'SSH Private Key', 'Absolute path'); + print qq{
\n}; + + # Password field + my $pw_val = GnizaWHM::UI::esc($conf->{REMOTE_PASSWORD} // ''); + print qq{
\n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ Requires sshpass on server\n}; + print qq{
\n}; + print qq{
\n}; + + print qq{
\n
\n}; + print qq{
\n}; + + # ── S3 fields ───────────────────────────────────────────── + my $s3_hidden = ($remote_type ne 's3') ? ' hidden' : ''; + + print qq{
\n}; + print qq{
\n
\n}; + print qq{

Amazon S3 / S3-Compatible

\n}; + _field($conf, 'S3_ACCESS_KEY_ID', 'Access Key ID', 'Required'); + _password_field($conf, 'S3_SECRET_ACCESS_KEY', 'Secret Access Key', 'Required'); + _field($conf, 'S3_REGION', 'Region', 'Default: us-east-1'); + _field($conf, 'S3_ENDPOINT', 'Custom Endpoint', 'For MinIO, Wasabi, etc.'); + _field($conf, 'S3_BUCKET', 'Bucket Name', 'Required'); + print qq{

Requires rclone installed on this server.

\n}; + print qq{
\n
\n}; + print qq{
\n}; + + # ── Google Drive fields ─────────────────────────────────── + my $gdrive_hidden = ($remote_type ne 'gdrive') ? ' hidden' : ''; + + print qq{
\n}; + print qq{
\n
\n}; + print qq{

Google Drive

\n}; + _field($conf, 'GDRIVE_SERVICE_ACCOUNT_FILE', 'Service Account JSON', 'Absolute path, required'); + _field($conf, 'GDRIVE_ROOT_FOLDER_ID', 'Root Folder ID', 'Optional'); + print qq{

Requires rclone installed on this server.

\n}; + print qq{
\n
\n}; + print qq{
\n}; + + # ── Common fields ───────────────────────────────────────── + print qq{
\n
\n}; + print qq{

Storage Path

\n}; + _field($conf, 'REMOTE_BASE', 'Remote Base Dir', 'Default: /backups'); + print qq{
\n
\n}; + + # Transfer + print qq{
\n
\n}; + print qq{

Transfer Settings

\n}; + _field($conf, 'BWLIMIT', 'Bandwidth Limit', 'KB/s, 0 = unlimited'); + print qq{
\n}; + _field($conf, 'RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'SSH only'); + print qq{
\n}; + print qq{
\n
\n}; + + # Retention + print qq{
\n
\n}; + print qq{

Retention

\n}; + _field($conf, 'RETENTION_COUNT', 'Snapshots to Keep', 'Default: 30'); + print qq{
\n
\n}; + + # Submit + print qq{
\n}; + my $btn_label = $is_edit ? 'Save Changes' : 'Create Remote'; + print qq{ \n}; + print qq{ \n}; + print qq{ Cancel\n}; + print qq{
\n}; + + print qq{
\n}; + + print <<'JS'; + +JS +} + +sub _field { + my ($conf, $key, $label, $hint) = @_; + my $val = GnizaWHM::UI::esc($conf->{$key} // ''); + my $hint_html = $hint ? qq{ $hint} : ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ $hint_html\n} if $hint; + print qq{
\n}; +} + +sub _password_field { + my ($conf, $key, $label, $hint) = @_; + my $val = GnizaWHM::UI::esc($conf->{$key} // ''); + my $hint_html = $hint ? qq{ $hint} : ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ $hint_html\n} if $hint; + print qq{
\n}; +} diff --git a/whm/gniza-whm/restore.cgi b/whm/gniza-whm/restore.cgi new file mode 100644 index 0000000..ad32ca6 --- /dev/null +++ b/whm/gniza-whm/restore.cgi @@ -0,0 +1,1017 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza WHM Plugin — Restore +# Step-by-step restore workflow with dynamic dropdowns +use strict; +use warnings; + +use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib'; + +use Whostmgr::HTMLInterface (); +use Cpanel::Form (); +use GnizaWHM::Config; +use GnizaWHM::Runner; +use GnizaWHM::UI; + +my $form = Cpanel::Form::parseform(); +my $method = $ENV{'REQUEST_METHOD'} // 'GET'; +my $step = $form->{'restore_step'} // '1'; + +my %TYPE_LABELS = ( + account => 'Full Account', + files => 'Files', + database => 'Database', + mailbox => 'Mailbox', + cron => 'Cron Jobs', + dbusers => 'Database Users & Grants', + cpconfig => 'Panel Config', + domains => 'Domains', + ssl => 'SSL Certificates', +); + +my %SIMPLE_TYPES = map { $_ => 1 } qw(account cron cpconfig); + +if ($step eq 'fetch_options') { handle_fetch_options() } +elsif ($step eq '2') { handle_step2() } +elsif ($step eq '3') { handle_step3() } +elsif ($step eq '4') { handle_step4() } +else { handle_step1() } + +exit; + +# ── Helpers ─────────────────────────────────────────────────── + +sub _uri_escape { + my $str = shift // ''; + $str =~ s/([^A-Za-z0-9\-._~])/sprintf("%%%02X", ord($1))/ge; + return $str; +} + +# ── JSON endpoint: fetch database/mailbox options ───────────── + +sub handle_fetch_options { + my $remote = $form->{'remote'} // ''; + my $account = $form->{'account'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + my $type = $form->{'type'} // ''; + + print "Content-Type: application/json\r\n\r\n"; + + if ($remote eq '' || $account eq '' || $timestamp eq '' || $type eq '') { + print qq({"error":"Missing required parameters"}); + return; + } + + my $subcmd; + my %extra_opts; + if ($type eq 'database') { $subcmd = 'list-databases' } + elsif ($type eq 'mailbox') { $subcmd = 'list-mailboxes' } + elsif ($type eq 'dbusers') { $subcmd = 'list-dbusers' } + elsif ($type eq 'cron') { $subcmd = 'list-cron' } + elsif ($type eq 'domains') { $subcmd = 'list-dns' } + elsif ($type eq 'ssl') { $subcmd = 'list-ssl' } + elsif ($type eq 'files') { + $subcmd = 'list-files'; + my $path = $form->{'path'} // ''; + $extra_opts{path} = $path if $path ne ''; + } + else { + print qq({"error":"Invalid type"}); + return; + } + + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run( + 'restore', $subcmd, [$account], + { remote => $remote, timestamp => $timestamp, %extra_opts } + ); + + unless ($ok) { + my $msg = $stderr // 'Failed to list options'; + $msg =~ s/\\/\\\\/g; + $msg =~ s/"/\\"/g; + $msg =~ s/\n/\\n/g; + $msg =~ s/\r/\\r/g; + print qq({"error":"$msg"}); + return; + } + + my @options; + for my $line (split /\n/, $stdout) { + $line =~ s/^\s+|\s+$//g; + next unless $line ne ''; + push @options, $line; + } + + # Build JSON array manually (no JSON module dependency) + my $json_arr = join(',', map { my $v = $_; $v =~ s/\\/\\\\/g; $v =~ s/"/\\"/g; qq("$v") } @options); + print qq({"options":[$json_arr]}); +} + +# ── Step 1: Select Account + Remote ───────────────────────── + +sub handle_step1 { + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi'); + + print GnizaWHM::UI::page_header('Restore from Backup'); + print GnizaWHM::UI::render_nav('restore.cgi'); + print GnizaWHM::UI::render_flash(); + + my @remotes = GnizaWHM::UI::list_remotes(); + + unless (@remotes) { + print qq{
No remotes configured. Add a remote first.
\n}; + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); + return; + } + + print qq{
\n}; + print qq{\n}; + + print qq{
\n
\n}; + print qq{

Step 1: Select Source

\n}; + + # Remote dropdown + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + + # Account input + my @accounts = GnizaWHM::UI::get_cpanel_accounts(); + print qq{
\n}; + print qq{ \n}; + if (@accounts) { + print qq{ \n}; + } else { + print qq{ \n}; + } + print qq{
\n}; + + print qq{
\n
\n}; + + print qq{
\n}; + print qq{ \n}; + print qq{
\n}; + + print qq{
\n}; + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Step 2: Select Snapshot + Restore Type ─────────────────── + +sub handle_step2 { + my $remote = $form->{'remote'} // ''; + my $account = $form->{'account'} // ''; + + if ($remote eq '' || $account eq '') { + GnizaWHM::UI::set_flash('error', 'Remote and account are required.'); + print "Status: 302 Found\r\n"; + print "Location: restore.cgi\r\n\r\n"; + exit; + } + + # Fetch snapshots via Runner + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('list', undef, [], { remote => $remote, account => $account }); + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi'); + + print GnizaWHM::UI::page_header('Restore from Backup'); + print GnizaWHM::UI::render_nav('restore.cgi'); + print GnizaWHM::UI::render_flash(); + + my $esc_remote = GnizaWHM::UI::esc($remote); + my $esc_account = GnizaWHM::UI::esc($account); + + unless ($ok) { + my $msg = GnizaWHM::UI::esc($stderr || 'Failed to list snapshots'); + print qq{
$msg
\n}; + print qq{Back\n}; + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); + return; + } + + # Parse snapshot timestamps from output + my @snapshots; + for my $line (split /\n/, $stdout) { + if ($line =~ /^\s+(\d{4}-\d{2}-\d{2}T\d{6})/) { + push @snapshots, $1; + } + } + + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + + print qq{
\n
\n}; + print qq{

Step 2: Choose Restore Options

\n}; + print qq{

Account: $esc_account on remote $esc_remote

\n}; + + # Snapshot dropdown + print qq{
\n}; + print qq{ \n}; + if (@snapshots) { + print qq{ \n}; + } else { + print qq{ No snapshots found\n}; + } + print qq{
\n}; + + # Restore mode toggle: Full Account vs Selective + print qq{
\n}; + print qq{ \n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{
\n}; + + # Hidden field that always carries account type when Full is selected + print qq{\n}; + + # Selective type buttons (hidden by default) + my @selective_types = ( + ['files', 'Files'], + ['database', 'Database'], + ['dbusers', 'Database Users'], + ['mailbox', 'Mailbox'], + ['cron', 'Cron'], + ['cpconfig', 'Config'], + ['domains', 'Domains'], + ['ssl', 'SSL'], + ); + + print qq{\n}; + + # File browser modal + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + + print qq{
\n
\n}; + + if (@snapshots) { + print qq{
\n}; + print qq{ \n}; + print qq{ Back\n}; + print qq{
\n}; + } else { + print qq{Back\n}; + } + + print qq{
\n}; + + # JavaScript for dynamic dropdowns + print < +var gnizaCache = {}; +var gnizaRemote = '$esc_remote'; +var gnizaAccount = '$esc_account'; + +var fbCache = {}; +var fbSelected = ''; + +function gnizaSnapshotChange() { + gnizaCache = {}; + fbCache = {}; + gnizaModeChanged(); +} + +function gnizaModeChanged() { + var mode = document.querySelector('input[name="restore_mode"]:checked').value; + var selective = mode === 'selective'; + document.getElementById('selective-panel').style.display = selective ? '' : 'none'; + document.getElementById('type_account_hidden').disabled = selective; + if (selective) { + gnizaTypesChanged(); + } else { + var panels = ['field-path','field-dbname','field-email','field-dbusers','field-cron','field-domains','field-ssl']; + for (var i = 0; i < panels.length; i++) { + document.getElementById(panels[i]).style.display = 'none'; + } + } +} + +function gnizaTypesChanged() { + var types = { + files: 'field-path', + database: 'field-dbname', + mailbox: 'field-email', + dbusers: 'field-dbusers', + cron: 'field-cron', + domains: 'field-domains', + ssl: 'field-ssl' + }; + for (var t in types) { + var el = document.querySelector('input[name="type_' + t + '"]'); + document.getElementById(types[t]).style.display = el && el.checked ? '' : 'none'; + } + + if (document.querySelector('input[name="type_database"]').checked) { gnizaLoadOptions('database', 'dbname-list', 'dbnames'); } + if (document.querySelector('input[name="type_mailbox"]').checked) { gnizaLoadOptions('mailbox', 'email-list', 'emails'); } + if (document.querySelector('input[name="type_dbusers"]').checked) { gnizaLoadOptions('dbusers', 'dbusers-list', 'dbuser_names'); } + if (document.querySelector('input[name="type_cron"]').checked) { gnizaLoadPreview('cron', 'cron-list'); } + if (document.querySelector('input[name="type_domains"]').checked) { gnizaLoadOptions('domains', 'domains-list', 'domain_names'); } + if (document.querySelector('input[name="type_ssl"]').checked) { gnizaLoadOptions('ssl', 'ssl-list', 'ssl_names'); } +} + +function gnizaLoadOptions(type, containerId, hiddenId) { + var ts = document.getElementById('timestamp').value; + var cacheKey = type + ':' + ts; + + if (gnizaCache[cacheKey]) { + gnizaPopulateChecklist(containerId, hiddenId, gnizaCache[cacheKey]); + return; + } + + var container = document.getElementById(containerId); + container.innerHTML = ' Loading...'; + document.getElementById(hiddenId).value = ''; + + var url = 'restore.cgi?restore_step=fetch_options' + + '&remote=' + encodeURIComponent(gnizaRemote) + + '&account=' + encodeURIComponent(gnizaAccount) + + '×tamp=' + encodeURIComponent(ts) + + '&type=' + encodeURIComponent(type); + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.onreadystatechange = function() { + if (xhr.readyState !== 4) return; + if (xhr.status === 200) { + try { + var data = JSON.parse(xhr.responseText); + if (data.error) { + container.innerHTML = 'Error: ' + data.error + ''; + } else { + gnizaCache[cacheKey] = data.options; + gnizaPopulateChecklist(containerId, hiddenId, data.options); + } + } catch(e) { + container.innerHTML = 'Failed to parse response'; + } + } else { + container.innerHTML = 'Request failed'; + } + }; + xhr.send(); +} + +function gnizaPopulateChecklist(containerId, hiddenId, options) { + var container = document.getElementById(containerId); + var hidden = document.getElementById(hiddenId); + hidden.value = ''; + + if (!options || options.length === 0) { + container.innerHTML = '(none found)'; + return; + } + + var allLabels = {'dbname-list':'All Databases','dbusers-list':'All Database Users','email-list':'All Mailboxes','domains-list':'All Domains','ssl-list':'All Certificates'}; + var allLabel = allLabels[containerId] || 'All'; + var html = ''; + + for (var i = 0; i < options.length; i++) { + var v = options[i].replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + html += ''; + } + container.innerHTML = html; +} + +function gnizaToggleAll(containerId, hiddenId, checked) { + var container = document.getElementById(containerId); + var hidden = document.getElementById(hiddenId); + var items = container.querySelectorAll('input[data-item]'); + for (var i = 0; i < items.length; i++) { + items[i].disabled = checked; + if (checked) items[i].checked = false; + } + hidden.value = checked ? '__ALL__' : ''; +} + +function gnizaSyncHidden(containerId, hiddenId) { + var container = document.getElementById(containerId); + var hidden = document.getElementById(hiddenId); + var items = container.querySelectorAll('input[data-item]:checked'); + var vals = []; + for (var i = 0; i < items.length; i++) { + vals.push(items[i].value); + } + hidden.value = vals.join(','); +} + +function gnizaLoadPreview(type, containerId) { + var ts = document.getElementById('timestamp').value; + var cacheKey = type + ':' + ts; + + if (gnizaCache[cacheKey]) { + gnizaPopulatePreview(containerId, gnizaCache[cacheKey], type); + return; + } + + var container = document.getElementById(containerId); + container.innerHTML = ' Loading...'; + + var url = 'restore.cgi?restore_step=fetch_options' + + '&remote=' + encodeURIComponent(gnizaRemote) + + '&account=' + encodeURIComponent(gnizaAccount) + + '×tamp=' + encodeURIComponent(ts) + + '&type=' + encodeURIComponent(type); + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.onreadystatechange = function() { + if (xhr.readyState !== 4) return; + if (xhr.status === 200) { + try { + var data = JSON.parse(xhr.responseText); + if (data.error) { + container.innerHTML = 'Error: ' + data.error + ''; + } else { + gnizaCache[cacheKey] = data.options; + gnizaPopulatePreview(containerId, data.options, type); + } + } catch(e) { + container.innerHTML = 'Failed to parse response'; + } + } else { + container.innerHTML = 'Request failed'; + } + }; + xhr.send(); +} + +function gnizaPopulatePreview(containerId, options, type) { + var container = document.getElementById(containerId); + if (!options || options.length === 0) { + container.innerHTML = '(none found)'; + return; + } + if (type === 'cron') { + var html = '
';
+        for (var i = 0; i < options.length; i++) {
+            html += options[i].replace(/&/g,'&').replace(//g,'>') + '\\n';
+        }
+        html += '
'; + container.innerHTML = html; + } else { + var html = '
    '; + for (var i = 0; i < options.length; i++) { + html += '
  • ' + options[i].replace(/&/g,'&').replace(//g,'>') + '
  • '; + } + html += '
'; + container.innerHTML = html; + } +} + +gnizaModeChanged(); + +function gnizaOpenFileBrowser() { + fbSelected = ''; + document.getElementById('fb-select-btn').disabled = true; + document.getElementById('fb-modal').showModal(); + gnizaLoadDir(''); +} + +function gnizaLoadDir(path) { + var ts = document.getElementById('timestamp').value; + var cacheKey = 'fb:' + ts + ':' + path; + + if (fbCache[cacheKey]) { + gnizaRenderFileList(path, fbCache[cacheKey]); + return; + } + + document.getElementById('fb-loading').style.display = ''; + document.getElementById('fb-error').style.display = 'none'; + document.getElementById('fb-tbody').innerHTML = ''; + + var url = 'restore.cgi?restore_step=fetch_options' + + '&remote=' + encodeURIComponent(gnizaRemote) + + '&account=' + encodeURIComponent(gnizaAccount) + + '×tamp=' + encodeURIComponent(ts) + + '&type=files' + + (path ? '&path=' + encodeURIComponent(path) : ''); + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.onreadystatechange = function() { + if (xhr.readyState !== 4) return; + document.getElementById('fb-loading').style.display = 'none'; + if (xhr.status === 200) { + try { + var data = JSON.parse(xhr.responseText); + if (data.error) { + document.getElementById('fb-error').textContent = data.error; + document.getElementById('fb-error').style.display = ''; + } else { + fbCache[cacheKey] = data.options; + gnizaRenderFileList(path, data.options); + } + } catch(e) { + document.getElementById('fb-error').textContent = 'Failed to parse response'; + document.getElementById('fb-error').style.display = ''; + } + } else { + document.getElementById('fb-error').textContent = 'Request failed'; + document.getElementById('fb-error').style.display = ''; + } + }; + xhr.send(); +} + +function gnizaRenderBreadcrumbs(path) { + var ul = document.createElement('ul'); + var li = document.createElement('li'); + var a = document.createElement('a'); + a.textContent = 'homedir'; + a.href = '#'; + a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(''); }; + li.appendChild(a); + ul.appendChild(li); + + if (path) { + var parts = path.replace(/\\/\$/, '').split('/'); + var built = ''; + for (var i = 0; i < parts.length; i++) { + built += (i > 0 ? '/' : '') + parts[i]; + li = document.createElement('li'); + if (i < parts.length - 1) { + a = document.createElement('a'); + a.textContent = parts[i]; + a.href = '#'; + (function(p) { a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(p); }; })(built); + li.appendChild(a); + } else { + li.textContent = parts[i]; + } + ul.appendChild(li); + } + } + + var bc = document.getElementById('fb-breadcrumbs'); + bc.innerHTML = ''; + bc.appendChild(ul); +} + +function gnizaRenderFileList(currentPath, entries) { + gnizaRenderBreadcrumbs(currentPath); + fbSelected = ''; + document.getElementById('fb-select-btn').disabled = true; + + var tbody = document.getElementById('fb-tbody'); + tbody.innerHTML = ''; + + if (!entries || entries.length === 0) { + tbody.innerHTML = '(empty directory)'; + return; + } + + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + var isDir = entry.endsWith('/'); + var displayName = entry; + var fullPath = currentPath ? currentPath.replace(/\\/\$/, '') + '/' + entry : entry; + + var tr = document.createElement('tr'); + tr.className = 'cursor-pointer hover'; + tr.setAttribute('data-path', fullPath); + + var td = document.createElement('td'); + td.className = 'py-1'; + var icon = isDir ? '\\uD83D\\uDCC1 ' : '\\uD83D\\uDCC4 '; + td.textContent = icon + displayName; + tr.appendChild(td); + + (function(row, path, dir) { + row.onclick = function() { gnizaHighlight(row, path); }; + if (dir) { + row.ondblclick = function() { gnizaLoadDir(path.replace(/\\/\$/, '')); }; + } + })(tr, fullPath, isDir); + + tbody.appendChild(tr); + } +} + +function gnizaHighlight(row, path) { + var rows = document.getElementById('fb-tbody').querySelectorAll('tr'); + for (var i = 0; i < rows.length; i++) { + rows[i].classList.remove('bg-primary/10'); + } + row.classList.add('bg-primary/10'); + fbSelected = path; + document.getElementById('fb-select-btn').disabled = false; +} + +function gnizaSelectPath() { + if (fbSelected) { + document.getElementById('path').value = fbSelected; + } + document.getElementById('fb-modal').close(); +} + +JS + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Step 3: Summary + Confirm ──────────────────────────────── + +sub handle_step3 { + my $remote = $form->{'remote'} // ''; + my $account = $form->{'account'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + my $path = $form->{'path'} // ''; + my $dbnames = $form->{'dbnames'} // ''; + my $dbuser_names = $form->{'dbuser_names'} // ''; + my $emails = $form->{'emails'} // ''; + my $domain_names = $form->{'domain_names'} // ''; + my $ssl_names = $form->{'ssl_names'} // ''; + + # Collect selected types from type_* checkboxes + my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl); + my @selected_types; + for my $t (@all_type_keys) { + push @selected_types, $t if ($form->{"type_$t"} // '') eq '1'; + } + + unless (@selected_types) { + GnizaWHM::UI::set_flash('error', 'Please select at least one restore type.'); + print "Status: 302 Found\r\n"; + print "Location: restore.cgi?restore_step=2&remote=" . _uri_escape($remote) . "&account=" . _uri_escape($account) . "\r\n\r\n"; + exit; + } + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi'); + + print GnizaWHM::UI::page_header('Restore from Backup'); + print GnizaWHM::UI::render_nav('restore.cgi'); + + my $esc_remote = GnizaWHM::UI::esc($remote); + my $esc_account = GnizaWHM::UI::esc($account); + my $esc_timestamp = GnizaWHM::UI::esc($timestamp); + + my $types_display = join(', ', map { GnizaWHM::UI::esc($TYPE_LABELS{$_} // $_) } @selected_types); + + print qq{
\n
\n}; + print qq{

Step 3: Confirm Restore

\n}; + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + + # Show sub-field details for applicable types + if (grep { $_ eq 'files' } @selected_types) { + my $path_display = $path ne '' ? GnizaWHM::UI::esc($path) : 'All files'; + print qq{\n}; + } + if (grep { $_ eq 'database' } @selected_types) { + my $db_display = ($dbnames eq '' || $dbnames eq '__ALL__') ? 'All databases' : GnizaWHM::UI::esc($dbnames); + print qq{\n}; + } + if (grep { $_ eq 'dbusers' } @selected_types) { + my $dbu_display = ($dbuser_names eq '' || $dbuser_names eq '__ALL__') ? 'All database users' : GnizaWHM::UI::esc($dbuser_names); + print qq{\n}; + } + if (grep { $_ eq 'mailbox' } @selected_types) { + my $mb_display = ($emails eq '' || $emails eq '__ALL__') ? 'All mailboxes' : GnizaWHM::UI::esc($emails); + print qq{\n}; + } + if (grep { $_ eq 'domains' } @selected_types) { + my $dom_display = ($domain_names eq '' || $domain_names eq '__ALL__') ? 'All domains' : GnizaWHM::UI::esc($domain_names); + print qq{\n}; + } + if (grep { $_ eq 'ssl' } @selected_types) { + my $ssl_display = ($ssl_names eq '' || $ssl_names eq '__ALL__') ? 'All certificates' : GnizaWHM::UI::esc($ssl_names); + print qq{\n}; + } + + print qq{
Remote$esc_remote
Account$esc_account
Snapshot$esc_timestamp
Restore Types$types_display
Path$path_display
Database$db_display
Database Users$dbu_display
Mailbox$mb_display
Domains$dom_display
SSL$ssl_display
\n}; + print qq{
\n
\n}; + + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + for my $t (@selected_types) { + print qq{\n}; + } + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + print GnizaWHM::UI::csrf_hidden_field(); + + print qq{
\n}; + print qq{ \n}; + print qq{ Cancel\n}; + print qq{
\n}; + print qq{
\n}; + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Step 4: Execute + Show Output ──────────────────────────── + +sub handle_step4 { + unless ($method eq 'POST' && GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.'); + print "Status: 302 Found\r\n"; + print "Location: restore.cgi\r\n\r\n"; + exit; + } + + my $remote = $form->{'remote'} // ''; + my $account = $form->{'account'} // ''; + my $timestamp = $form->{'timestamp'} // ''; + my $path = $form->{'path'} // ''; + my $dbnames = $form->{'dbnames'} // ''; + my $dbuser_names = $form->{'dbuser_names'} // ''; + my $emails = $form->{'emails'} // ''; + my $domain_names = $form->{'domain_names'} // ''; + my $ssl_names = $form->{'ssl_names'} // ''; + + # Collect selected types + my @all_type_keys = qw(account files database mailbox cron dbusers cpconfig domains ssl); + my @selected_types; + for my $t (@all_type_keys) { + push @selected_types, $t if ($form->{"type_$t"} // '') eq '1'; + } + + unless (@selected_types) { + GnizaWHM::UI::set_flash('error', 'No restore types selected.'); + print "Status: 302 Found\r\n"; + print "Location: restore.cgi\r\n\r\n"; + exit; + } + + # Execute each type sequentially + my @results; + for my $type (@selected_types) { + my %opts = (remote => $remote); + $opts{timestamp} = $timestamp if $timestamp ne ''; + + if ($SIMPLE_TYPES{$type}) { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', $type, [$account], \%opts); + push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } elsif ($type eq 'files') { + $opts{path} = $path if $path ne ''; + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'files', [$account], \%opts); + push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } elsif ($type eq 'database') { + my @dbs; + if ($dbnames ne '' && $dbnames ne '__ALL__') { + @dbs = split /,/, $dbnames; + } + if (@dbs) { + for my $db (@dbs) { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'database', [$account, $db], \%opts); + push @results, { type => $type, label => "Database: $db", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } else { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'database', [$account], \%opts); + push @results, { type => $type, label => 'Database: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } elsif ($type eq 'dbusers') { + my @dbus; + if ($dbuser_names ne '' && $dbuser_names ne '__ALL__') { + @dbus = split /,/, $dbuser_names; + } + if (@dbus) { + for my $dbu (@dbus) { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'dbusers', [$account, $dbu], \%opts); + push @results, { type => $type, label => "DB User: $dbu", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } else { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'dbusers', [$account], \%opts); + push @results, { type => $type, label => 'Database Users: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } elsif ($type eq 'mailbox') { + my @mbs; + if ($emails ne '' && $emails ne '__ALL__') { + @mbs = split /,/, $emails; + } + if (@mbs) { + for my $mb (@mbs) { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'mailbox', [$account, $mb], \%opts); + push @results, { type => $type, label => "Mailbox: $mb", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } else { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'mailbox', [$account], \%opts); + push @results, { type => $type, label => 'Mailbox: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } elsif ($type eq 'domains') { + my @doms; + if ($domain_names ne '' && $domain_names ne '__ALL__') { + @doms = split /,/, $domain_names; + } + if (@doms) { + for my $dom (@doms) { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'domains', [$account, $dom], \%opts); + push @results, { type => $type, label => "Domain: $dom", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } else { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'domains', [$account], \%opts); + push @results, { type => $type, label => 'Domains: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } elsif ($type eq 'ssl') { + my @certs; + if ($ssl_names ne '' && $ssl_names ne '__ALL__') { + @certs = split /,/, $ssl_names; + } + if (@certs) { + for my $cert (@certs) { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'ssl', [$account, $cert], \%opts); + push @results, { type => $type, label => "SSL: $cert", ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } else { + my ($ok, $stdout, $stderr) = GnizaWHM::Runner::run('restore', 'ssl', [$account], \%opts); + push @results, { type => $type, label => 'SSL: All', ok => $ok, stdout => $stdout // '', stderr => $stderr // '' }; + } + } else { + push @results, { type => $type, label => $TYPE_LABELS{$type} // $type, ok => 0, stdout => '', stderr => 'Invalid restore type' }; + } + } + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Restore', '', '/cgi/gniza-whm/restore.cgi'); + + print GnizaWHM::UI::page_header('Restore from Backup'); + print GnizaWHM::UI::render_nav('restore.cgi'); + + # Overall status + my $all_ok = !grep { !$_->{ok} } @results; + my $any_ok = grep { $_->{ok} } @results; + if ($all_ok) { + print qq{
All restore operations completed successfully.
\n}; + } elsif ($any_ok) { + print qq{
Some restore operations failed. See details below.
\n}; + } else { + print qq{
All restore operations failed.
\n}; + } + + # Per-type output blocks + for my $r (@results) { + my $esc_label = GnizaWHM::UI::esc($r->{label}); + my $badge = $r->{ok} + ? 'OK' + : 'Failed'; + + print qq{
\n
\n}; + print qq{

$esc_label $badge

\n}; + my $output = $r->{stdout} . ($r->{stderr} =~ /\S/ ? "\n$r->{stderr}" : ''); + $output = '(no output)' unless $output =~ /\S/; + print qq{
} . GnizaWHM::UI::esc($output) . qq{
\n}; + print qq{
\n
\n}; + } + + print qq{Start New Restore\n}; + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} diff --git a/whm/gniza-whm/schedules.cgi b/whm/gniza-whm/schedules.cgi new file mode 100644 index 0000000..ddd16db --- /dev/null +++ b/whm/gniza-whm/schedules.cgi @@ -0,0 +1,511 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza WHM Plugin — Schedule CRUD +use strict; +use warnings; + +use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib'; + +use Whostmgr::HTMLInterface (); +use Cpanel::Form (); +use File::Copy (); +use GnizaWHM::Config; +use GnizaWHM::Validator; +use GnizaWHM::Cron; +use GnizaWHM::UI; + +my $form = Cpanel::Form::parseform(); +my $method = $ENV{'REQUEST_METHOD'} // 'GET'; +my $action = $form->{'action'} // 'list'; + +# Route to handler +if ($action eq 'add') { handle_add() } +elsif ($action eq 'edit') { handle_edit() } +elsif ($action eq 'delete') { handle_delete() } +elsif ($action eq 'toggle_cron') { handle_toggle_cron() } +else { handle_list() } + +exit; + +# ── List ───────────────────────────────────────────────────── + +sub handle_list { + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Schedules', '', '/cgi/gniza-whm/schedules.cgi'); + + print GnizaWHM::UI::page_header('Schedule Management'); + print GnizaWHM::UI::render_nav('schedules.cgi'); + print GnizaWHM::UI::render_flash(); + + # Configured schedules + my @schedules = GnizaWHM::UI::list_schedules(); + my $cron_schedules = GnizaWHM::Cron::get_current_schedules(); + + print qq{
\n
\n}; + print qq{

Configured Schedules

\n}; + + if (@schedules) { + 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'); + my $esc_name = GnizaWHM::UI::esc($name); + my $esc_sched = GnizaWHM::UI::esc($conf->{SCHEDULE} // ''); + my $esc_time = GnizaWHM::UI::esc($conf->{SCHEDULE_TIME} // '02:00'); + my $esc_day = GnizaWHM::UI::esc($conf->{SCHEDULE_DAY} // '-'); + my $esc_remotes = GnizaWHM::UI::esc($conf->{REMOTES} // '(all)'); + $esc_remotes = '(all)' if $esc_remotes eq ''; + + my $in_cron = exists $cron_schedules->{$name}; + my $checked = $in_cron ? ' checked' : ''; + + print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{\n}; + } + print qq{\n
NameTypeTimeDayRemotesActiveActions
$esc_name$esc_sched$esc_time$esc_day$esc_remotes}; + print qq{}; + print qq{}; + print qq{Edit }; + print qq{
}; + print qq{}; + print qq{}; + print GnizaWHM::UI::csrf_hidden_field(); + print qq{}; + print qq{
}; + print qq{
\n}; + } else { + print qq{

No schedules configured. Add a schedule to define when backups run.

\n}; + } + print qq{
\n
\n}; + + # CSRF token + AJAX toggle script + my $csrf_token = GnizaWHM::UI::generate_csrf_token(); + print qq{\n}; + + # Action buttons + print qq{
\n}; + print qq{ Add Schedule\n}; + print qq{
\n}; + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Add ────────────────────────────────────────────────────── + +sub handle_add { + my @errors; + + if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + my $name = $form->{'schedule_name'} // ''; + my $name_err = GnizaWHM::Validator::validate_schedule_name($name); + push @errors, $name_err if $name_err; + + if (!@errors && -f GnizaWHM::UI::schedule_conf_path($name)) { + push @errors, "A schedule named '$name' already exists."; + } + + my %data; + for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) { + $data{$key} = $form->{$key} // ''; + } + + if (!@errors) { + my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data); + push @errors, @$validation_errors; + } + + if (!@errors) { + my $dest = GnizaWHM::UI::schedule_conf_path($name); + my $example = GnizaWHM::UI::schedule_example_path(); + if (-f $example) { + File::Copy::copy($example, $dest) + or do { push @errors, "Failed to create schedule file: $!"; goto RENDER_ADD; }; + } + my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS); + if ($ok) { + my ($cron_ok, $cron_err) = GnizaWHM::Cron::install_schedule($name); + if ($cron_ok) { + GnizaWHM::UI::set_flash('success', "Schedule '$name' created and activated."); + } else { + GnizaWHM::UI::set_flash('warning', "Schedule '$name' created but cron activation failed: $cron_err"); + } + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } else { + push @errors, "Failed to save schedule config: $err"; + } + } + } + + RENDER_ADD: + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Add Schedule', '', '/cgi/gniza-whm/schedules.cgi'); + + print GnizaWHM::UI::page_header('Add Schedule'); + print GnizaWHM::UI::render_nav('schedules.cgi'); + + if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); + } + + my $conf = {}; + if ($method eq 'POST') { + for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } + } + my $name_val = GnizaWHM::UI::esc($form->{'schedule_name'} // ''); + + render_schedule_form($conf, $name_val, 0); + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Edit ───────────────────────────────────────────────────── + +sub handle_edit { + my $name = $form->{'name'} // ''; + my @errors; + + my $name_err = GnizaWHM::Validator::validate_schedule_name($name); + if ($name_err) { + GnizaWHM::UI::set_flash('error', "Invalid schedule name."); + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } + + my $conf_path = GnizaWHM::UI::schedule_conf_path($name); + unless (-f $conf_path) { + GnizaWHM::UI::set_flash('error', "Schedule '$name' not found."); + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } + + if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + my %data; + for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) { + $data{$key} = $form->{$key} // ''; + } + + if (!@errors) { + my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data); + push @errors, @$validation_errors; + } + + if (!@errors) { + my ($ok, $err) = GnizaWHM::Config::write($conf_path, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS); + if ($ok) { + # Re-install cron if it was active, so timing/remote changes take effect + my $cron_schedules = GnizaWHM::Cron::get_current_schedules(); + if (exists $cron_schedules->{$name}) { + GnizaWHM::Cron::install_schedule($name); + } + GnizaWHM::UI::set_flash('success', "Schedule '$name' updated successfully."); + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } else { + push @errors, "Failed to save schedule config: $err"; + } + } + } + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Edit Schedule', '', '/cgi/gniza-whm/schedules.cgi'); + + print GnizaWHM::UI::page_header("Edit Schedule: " . GnizaWHM::UI::esc($name)); + print GnizaWHM::UI::render_nav('schedules.cgi'); + + if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); + } + + my $conf; + if (@errors && $method eq 'POST') { + $conf = {}; + for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } + } else { + $conf = GnizaWHM::Config::parse($conf_path, 'schedule'); + } + + render_schedule_form($conf, GnizaWHM::UI::esc($name), 1); + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Delete ─────────────────────────────────────────────────── + +sub handle_delete { + if ($method ne 'POST') { + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } + + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + GnizaWHM::UI::set_flash('error', 'Invalid or expired form token.'); + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } + + my $name = $form->{'name'} // ''; + my $name_err = GnizaWHM::Validator::validate_schedule_name($name); + if ($name_err) { + GnizaWHM::UI::set_flash('error', 'Invalid schedule name.'); + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } + + my $conf_path = GnizaWHM::UI::schedule_conf_path($name); + if (-f $conf_path) { + GnizaWHM::Cron::remove_schedule($name); + unlink $conf_path; + GnizaWHM::UI::set_flash('success', "Schedule '$name' deleted."); + } else { + GnizaWHM::UI::set_flash('error', "Schedule '$name' not found."); + } + + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; +} + +# ── Toggle Cron ────────────────────────────────────────────── + +sub handle_toggle_cron { + if ($method ne 'POST') { + print "Status: 302 Found\r\n"; + print "Location: schedules.cgi\r\n\r\n"; + exit; + } + + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + my $new_csrf = GnizaWHM::UI::generate_csrf_token(); + _json_response(0, 0, 'Invalid or expired form token.', $new_csrf); + } + + my $new_csrf = GnizaWHM::UI::generate_csrf_token(); + + my $name = $form->{'name'} // ''; + my $name_err = GnizaWHM::Validator::validate_schedule_name($name); + if ($name_err) { + _json_response(0, 0, 'Invalid schedule name.', $new_csrf); + } + + my $cron_schedules = GnizaWHM::Cron::get_current_schedules(); + my $is_active = exists $cron_schedules->{$name}; + + if ($is_active) { + my ($ok, $err) = GnizaWHM::Cron::remove_schedule($name); + if ($ok) { + _json_response(1, 0, "Cron disabled for '$name'.", $new_csrf); + } else { + _json_response(0, 1, "Failed to remove cron: $err", $new_csrf); + } + } else { + my ($ok, $err) = GnizaWHM::Cron::install_schedule($name); + if ($ok) { + _json_response(1, 1, "Cron enabled for '$name'.", $new_csrf); + } else { + _json_response(0, 0, "Failed to install cron: $err", $new_csrf); + } + } +} + +sub _json_response { + my ($ok, $active, $message, $csrf) = @_; + # Escape for JSON + $message =~ s/\\/\\\\/g; + $message =~ s/"/\\"/g; + my $active_str = $active ? 'true' : 'false'; + my $ok_str = $ok ? 'true' : 'false'; + print "Content-Type: application/json\r\n\r\n"; + print qq({"ok":$ok_str,"active":$active_str,"message":"$message","csrf":"$csrf"}); + exit; +} + +# ── Shared Form Renderer ──────────────────────────────────── + +sub render_schedule_form { + my ($conf, $name_val, $is_edit) = @_; + + my $action_val = $is_edit ? 'edit' : 'add'; + print qq{
\n}; + print qq{\n}; + print GnizaWHM::UI::csrf_hidden_field(); + + if ($is_edit) { + print qq{\n}; + } + + # Schedule name + print qq{
\n
\n}; + print qq{

Schedule Identity

\n}; + print qq{
\n}; + print qq{ \n}; + if ($is_edit) { + print qq{ \n}; + } else { + print qq{ \n}; + print qq{ Letters, digits, hyphens, underscores\n}; + } + print qq{
\n}; + print qq{
\n
\n}; + + # Schedule settings + print qq{
\n
\n}; + print qq{

Schedule Settings

\n}; + + my $sched = $conf->{SCHEDULE} // ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + + _sched_field($conf, 'SCHEDULE_TIME', 'Time (HH:MM)', 'Default: 02:00'); + + print qq{
\n}; + _sched_field($conf, 'SCHEDULE_DAY', 'Day', 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)'); + print qq{
\n}; + + print qq{
\n}; + _sched_field($conf, 'SCHEDULE_CRON', 'Cron Expression', '5-field cron (for custom only)'); + print qq{
\n}; + + print qq{
\n
\n}; + + # Target remotes + print qq{
\n
\n}; + print qq{

Target Remotes

\n}; + print qq{

Select which remotes this schedule targets. Leave all unchecked to target all remotes.

\n}; + + my @remotes = GnizaWHM::UI::list_remotes(); + my %selected_remotes; + my $remotes_str = $conf->{REMOTES} // ''; + for my $r (split /,/, $remotes_str) { + $r =~ s/^\s+|\s+$//g; + $selected_remotes{$r} = 1 if $r ne ''; + } + + if (@remotes) { + for my $rname (@remotes) { + my $esc_name = GnizaWHM::UI::esc($rname); + my $checked = $selected_remotes{$rname} ? ' checked' : ''; + print qq{\n}; + } + # Hidden field to collect selected remotes via JS + print qq{\n}; + } else { + print qq{

No remotes configured. Add a remote first.

\n}; + } + + print qq{
\n
\n}; + + # Submit + print qq{
\n}; + my $btn_label = $is_edit ? 'Save Changes' : 'Create Schedule'; + print qq{ \n}; + print qq{ Cancel\n}; + print qq{
\n}; + + print qq{
\n}; + + # JS for schedule field visibility and remote collection + print <<'JS'; + +JS +} + +sub _sched_field { + my ($conf, $key, $label, $hint) = @_; + my $val = GnizaWHM::UI::esc($conf->{$key} // ''); + my $hint_html = $hint ? qq{ $hint} : ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ $hint_html\n} if $hint; + print qq{
\n}; +} diff --git a/whm/gniza-whm/settings.cgi b/whm/gniza-whm/settings.cgi new file mode 100644 index 0000000..66259b9 --- /dev/null +++ b/whm/gniza-whm/settings.cgi @@ -0,0 +1,171 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza WHM Plugin — Main Config Editor +use strict; +use warnings; + +use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib'; + +use Whostmgr::HTMLInterface (); +use Cpanel::Form (); +use GnizaWHM::Config; +use GnizaWHM::Validator; +use GnizaWHM::UI; + +my $CONFIG_FILE = '/etc/gniza/gniza.conf'; +my $form = Cpanel::Form::parseform(); +my $method = $ENV{'REQUEST_METHOD'} // 'GET'; + +# ── Handle POST ────────────────────────────────────────────── + +my @errors; +my $saved = 0; + +if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + if (!@errors) { + my %data; + for my $key (@GnizaWHM::Config::MAIN_KEYS) { + $data{$key} = $form->{$key} // ''; + } + + my $validation_errors = GnizaWHM::Validator::validate_main_config(\%data); + + if (@$validation_errors) { + @errors = @$validation_errors; + } else { + my ($ok, $err) = GnizaWHM::Config::write($CONFIG_FILE, \%data, \@GnizaWHM::Config::MAIN_KEYS); + if ($ok) { + GnizaWHM::UI::set_flash('success', 'Configuration saved successfully.'); + print "Status: 302 Found\r\n"; + print "Location: settings.cgi\r\n\r\n"; + exit; + } else { + push @errors, "Failed to save config: $err"; + } + } + } +} + +# ── Render Page ────────────────────────────────────────────── + +print "Content-Type: text/html\r\n\r\n"; + +Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Settings', '', '/cgi/gniza-whm/settings.cgi'); + +print GnizaWHM::UI::page_header('Settings'); +print GnizaWHM::UI::render_nav('settings.cgi'); +print GnizaWHM::UI::render_flash(); + +if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); +} + +# Load current config (or use POST data if validation failed) +my $conf; +if (@errors && $method eq 'POST') { + $conf = {}; + for my $key (@GnizaWHM::Config::MAIN_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } +} else { + $conf = GnizaWHM::Config::parse($CONFIG_FILE, 'main'); +} + +# Helper to output a text field row +sub field_text { + my ($key, $label, $hint, $extra) = @_; + $extra //= ''; + my $val = GnizaWHM::UI::esc($conf->{$key} // ''); + my $hint_html = $hint ? qq{ $hint} : ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ $hint_html\n} if $hint; + print qq{
\n}; +} + +# Helper to output a select field row +sub field_select { + my ($key, $label, $options_ref) = @_; + my $current = $conf->{$key} // ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; +} + +# ── Form ───────────────────────────────────────────────────── + +print qq{
\n}; +print GnizaWHM::UI::csrf_hidden_field(); + +# Section: Local Settings +print qq{
\n
\n}; +print qq{

Local Settings

\n}; +field_text('TEMP_DIR', 'Working Directory', 'Default: /usr/local/gniza/workdir'); +print qq{
\n
\n}; + +# Section: Account Filtering +print qq{
\n
\n}; +print qq{

Account Filtering

\n}; +my $inc_val = GnizaWHM::UI::esc($conf->{INCLUDE_ACCOUNTS} // ''); +my $exc_val = GnizaWHM::UI::esc($conf->{EXCLUDE_ACCOUNTS} // ''); +print qq{
\n}; +print qq{ \n}; +print qq{ \n}; +print qq{
\n}; +print qq{
\n}; +print qq{ \n}; +print qq{ \n}; +print qq{
\n}; + +my @accounts = GnizaWHM::UI::get_cpanel_accounts(); +if (@accounts) { + print qq{
}; + print qq{Available accounts: } . GnizaWHM::UI::esc(join(', ', @accounts)); + print qq{
\n}; +} +print qq{
\n
\n}; + +# Section: Logging +print qq{
\n
\n}; +print qq{

Logging

\n}; +field_text('LOG_DIR', 'Log Directory', 'Default: /var/log/gniza'); +field_select('LOG_LEVEL', 'Log Level', ['debug', 'info', 'warn', 'error']); +field_text('LOG_RETAIN', 'Log Retention (days)', 'Default: 90'); +print qq{
\n
\n}; + +# Section: Notifications +print qq{
\n
\n}; +print qq{

Notifications

\n}; +field_text('NOTIFY_EMAIL', 'Email Address', 'Empty = disabled'); +field_select('NOTIFY_ON', 'Notify On', ['always', 'failure', 'never']); +print qq{
\n
\n}; + +# Section: Advanced +print qq{
\n
\n}; +print qq{

Advanced

\n}; +field_text('LOCK_FILE', 'Lock File', 'Default: /var/run/gniza.lock'); +field_text('SSH_TIMEOUT', 'SSH Timeout (seconds)', 'Default: 30'); +field_text('SSH_RETRIES', 'SSH Retries', 'Default: 3'); +field_text('RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'Additional flags for rsync'); +print qq{
\n
\n}; + +# Submit +print qq{
\n}; +print qq{ \n}; +print qq{
\n}; + +print qq{
\n}; + +print GnizaWHM::UI::page_footer(); +Whostmgr::HTMLInterface::footer(); diff --git a/whm/gniza-whm/setup.cgi b/whm/gniza-whm/setup.cgi new file mode 100644 index 0000000..e16e947 --- /dev/null +++ b/whm/gniza-whm/setup.cgi @@ -0,0 +1,523 @@ +#!/usr/local/cpanel/3rdparty/bin/perl +# gniza WHM Plugin — Setup Wizard +# 3-step wizard: SSH Key → Remote Destination → Schedule +use strict; +use warnings; + +use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib'; + +use Whostmgr::HTMLInterface (); +use Cpanel::Form (); +use File::Copy (); +use GnizaWHM::Config; +use GnizaWHM::Validator; +use GnizaWHM::Cron; +use GnizaWHM::UI; + +my $form = Cpanel::Form::parseform(); +my $method = $ENV{'REQUEST_METHOD'} // 'GET'; +my $step = $form->{'step'} // '1'; + +if ($step eq 'test') { handle_test_connection() } +elsif ($step eq '2') { handle_step2() } +elsif ($step eq '3') { handle_step3() } +else { handle_step1() } + +exit; + +# ── Test Connection (JSON) ──────────────────────────────────── + +sub handle_test_connection { + print "Content-Type: application/json\r\n\r\n"; + + my $host = $form->{'host'} // ''; + my $port = $form->{'port'} || '22'; + my $user = $form->{'user'} || 'root'; + my $key = $form->{'key'} // ''; + + if ($host eq '' || $key eq '') { + print qq({"success":false,"message":"Host and SSH key path are required."}); + exit; + } + + my ($ok, $err) = GnizaWHM::UI::test_ssh_connection($host, $port, $user, $key); + if ($ok) { + print qq({"success":true,"message":"SSH connection successful."}); + } else { + $err //= 'Unknown error'; + $err =~ s/\\/\\\\/g; + $err =~ s/"/\\"/g; + $err =~ s/\n/\\n/g; + print qq({"success":false,"message":"SSH connection failed: $err"}); + } + exit; +} + +# ── Step 1: SSH Key ────────────────────────────────────────── + +sub handle_step1 { + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Setup Wizard', '', '/cgi/gniza-whm/setup.cgi'); + + print GnizaWHM::UI::page_header('gniza Setup Wizard'); + render_steps_indicator(1); + + my $keys = GnizaWHM::UI::detect_ssh_keys(); + + print qq{
\n
\n}; + print qq{

Step 1: SSH Key

\n}; + print qq{

gniza uses SSH keys to connect to remote backup destinations. An SSH key must be set up before adding a remote.

\n}; + + if (@$keys) { + print qq{
\n}; + print qq{

Existing keys found:

\n}; + print qq{\n}; + print qq{\n}; + print qq{\n}; + my $first = 1; + for my $k (@$keys) { + my $checked = $first ? ' checked' : ''; + my $pub = $k->{has_pub} ? 'Available' : 'Missing'; + my $esc_path = GnizaWHM::UI::esc($k->{path}); + my $esc_type = GnizaWHM::UI::esc($k->{type}); + print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{\n}; + $first = 0; + } + print qq{\n
TypePathPublic Key
$esc_type$esc_path$pub
\n}; + + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{
\n}; + + print qq{
\n}; + print qq{\n}; + print qq{
\n}; + print qq{ \n}; + print qq{ Cancel\n}; + print qq{
\n}; + print qq{
\n}; + } else { + print qq{
No SSH keys found in /root/.ssh/. You need to create one first.
\n}; + } + + # Always show key generation instructions + print qq{
\n}; + print qq{

Generate a new SSH key (if needed):

\n}; + print qq{
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""
\n}; + print qq{

Copy the public key to the remote server:

\n}; + print qq{
ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host
\n}; + print qq{

Run these commands in WHM → Server Configuration → Terminal, or via SSH.

\n}; + print qq{
\n}; + + unless (@$keys) { + print qq{
\n}; + print qq{\n}; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + print qq{
\n}; + print qq{ \n}; + print qq{ Cancel\n}; + print qq{
\n}; + print qq{
\n}; + } + + print qq{
\n
\n}; + + # JS to resolve selected key into key_path param + print <<'JS'; + +JS + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Step 2: Remote Destination ─────────────────────────────── + +sub handle_step2 { + my @errors; + my $key_path = $form->{'key_path'} // '/root/.ssh/id_ed25519'; + + if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + my $name = $form->{'remote_name'} // ''; + my $name_err = GnizaWHM::Validator::validate_remote_name($name); + push @errors, $name_err if $name_err; + + if (!@errors && -f GnizaWHM::UI::remote_conf_path($name)) { + push @errors, "A remote named '$name' already exists."; + } + + my %data; + for my $key (@GnizaWHM::Config::REMOTE_KEYS) { + $data{$key} = $form->{$key} // ''; + } + + if (!@errors) { + my $validation_errors = GnizaWHM::Validator::validate_remote_config(\%data); + push @errors, @$validation_errors; + } + + if (!@errors) { + my ($ssh_ok, $ssh_err) = GnizaWHM::UI::test_ssh_connection( + $data{REMOTE_HOST}, + $data{REMOTE_PORT} || '22', + $data{REMOTE_USER} || 'root', + $data{REMOTE_KEY}, + ); + push @errors, "SSH connection test failed: $ssh_err" unless $ssh_ok; + } + + if (!@errors) { + my $dest = GnizaWHM::UI::remote_conf_path($name); + my $example = GnizaWHM::UI::remote_example_path(); + if (-f $example) { + File::Copy::copy($example, $dest) + or do { push @errors, "Failed to create remote file: $!"; goto RENDER_STEP2; }; + } + my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::REMOTE_KEYS); + if ($ok) { + print "Status: 302 Found\r\n"; + print "Location: setup.cgi?step=3&remote_name=" . _uri_escape($name) . "\r\n\r\n"; + exit; + } else { + push @errors, "Failed to save remote config: $err"; + } + } + } + + RENDER_STEP2: + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Setup Wizard', '', '/cgi/gniza-whm/setup.cgi'); + + print GnizaWHM::UI::page_header('gniza Setup Wizard'); + render_steps_indicator(2); + + if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); + } + + my $conf = {}; + my $name_val = ''; + if ($method eq 'POST') { + for my $key (@GnizaWHM::Config::REMOTE_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } + $name_val = GnizaWHM::UI::esc($form->{'remote_name'} // ''); + } else { + $conf->{REMOTE_KEY} = $key_path; + $conf->{REMOTE_PORT} = '22'; + $conf->{REMOTE_USER} = 'root'; + $conf->{REMOTE_BASE} = '/backups'; + $conf->{RETENTION_COUNT} = '30'; + $conf->{BWLIMIT} = '0'; + } + + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print GnizaWHM::UI::csrf_hidden_field(); + + print qq{
\n
\n}; + print qq{

Step 2: Remote Destination

\n}; + print qq{

Configure the remote server where backups will be stored.

\n}; + + _wiz_field('remote_name', $name_val, 'Remote Name', 'Letters, digits, hyphens, underscores'); + _wiz_field_conf($conf, 'REMOTE_HOST', 'Hostname / IP', 'Required'); + _wiz_field_conf($conf, 'REMOTE_PORT', 'SSH Port', 'Default: 22'); + _wiz_field_conf($conf, 'REMOTE_USER', 'SSH User', 'Default: root'); + _wiz_field_conf($conf, 'REMOTE_KEY', 'SSH Private Key', 'Absolute path, required'); + _wiz_field_conf($conf, 'REMOTE_BASE', 'Remote Base Dir', 'Default: /backups'); + _wiz_field_conf($conf, 'BWLIMIT', 'Bandwidth Limit', 'KB/s, 0 = unlimited'); + _wiz_field_conf($conf, 'RETENTION_COUNT', 'Snapshots to Keep', 'Default: 30'); + _wiz_field_conf($conf, 'RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'Optional'); + print qq{
\n
\n}; + + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ Back\n}; + print qq{
\n}; + + print qq{
\n}; + + print <<'JS'; + +JS + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Step 3: Schedule (writes to schedules.d/) ──────────────── + +sub handle_step3 { + my $remote_name = $form->{'remote_name'} // ''; + my @errors; + + my $name_err = GnizaWHM::Validator::validate_remote_name($remote_name); + if ($name_err) { + GnizaWHM::UI::set_flash('error', 'Invalid remote name. Please start the wizard again.'); + print "Status: 302 Found\r\n"; + print "Location: setup.cgi\r\n\r\n"; + exit; + } + + my $remote_conf_path = GnizaWHM::UI::remote_conf_path($remote_name); + unless (-f $remote_conf_path) { + GnizaWHM::UI::set_flash('error', "Remote '$remote_name' not found. Please start the wizard again."); + print "Status: 302 Found\r\n"; + print "Location: setup.cgi\r\n\r\n"; + exit; + } + + if ($method eq 'POST') { + unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) { + push @errors, 'Invalid or expired form token. Please try again.'; + } + + my $schedule = $form->{SCHEDULE} // ''; + + if ($schedule ne '' && !@errors) { + # Create a schedule config in schedules.d/ targeting this remote + my $sched_name = $remote_name; + my %data = ( + SCHEDULE => $schedule, + SCHEDULE_TIME => $form->{SCHEDULE_TIME} // '02:00', + SCHEDULE_DAY => $form->{SCHEDULE_DAY} // '', + SCHEDULE_CRON => $form->{SCHEDULE_CRON} // '', + REMOTES => $remote_name, + ); + + my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data); + push @errors, @$validation_errors; + + if (!@errors) { + my $sched_path = GnizaWHM::UI::schedule_conf_path($sched_name); + my $example = GnizaWHM::UI::schedule_example_path(); + if (-f $example) { + File::Copy::copy($example, $sched_path) + or do { push @errors, "Failed to create schedule file: $!"; }; + } + if (!@errors) { + my ($ok, $err) = GnizaWHM::Config::write($sched_path, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS); + push @errors, "Failed to save schedule: $err" unless $ok; + } + } + + if (!@errors) { + my ($ok, $stdout, $stderr) = GnizaWHM::Cron::install_schedules(); + if (!$ok) { + push @errors, "Schedule saved but cron install failed: $stderr"; + } + } + } + + if (!@errors) { + GnizaWHM::UI::set_flash('success', 'Setup complete! Your first remote destination is configured.'); + print "Status: 302 Found\r\n"; + print "Location: index.cgi\r\n\r\n"; + exit; + } + } + + print "Content-Type: text/html\r\n\r\n"; + Whostmgr::HTMLInterface::defheader('gniza Setup Wizard', '', '/cgi/gniza-whm/setup.cgi'); + + print GnizaWHM::UI::page_header('gniza Setup Wizard'); + render_steps_indicator(3); + + if (@errors) { + print GnizaWHM::UI::render_errors(\@errors); + } + + my $esc_name = GnizaWHM::UI::esc($remote_name); + + # Load existing schedule data from schedules.d/ if it exists, else from POST + my $conf = {}; + if (@errors && $method eq 'POST') { + for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) { + $conf->{$key} = $form->{$key} // ''; + } + } elsif (-f GnizaWHM::UI::schedule_conf_path($remote_name)) { + $conf = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($remote_name), 'schedule'); + } + + print qq{
\n}; + print qq{\n}; + print qq{\n}; + print GnizaWHM::UI::csrf_hidden_field(); + + print qq{
\n
\n}; + print qq{

Step 3: Backup Schedule

\n}; + print qq{

Set up an automatic backup schedule for remote $esc_name. You can skip this and configure it later.

\n}; + + my $sched = $conf->{SCHEDULE} // ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{
\n}; + + _wiz_field_conf($conf, 'SCHEDULE_TIME', 'Time (HH:MM)', 'Default: 02:00'); + + print qq{
\n}; + _wiz_field_conf($conf, 'SCHEDULE_DAY', 'Day', 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)'); + print qq{
\n}; + + print qq{
\n}; + _wiz_field_conf($conf, 'SCHEDULE_CRON', 'Cron Expression', '5-field cron (for custom only)'); + print qq{
\n}; + + print qq{
\n
\n}; + + print qq{
\n}; + print qq{ \n}; + print qq{ Skip\n}; + print qq{
\n}; + + print qq{
\n}; + + print <<'JS'; + +JS + + print GnizaWHM::UI::page_footer(); + Whostmgr::HTMLInterface::footer(); +} + +# ── Shared Helpers ─────────────────────────────────────────── + +sub render_steps_indicator { + my ($current) = @_; + my @labels = ('SSH Key', 'Remote', 'Schedule'); + print qq{
    \n}; + for my $i (1..3) { + my $class = 'step'; + $class .= ' step-primary' if $i <= $current; + print qq{
  • $labels[$i-1]
  • \n}; + } + print qq{
\n}; +} + +sub _wiz_field { + my ($name, $val, $label, $hint) = @_; + $val = GnizaWHM::UI::esc($val // ''); + my $hint_html = $hint ? qq{ $hint} : ''; + print qq{
\n}; + print qq{ \n}; + print qq{ \n}; + print qq{ $hint_html\n} if $hint; + print qq{
\n}; +} + +sub _wiz_field_conf { + my ($conf, $key, $label, $hint) = @_; + _wiz_field($key, $conf->{$key}, $label, $hint); +} + +sub _uri_escape { + my ($str) = @_; + $str =~ s/([^A-Za-z0-9._~-])/sprintf("%%%02X", ord($1))/ge; + return $str; +}