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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-04 02:39:39 +02:00
commit 1459bd1b8b
45 changed files with 11397 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
whm/gniza-whm/assets/node_modules/

577
CLAUDE.md Normal file
View File

@@ -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/<name>.conf`, overriding REMOTE_* globals
- `_restore_remote_globals()` — restore saved snapshot
This keeps the change set minimal — no existing function signatures needed modification.
**Critical pattern:** Always call `_save_remote_globals()` before a remote loop, `load_remote()` inside the loop, and `_restore_remote_globals()` after the loop:
```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/<name>.conf` — per-remote config (REMOTE_*, retention, transfer)
4. `/etc/gniza/schedules.d/<name>.conf` — per-schedule config (timing, target remotes)
5. CLI flags (`--debug`, `--config=PATH`)
### Snapshot Layout
- Remote path: `$REMOTE_BASE/<hostname>/accounts/<user>/snapshots/<timestamp>`
- Timestamp format: `YYYY-MM-DDTHHMMSS` (UTC)
- pkgacct content lives directly in the snapshot root (no `pkgacct/` wrapper)
- `homedir/` subdirectory sits alongside the pkgacct content
```
<timestamp>/
├── mysql/ ← pkgacct: SQL dumps (*.sql.gz)
├── mysql.sql ← pkgacct: database grants
├── cp/ ← pkgacct: cPanel metadata
├── ... ← other pkgacct files
└── homedir/ ← home directory
```
**SSH remotes:** In-progress snapshots have `.partial` suffix. `latest` symlink points to newest completed snapshot. Uses `rsync --link-dest` for deduplication.
**Cloud remotes (S3/GDrive):** Cloud storage has no atomic rename or symlinks. Instead:
- Uploads go directly to `<timestamp>/` (no `.partial` suffix)
- A `.complete` marker file is created on success
- `latest.txt` text file stores the newest timestamp (replaces symlink)
- Directories without `.complete` are treated as partials and purged on next run
**Backward compat:** Old snapshots may have a `pkgacct/` subdirectory. Verify and restore detect the format automatically (`test -d "$snap_path/pkgacct"` for SSH, `rclone_exists` for cloud) and adjust paths accordingly. `transfer_pkgacct()` link-dest also handles old-format previous snapshots.
### Decoupled Schedules
Schedules are independent from remotes. Each schedule lives in `/etc/gniza/schedules.d/<name>.conf` and defines when backups run and which remotes to target. This allows multiple schedules targeting different sets of remotes.
Cron entries are tagged with `# gniza:<name>` 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/<module>.sh` — each file focuses on one responsibility
- Public functions: `snake_case` (e.g., `transfer_pkgacct`, `list_remote_snapshots`)
- Private/helper functions: `_prefixed` (e.g., `_backup_to_current_remote`, `_save_remote_globals`)
- CLI commands: `cmd_<name>()` in `bin/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=<prev>`
- `--fake-super` stores real uid/gid/permissions as xattrs on the remote, preserving ownership even when the remote user is not root
- Bandwidth limiting: `--bwlimit=$BWLIMIT`
- Extra opts: `$RSYNC_EXTRA_OPTS` (split by word)
### Rclone Transport (REMOTE_TYPE=s3 or gdrive)
- All cloud operations go through `lib/rclone.sh`
- `_is_rclone_mode()` returns true when `REMOTE_TYPE` is `s3` or `gdrive`
- Each library function (snapshot, transfer, retention, verify, restore) checks `_is_rclone_mode` at the top and dispatches to `rclone_*` equivalents
- Temp rclone config generated per-operation from stored globals (`_build_rclone_config()`), cleaned up after
- S3 path: `remote:${S3_BUCKET}${REMOTE_BASE}/<hostname>/accounts/...`
- GDrive path: `remote:${REMOTE_BASE}/<hostname>/accounts/...`
- Bandwidth limiting: `--bwlimit=${BWLIMIT}k`
- Retries with exponential backoff mirror rsync behavior
## Configuration Files
### Main Config (`/etc/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/<name>.conf`)
**Common (all types):**
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `REMOTE_TYPE` | No | `ssh` | `ssh`, `s3`, or `gdrive` |
| `REMOTE_BASE` | No | `/backups` | Remote base directory/path |
| `BWLIMIT` | No | `0` | Bandwidth limit KB/s |
| `RETENTION_COUNT` | No | `30` | Snapshots to keep |
**SSH-specific (REMOTE_TYPE=ssh):**
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `REMOTE_HOST` | Yes | — | Remote hostname/IP |
| `REMOTE_PORT` | No | `22` | SSH port |
| `REMOTE_USER` | No | `root` | SSH user |
| `REMOTE_AUTH_METHOD` | No | `key` | `key` or `password` |
| `REMOTE_KEY` | Yes (key) | — | SSH private key path |
| `REMOTE_PASSWORD` | Yes (password) | — | SSH password (requires sshpass) |
| `RSYNC_EXTRA_OPTS` | No | (empty) | Extra rsync options |
**S3-specific (REMOTE_TYPE=s3):**
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `S3_ACCESS_KEY_ID` | Yes | — | S3 access key |
| `S3_SECRET_ACCESS_KEY` | Yes | — | S3 secret key |
| `S3_REGION` | No | `us-east-1` | AWS region |
| `S3_ENDPOINT` | No | (empty) | Custom endpoint for S3-compatible services |
| `S3_BUCKET` | Yes | — | Bucket name |
**Google Drive-specific (REMOTE_TYPE=gdrive):**
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GDRIVE_SERVICE_ACCOUNT_FILE` | Yes | — | Path to service account JSON key file |
| `GDRIVE_ROOT_FOLDER_ID` | No | (empty) | Root folder ID |
### Schedule Config (`/etc/gniza/schedules.d/<name>.conf`)
Schedules are decoupled from remotes. Each schedule targets one or more remotes.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SCHEDULE` | Yes | — | `hourly\|daily\|weekly\|monthly\|custom` |
| `SCHEDULE_TIME` | No | `02:00` | HH:MM (24-hour) |
| `SCHEDULE_DAY` | Conditional | — | Hours interval (1-23) for hourly, day-of-week (0-6) for weekly, day-of-month (1-28) for monthly |
| `SCHEDULE_CRON` | Conditional | — | Full 5-field cron expr (for `custom` only) |
| `REMOTES` | No | (all) | Comma-separated remote names to target |
## 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/<module>.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_<name>()` 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_<NAME>` to `lib/constants.sh`
2. Add to `load_config()` in `lib/config.sh` with fallback
3. Add validation in `validate_config()` if needed
4. Add to `etc/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/<name>.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 `<style>` blocks (except the auto-generated inline delivery in `page_header()`), no CSS files other than the Tailwind build output.
### WHM CSS Build System (Tailwind v4 + DaisyUI v5)
All WHM pages use Tailwind CSS v4 with DaisyUI v5 for styling. The CSS is built from source and committed.
**Build:**
```bash
cd whm/gniza-whm/assets && npm install && npm run build:css
```
**Key files:**
- `assets/src/input.css` — Tailwind entry point with DaisyUI plugin config
- `assets/src/safelist.html` — Class safelist (required because Tailwind v4 scanner doesn't recognize `.cgi`/`.pm` file extensions)
- `assets/gniza-whm.css` — Built output (committed to repo)
**WHM CSS delivery quirks:**
- WHM's CGI directory cannot serve static files directly
- WHM URLs require session token prefix (`/cpsessXXXXX/`)
- CSS is inlined via `<style>` tag in `page_header()` — reads from disk, strips `@layer` wrappers, embeds inline
- Tailwind v4 wraps all CSS in `@layer` directives which have lower specificity than WHM's un-layered CSS — `_unwrap_layers()` strips these
- `@import "tailwindcss" important;` adds `!important` to all utilities so they override WHM's styles
**Adding new CSS classes:**
1. Add the class to `assets/src/safelist.html` (since Tailwind can't scan `.cgi`/`.pm` files)
2. Rebuild: `cd whm/gniza-whm/assets && npm run build:css`
3. Deploy the updated `gniza-whm.css` to the server
### Deploying changes to production
**WHM plugin (CGIs, Perl modules, CSS):**
```bash
# Deploy all WHM files
rsync -avz -e 'ssh -p 2223' whm/gniza-whm/ \
root@192.168.100.13:/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/ \
--exclude=assets/node_modules --exclude=assets/src --exclude=assets/package.json --exclude=assets/package-lock.json
ssh -p 2223 root@192.168.100.13 'chmod 0755 /usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/*.cgi'
```
**CLI + libraries:**
```bash
rsync -avz -e 'ssh -p 2223' bin/ root@192.168.100.13:/usr/local/gniza/bin/
rsync -avz -e 'ssh -p 2223' lib/ root@192.168.100.13:/usr/local/gniza/lib/
rsync -avz -e 'ssh -p 2223' etc/ root@192.168.100.13:/usr/local/gniza/etc/
ssh -p 2223 root@192.168.100.13 'chmod 0755 /usr/local/gniza/bin/gniza && mkdir -p /etc/gniza/schedules.d'
```

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

381
README.md Normal file
View File

@@ -0,0 +1,381 @@
# gniza
cPanel Backup, Restore & Disaster Recovery tool.
Uses `pkgacct --nocompress --skiphomedir` for account backups, gzips SQL files individually, and transfers everything (including homedirs) to remote destinations with incremental snapshots. Supports three remote types: **SSH** (rsync with hardlink-based `--link-dest`), **Amazon S3** / S3-compatible (via rclone), and **Google Drive** (via rclone). Supports multiple remote destinations with independent schedules and retention policies.
## Quick Start
```bash
# Install
sudo bash scripts/install.sh
# Interactive setup (creates config + first remote)
sudo gniza init
# Add additional remote destinations
sudo gniza init remote offsite
# Test backup (dry run)
sudo gniza backup --dry-run
# Run backup
sudo gniza backup
# Install cron schedules for all remotes
sudo gniza schedule install
```
## Commands
```
gniza backup [--account=NAME] [--remote=NAME] [--dry-run]
gniza restore account <name> [--remote=NAME] [--timestamp=TS] [--force]
gniza restore files <name> [--remote=NAME] [--path=subpath] [--timestamp=TS]
gniza restore database <name> <dbname> [--remote=NAME] [--timestamp=TS]
gniza restore server [--remote=NAME] [--timestamp=TS]
gniza list [--account=NAME] [--remote=NAME]
gniza verify [--account=NAME] [--remote=NAME]
gniza status
gniza remote list
gniza remote delete <name>
gniza schedule {install|show|remove}
gniza init
gniza init remote <name>
gniza version
gniza help
```
### Global Options
| Option | Description |
|--------|-------------|
| `--config=PATH` | Alternate config file (default: `/etc/gniza/gniza.conf`) |
| `--remote=NAME` | Target a specific remote from `/etc/gniza/remotes.d/` |
| `--debug` | Enable debug logging |
## Configuration
### Main Config
**File:** `/etc/gniza/gniza.conf`
Controls local settings (accounts, logging, notifications). Remote destinations are configured in `/etc/gniza/remotes.d/`.
```bash
# Local Settings
TEMP_DIR="/usr/local/gniza/workdir" # Working dir for pkgacct output
INCLUDE_ACCOUNTS="" # Comma-separated, empty = all
EXCLUDE_ACCOUNTS="nobody" # Comma-separated exclusions
# Logging
LOG_DIR="/var/log/gniza"
LOG_LEVEL="info" # debug, info, warn, error
LOG_RETAIN=90 # Days to keep log files
# Notifications
NOTIFY_EMAIL="" # Email for notifications
NOTIFY_ON="failure" # always, failure, never
# Advanced
LOCK_FILE="/var/run/gniza.lock"
SSH_TIMEOUT=30
SSH_RETRIES=3
RSYNC_EXTRA_OPTS=""
```
See `etc/gniza.conf.example` for the full template.
### Remote Destinations
Back up to one or more destinations with independent schedules, retention policies, and bandwidth limits. Supports SSH, Amazon S3 (and S3-compatible services like MinIO, Wasabi, Backblaze B2), and Google Drive. Remote destinations are configured as individual files in `/etc/gniza/remotes.d/`.
#### Setup
```bash
# Interactive setup (recommended)
sudo gniza init remote nas
sudo gniza init remote offsite
# Or copy the template manually
sudo cp /etc/gniza/remote.conf.example /etc/gniza/remotes.d/nas.conf
sudo vi /etc/gniza/remotes.d/nas.conf
# List configured remotes
sudo gniza remote list
# Delete a remote
sudo gniza remote delete nas
```
#### Remote Config Format
Each file in `/etc/gniza/remotes.d/<name>.conf`:
```bash
# Remote type: "ssh" (default), "s3", or "gdrive"
REMOTE_TYPE="ssh"
# ── SSH Remote ──────────────────────────────────
REMOTE_HOST="192.168.1.100" # Required (SSH only)
REMOTE_PORT=22
REMOTE_USER="root"
REMOTE_AUTH_METHOD="key" # "key" or "password"
REMOTE_KEY="/root/.ssh/id_rsa" # Required for key auth
REMOTE_PASSWORD="" # Required for password auth (needs sshpass)
# ── S3 Remote ───────────────────────────────────
S3_ACCESS_KEY_ID="" # Required (S3 only)
S3_SECRET_ACCESS_KEY="" # Required (S3 only)
S3_REGION="us-east-1"
S3_ENDPOINT="" # For S3-compatible services
S3_BUCKET="" # Required (S3 only)
# ── Google Drive Remote ─────────────────────────
GDRIVE_SERVICE_ACCOUNT_FILE="" # Required (GDrive only)
GDRIVE_ROOT_FOLDER_ID="" # Optional
# ── Common ──────────────────────────────────────
REMOTE_BASE="/backups"
BWLIMIT=0
RETENTION_COUNT=30
RSYNC_EXTRA_OPTS="" # SSH only
```
S3 and Google Drive remotes require `rclone` installed on the server.
See `etc/remote.conf.example` for the full template.
#### Usage
Without `--remote`, backup/list/verify operate on **all** configured remotes. Restore requires `--remote` to specify the source.
```bash
# Back up to all remotes
sudo gniza backup
# Back up to a specific remote
sudo gniza backup --remote=nas
# List snapshots on a specific remote
sudo gniza list --remote=offsite
# Restore requires explicit remote
sudo gniza restore account johndoe --remote=nas
```
## Schedule Management
Manage cron entries based on each remote's `SCHEDULE` settings. Each remote gets a tagged cron entry for clean install/remove.
```bash
sudo gniza schedule install # Install cron entries for all remotes
sudo gniza schedule show # Show current gniza cron entries
sudo gniza schedule remove # Remove all gniza cron entries
```
Example crontab entries:
```
# gniza:nas
0 2 * * * /usr/local/bin/gniza backup --remote=nas >> /var/log/gniza/cron-nas.log 2>&1
# gniza:offsite
0 3 * * 0 /usr/local/bin/gniza backup --remote=offsite >> /var/log/gniza/cron-offsite.log 2>&1
```
## Remote Directory Structure
### SSH Remotes
```
$REMOTE_BASE/<hostname>/accounts/<user>/
├── snapshots/
│ ├── 2026-03-03T020000/ # Completed snapshot
│ │ ├── mysql/ # SQL dumps (*.sql.gz)
│ │ ├── mysql.sql # Database grants
│ │ ├── cp/ # cPanel metadata
│ │ ├── ... # Other pkgacct files
│ │ └── homedir/ # Full home directory
│ ├── 2026-03-02T020000/ # Previous (hardlinked unchanged files)
│ └── 2026-03-01T020000.partial/ # Incomplete (failed/in-progress)
└── latest -> snapshots/2026-03-03T020000
```
### Cloud Remotes (S3/GDrive)
```
$REMOTE_BASE/<hostname>/accounts/<user>/snapshots/
├── 2026-03-03T020000/ # Completed snapshot
│ ├── .complete # Completion marker (empty file)
│ ├── mysql/ # SQL dumps (*.sql.gz)
│ ├── ... # Other pkgacct files
│ └── homedir/ # Full home directory
├── 2026-03-02T020000/ # Previous snapshot
│ └── .complete
├── 2026-03-01T020000/ # Partial (no .complete → purged on next run)
└── latest.txt # Contains timestamp of newest snapshot
```
Cloud storage has no atomic rename or symlinks, so `.complete` markers and `latest.txt` replace the `.partial` suffix and `latest` symlink used by SSH remotes.
pkgacct output is stored directly in the snapshot root (no wrapper subdirectory). The `homedir/` sits alongside it.
## Ownership & Permissions
All rsync operations use `--rsync-path="rsync --fake-super"` to preserve file ownership and permissions even when the remote SSH user is not root. The real uid/gid/permissions are stored as extended attributes (`user.rsync.%stat`) on the remote filesystem. On restore, the same flag reads the xattrs back, allowing the local root process to set the correct ownership.
## Backup Flow
1. Load main config (TEMP_DIR, LOG_DIR, accounts, notifications)
2. Resolve target remotes (`--remote=NAME` or all from `remotes.d/`)
3. Test connectivity to all targets upfront (SSH or rclone by type)
4. For each account:
- `pkgacct` ONCE
- Gzip SQL ONCE
- For each remote:
- `load_remote(name)` — swaps REMOTE_*/S3_*/GDRIVE_* globals
- Clean partials, get previous snapshot
- Transfer pkgacct content (rsync for SSH, rclone for cloud)
- Transfer homedir (rsync for SSH, rclone for cloud)
- Finalize snapshot (SSH: rename `.partial` + symlink; cloud: `.complete` marker + `latest.txt`)
- Enforce retention
- Cleanup local temp
5. Summary + notification
If one remote fails for an account, other remotes still receive the backup.
## Restore Workflows
| Workflow | Description |
|----------|-------------|
| **Full account** | Downloads pkgacct data, decompresses SQL, runs `/scripts/restorepkg`, rsyncs homedir, fixes ownership |
| **Selective files** | Rsyncs specific path from remote homedir backup |
| **Single database** | Downloads SQL dump + grants, imports via `mysql` |
| **Full server rebuild** | Restores all accounts found on the remote |
All restore commands require `--remote=NAME` to specify the source.
## Error Handling
- Single account failures don't abort the run
- Exit codes: `0` success, `1` fatal, `2` locked, `5` partial failure
- `.partial` directories mark incomplete snapshots
- rsync retries with exponential backoff (configurable via `SSH_RETRIES`)
- `flock`-based concurrency control prevents parallel runs
- In multi-remote mode, failure on one remote doesn't block others
## File Layout
```
/usr/local/gniza/ # Install directory
├── bin/gniza # CLI entrypoint
├── lib/ # Shell libraries
│ ├── constants.sh # Version, exit codes, colors, defaults
│ ├── utils.sh # die(), require_root(), timestamp, human_*
│ ├── logging.sh # Per-run log files, log_info/warn/error/debug
│ ├── config.sh # Config loading and validation
│ ├── locking.sh # flock-based concurrency control
│ ├── ssh.sh # SSH connectivity, remote_exec, rsync SSH cmd
│ ├── accounts.sh # cPanel account discovery and filtering
│ ├── pkgacct.sh # pkgacct execution, SQL gzip, temp cleanup
│ ├── snapshot.sh # Timestamp naming, list/resolve snapshots
│ ├── transfer.sh # rsync --link-dest transfers, finalize
│ ├── retention.sh # Prune old snapshots beyond RETENTION_COUNT
│ ├── verify.sh # Remote backup integrity checks
│ ├── notify.sh # Email notifications
│ ├── restore.sh # Full account, files, database, server restore
│ ├── remotes.sh # Remote discovery and context switching
│ └── schedule.sh # Cron management for multi-remote schedules
└── etc/
├── gniza.conf.example # Main config template
└── remote.conf.example # Remote destination template
/etc/gniza/ # Runtime configuration
├── gniza.conf # Main config
└── remotes.d/ # Remote destination configs
├── nas.conf
└── offsite.conf
/var/log/gniza/ # Log files
├── gniza-20260303-020000.log # Per-run logs
├── cron-nas.log # Per-remote cron output
└── cron-offsite.log
```
## WHM Plugin
gniza includes a WHM plugin for managing backups through the cPanel/WHM web interface.
### Installation
The plugin is installed automatically by `scripts/install.sh`. It registers with WHM at **Plugins > gniza Backup Manager**.
Plugin files are deployed to `/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/`.
### Setup Wizard
When gniza is not yet configured (no remotes in `/etc/gniza/remotes.d/`), the dashboard automatically redirects to a **3-step setup wizard**:
1. **SSH Key** — Detects existing keys in `/root/.ssh/` (`id_ed25519`, `id_rsa`, `id_ecdsa`, `id_dsa`). Lets you select one or enter a custom path. Shows `ssh-keygen` and `ssh-copy-id` commands for creating new keys.
2. **Remote Destination** — Configure the first remote: name, host, port, user, SSH key (pre-filled from step 1), base path, bandwidth limit, and retention count. Creates a config file in `/etc/gniza/remotes.d/`.
3. **Schedule** — Optionally set a backup schedule (hourly/daily/weekly/monthly/custom) for the new remote. Installs the cron entry automatically. Can be skipped.
The wizard is also accessible anytime from the dashboard quick links ("Run Setup Wizard").
### Pages
| Page | URL | Description |
|------|-----|-------------|
| Dashboard | `index.cgi` | Overview, remote listing, cron status, quick links |
| Settings | `settings.cgi` | Edit main config (`/etc/gniza/gniza.conf`) |
| Remotes | `remotes.cgi` | Add/edit/delete remote destinations |
| Schedules | `schedules.cgi` | View and manage cron schedules |
| Setup Wizard | `setup.cgi` | Guided initial configuration |
### SSH Key Guidance
When adding a new remote (via Remotes > Add Remote), an SSH key guidance block is displayed above the form showing:
- Detected existing keys on the server
- Commands to generate a new key and copy it to the remote
### Plugin File Layout
```
whm/
├── gniza-whm.conf # WHM AppConfig registration
└── gniza-whm/
├── index.cgi # Dashboard
├── setup.cgi # Setup wizard (3 steps)
├── settings.cgi # Main config editor
├── remotes.cgi # Remote CRUD
├── schedules.cgi # Cron management
├── assets/
│ └── gniza-whm.css # Shared styles
└── lib/GnizaWHM/
├── Config.pm # Config parser/writer (pure Perl)
├── Validator.pm # Input validation
├── Cron.pm # Cron read + allowlisted gniza commands
└── UI.pm # Navigation, flash, CSRF, HTML helpers
```
## Production Server
gniza is deployed on the following server:
| Server | Host | SSH Port |
|--------|------|----------|
| Production (cPanel) | `192.168.100.13` | `2223` |
Hostname: `181-79-81-251.cprapid.com`
## Running Tests
```bash
bash tests/test_utils.sh
```
## License
MIT

1336
bin/gniza Executable file

File diff suppressed because it is too large Load Diff

27
etc/gniza.conf.example Normal file
View File

@@ -0,0 +1,27 @@
# gniza configuration
# Copy to /etc/gniza/gniza.conf and edit
#
# Remote destinations: /etc/gniza/remotes.d/<name>.conf
# Backup schedules: /etc/gniza/schedules.d/<name>.conf
# ── Local Settings ─────────────────────────────────────────────
TEMP_DIR="/usr/local/gniza/workdir" # Working directory for pkgacct output
# ── Account Filtering ──────────────────────────────────────────
INCLUDE_ACCOUNTS="" # Comma-separated list, empty = all accounts
EXCLUDE_ACCOUNTS="nobody" # Comma-separated list of accounts to exclude
# ── Logging ────────────────────────────────────────────────────
LOG_DIR="/var/log/gniza" # Log directory
LOG_LEVEL="info" # debug, info, warn, error
LOG_RETAIN=90 # Days to keep log files
# ── Notifications ──────────────────────────────────────────────
NOTIFY_EMAIL="" # Email address for notifications (empty = disabled)
NOTIFY_ON="failure" # always, failure, never
# ── Advanced ───────────────────────────────────────────────────
LOCK_FILE="/var/run/gniza.lock"
SSH_TIMEOUT=30 # SSH connection timeout in seconds
SSH_RETRIES=3 # Number of rsync retry attempts
RSYNC_EXTRA_OPTS="" # Extra options to pass to rsync

40
etc/remote.conf.example Normal file
View File

@@ -0,0 +1,40 @@
# gniza remote destination config
# Copy to /etc/gniza/remotes.d/<name>.conf and edit
#
# Each file in /etc/gniza/remotes.d/ defines a remote backup destination.
# The filename (without .conf) is the remote name used with --remote=NAME.
# ── Remote Type ───────────────────────────────────────────────
# "ssh" (default), "s3" (Amazon S3 / S3-compatible), or "gdrive" (Google Drive)
REMOTE_TYPE="ssh"
# ── SSH Remote (REMOTE_TYPE="ssh") ────────────────────────────
REMOTE_HOST="" # Required: hostname or IP
REMOTE_PORT=22 # SSH port
REMOTE_USER="root" # SSH user
REMOTE_AUTH_METHOD="key" # "key" (SSH key) or "password" (sshpass)
REMOTE_KEY="/root/.ssh/id_rsa" # Path to SSH private key (key mode only)
REMOTE_PASSWORD="" # SSH password (password mode only, requires sshpass)
# ── S3 Remote (REMOTE_TYPE="s3") ─────────────────────────────
# Works with AWS S3, MinIO, Wasabi, Backblaze B2, and other S3-compatible services.
# Requires rclone installed on the server.
S3_ACCESS_KEY_ID="" # Required: S3 access key
S3_SECRET_ACCESS_KEY="" # Required: S3 secret key
S3_REGION="us-east-1" # AWS region (default: us-east-1)
S3_ENDPOINT="" # Optional: custom endpoint for S3-compatible services
S3_BUCKET="" # Required: bucket name
# ── Google Drive Remote (REMOTE_TYPE="gdrive") ───────────────
# Uses a service account for authentication. Requires rclone installed.
# Create a service account at https://console.cloud.google.com/iam-admin/serviceaccounts
GDRIVE_SERVICE_ACCOUNT_FILE="" # Required: path to service account JSON key file
GDRIVE_ROOT_FOLDER_ID="" # Optional: root folder ID to use as base
# ── Common Settings ───────────────────────────────────────────
REMOTE_BASE="/backups" # Base directory/path on remote
BWLIMIT=0 # Bandwidth limit in KB/s, 0 = unlimited
RSYNC_EXTRA_OPTS="" # Extra options to pass to rsync (SSH only)
# ── Retention ─────────────────────────────────────────────────
RETENTION_COUNT=30 # Number of snapshots to keep per account

17
etc/schedule.conf.example Normal file
View File

@@ -0,0 +1,17 @@
# gniza schedule config
# Copy to /etc/gniza/schedules.d/<name>.conf and edit
#
# Each file in /etc/gniza/schedules.d/ defines a backup schedule.
# The filename (without .conf) is the schedule name.
# ── Schedule ──────────────────────────────────────────────────
SCHEDULE="daily" # hourly, daily, weekly, monthly, custom
SCHEDULE_TIME="02:00" # HH:MM (24-hour)
SCHEDULE_DAY="" # hours between backups (1-23) for hourly
# day-of-week (0=Sun..6=Sat) for weekly
# day-of-month (1-28) for monthly
SCHEDULE_CRON="" # Full cron expression for SCHEDULE=custom
# ── Target Remotes ────────────────────────────────────────────
REMOTES="" # Comma-separated remote names (e.g. "nas,offsite")
# Empty = all configured remotes

62
lib/accounts.sh Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# gniza/lib/accounts.sh — cPanel account discovery, include/exclude filtering
get_all_accounts() {
if [[ -f /etc/trueuserdomains ]]; then
awk '{print $2}' /etc/trueuserdomains | sort -u
elif command -v whmapi1 &>/dev/null; then
whmapi1 listaccts --output=jsonpretty 2>/dev/null \
| grep -oP '"user"\s*:\s*"\K[^"]+' | sort -u
else
die "Cannot discover cPanel accounts: /etc/trueuserdomains not found and whmapi1 unavailable"
fi
}
filter_accounts() {
local all_accounts="$1"
local filtered=()
# Build exclude list
local -A excludes
if [[ -n "$EXCLUDE_ACCOUNTS" ]]; then
IFS=',' read -ra exc_arr <<< "$EXCLUDE_ACCOUNTS"
for acc in "${exc_arr[@]}"; do
acc=$(echo "$acc" | xargs) # trim whitespace
[[ -n "$acc" ]] && excludes["$acc"]=1
done
fi
# If INCLUDE_ACCOUNTS is set, only include those
if [[ -n "$INCLUDE_ACCOUNTS" ]]; then
IFS=',' read -ra inc_arr <<< "$INCLUDE_ACCOUNTS"
for acc in "${inc_arr[@]}"; do
acc=$(echo "$acc" | xargs)
[[ -n "$acc" && -z "${excludes[$acc]:-}" ]] && filtered+=("$acc")
done
else
while IFS= read -r acc; do
[[ -n "$acc" && -z "${excludes[$acc]:-}" ]] && filtered+=("$acc")
done <<< "$all_accounts"
fi
printf '%s\n' "${filtered[@]}"
}
get_backup_accounts() {
local all; all=$(get_all_accounts)
filter_accounts "$all"
}
get_account_homedir() {
local user="$1"
if [[ -f /etc/passwd ]]; then
getent passwd "$user" | cut -d: -f6
else
echo "/home/$user"
fi
}
account_exists() {
local user="$1"
id "$user" &>/dev/null
}

58
lib/config.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# gniza/lib/config.sh — Shell-variable config loading & validation
load_config() {
local config_file="${1:-$DEFAULT_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then
die "Config file not found: $config_file (run 'gniza init' to create one)"
fi
# Source the config (shell variables)
# shellcheck disable=SC1090
source "$config_file" || die "Failed to parse config file: $config_file"
# Apply defaults for optional settings
TEMP_DIR="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
INCLUDE_ACCOUNTS="${INCLUDE_ACCOUNTS:-}"
EXCLUDE_ACCOUNTS="${EXCLUDE_ACCOUNTS:-$DEFAULT_EXCLUDE_ACCOUNTS}"
LOG_DIR="${LOG_DIR:-$DEFAULT_LOG_DIR}"
LOG_LEVEL="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}"
LOG_RETAIN="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}"
NOTIFY_EMAIL="${NOTIFY_EMAIL:-}"
NOTIFY_ON="${NOTIFY_ON:-$DEFAULT_NOTIFY_ON}"
LOCK_FILE="${LOCK_FILE:-$DEFAULT_LOCK_FILE}"
SSH_TIMEOUT="${SSH_TIMEOUT:-$DEFAULT_SSH_TIMEOUT}"
SSH_RETRIES="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}"
# --debug flag overrides config
[[ "${GNIZA_DEBUG:-false}" == "true" ]] && LOG_LEVEL="debug"
export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT
export LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON
export LOCK_FILE SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS
}
validate_config() {
local errors=0
# Per-remote validation is handled by validate_remote() in remotes.sh.
# Here we only validate local/global settings.
case "$NOTIFY_ON" in
always|failure|never) ;;
*) log_error "NOTIFY_ON must be always|failure|never, got: $NOTIFY_ON"; ((errors++)) || true ;;
esac
case "$LOG_LEVEL" in
debug|info|warn|error) ;;
*) log_error "LOG_LEVEL must be debug|info|warn|error, got: $LOG_LEVEL"; ((errors++)) || true ;;
esac
if (( errors > 0 )); then
log_error "Configuration has $errors error(s)"
return 1
fi
return 0
}

51
lib/constants.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# gniza/lib/constants.sh — Version, exit codes, colors
[[ -n "${_GNIZA_CONSTANTS_LOADED:-}" ]] && return 0
_GNIZA_CONSTANTS_LOADED=1
readonly GNIZA_VERSION="0.1.0"
readonly GNIZA_NAME="gniza"
# Exit codes
readonly EXIT_OK=0
readonly EXIT_FATAL=1
readonly EXIT_LOCKED=2
readonly EXIT_PARTIAL=5
# Colors (disabled if not a terminal)
if [[ -t 1 ]]; then
readonly C_RED=$'\033[0;31m'
readonly C_GREEN=$'\033[0;32m'
readonly C_YELLOW=$'\033[0;33m'
readonly C_BLUE=$'\033[0;34m'
readonly C_BOLD=$'\033[1m'
readonly C_RESET=$'\033[0m'
else
readonly C_RED=""
readonly C_GREEN=""
readonly C_YELLOW=""
readonly C_BLUE=""
readonly C_BOLD=""
readonly C_RESET=""
fi
# Defaults
readonly DEFAULT_REMOTE_AUTH_METHOD="key"
readonly DEFAULT_REMOTE_PORT=22
readonly DEFAULT_REMOTE_USER="root"
readonly DEFAULT_REMOTE_BASE="/backups"
readonly DEFAULT_TEMP_DIR="/usr/local/gniza/workdir"
readonly DEFAULT_EXCLUDE_ACCOUNTS="nobody"
readonly DEFAULT_BWLIMIT=0
readonly DEFAULT_RETENTION_COUNT=30
readonly DEFAULT_LOG_DIR="/var/log/gniza"
readonly DEFAULT_LOG_LEVEL="info"
readonly DEFAULT_LOG_RETAIN=90
readonly DEFAULT_NOTIFY_ON="failure"
readonly DEFAULT_LOCK_FILE="/var/run/gniza.lock"
readonly DEFAULT_SSH_TIMEOUT=30
readonly DEFAULT_SSH_RETRIES=3
readonly DEFAULT_REMOTE_TYPE="ssh"
readonly DEFAULT_S3_REGION="us-east-1"
readonly DEFAULT_CONFIG_FILE="/etc/gniza/gniza.conf"

28
lib/locking.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# gniza/lib/locking.sh — flock-based concurrency control
declare -g LOCK_FD=""
acquire_lock() {
local lock_file="${LOCK_FILE:-$DEFAULT_LOCK_FILE}"
local lock_dir; lock_dir=$(dirname "$lock_file")
mkdir -p "$lock_dir" || die "Cannot create lock directory: $lock_dir"
exec {LOCK_FD}>"$lock_file"
if ! flock -n "$LOCK_FD"; then
die "Another gniza process is running (lock: $lock_file)" "$EXIT_LOCKED"
fi
echo $$ >&"$LOCK_FD"
log_debug "Lock acquired: $lock_file (PID $$)"
}
release_lock() {
if [[ -n "$LOCK_FD" ]]; then
flock -u "$LOCK_FD" 2>/dev/null
exec {LOCK_FD}>&- 2>/dev/null
LOCK_FD=""
log_debug "Lock released"
fi
}

57
lib/logging.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# gniza/lib/logging.sh — Per-run log files, log_info/warn/error/debug
declare -g LOG_FILE=""
_log_level_num() {
case "$1" in
debug) echo 0 ;;
info) echo 1 ;;
warn) echo 2 ;;
error) echo 3 ;;
*) echo 1 ;;
esac
}
init_logging() {
local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}"
mkdir -p "$log_dir" || die "Cannot create log directory: $log_dir"
LOG_FILE="$log_dir/gniza-$(date -u +%Y%m%d-%H%M%S).log"
touch "$LOG_FILE" || die "Cannot write to log file: $LOG_FILE"
# Clean old logs
local retain="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}"
find "$log_dir" -name "gniza-*.log" -mtime +"$retain" -delete 2>/dev/null || true
}
_log() {
local level="$1"; shift
local msg="$*"
local configured_level="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}"
local level_num; level_num=$(_log_level_num "$level")
local configured_num; configured_num=$(_log_level_num "$configured_level")
(( level_num < configured_num )) && return 0
local ts; ts=$(date -u +"%Y-%m-%d %H:%M:%S")
local upper; upper=$(echo "$level" | tr '[:lower:]' '[:upper:]')
local line="[$ts] [$upper] $msg"
# Always write to log file if initialized
[[ -n "$LOG_FILE" ]] && echo "$line" >> "$LOG_FILE"
# Print to stderr based on level
case "$level" in
error) echo "${C_RED}${line}${C_RESET}" >&2 ;;
warn) echo "${C_YELLOW}${line}${C_RESET}" >&2 ;;
info) echo "${line}" >&2 ;;
debug) echo "${C_BLUE}${line}${C_RESET}" >&2 ;;
esac
}
log_info() { _log info "$@"; }
log_warn() { _log warn "$@"; }
log_error() { _log error "$@"; }
log_debug() { _log debug "$@"; }

81
lib/notify.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# gniza/lib/notify.sh — Email notifications
send_notification() {
local subject="$1"
local body="$2"
local success="${3:-true}"
# Check if notifications are configured
[[ -z "${NOTIFY_EMAIL:-}" ]] && return 0
case "${NOTIFY_ON:-$DEFAULT_NOTIFY_ON}" in
never) return 0 ;;
failure) [[ "$success" == "true" ]] && return 0 ;;
always) ;;
esac
local hostname; hostname=$(hostname -f)
local full_subject="[gniza] [$hostname] $subject"
log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject"
if command -v mail &>/dev/null; then
echo "$body" | mail -s "$full_subject" "$NOTIFY_EMAIL"
elif command -v sendmail &>/dev/null; then
{
echo "To: $NOTIFY_EMAIL"
echo "Subject: $full_subject"
echo "Content-Type: text/plain; charset=UTF-8"
echo ""
echo "$body"
} | sendmail -t
else
log_warn "No mail command available, cannot send notification"
return 1
fi
log_debug "Notification sent"
return 0
}
send_backup_report() {
local total="$1"
local succeeded="$2"
local failed="$3"
local duration="$4"
local failed_accounts="$5"
local success="true"
local status="SUCCESS"
if (( failed > 0 )); then
if (( succeeded > 0 )); then
status="PARTIAL FAILURE"
else
status="FAILURE"
fi
success="false"
fi
local body=""
body+="Backup Report: $status"$'\n'
body+="=============================="$'\n'
body+="Hostname: $(hostname -f)"$'\n'
body+="Timestamp: $(date -u +"%Y-%m-%d %H:%M:%S UTC")"$'\n'
body+="Duration: $(human_duration "$duration")"$'\n'
body+=""$'\n'
body+="Accounts: $total total, $succeeded succeeded, $failed failed"$'\n'
if [[ -n "$failed_accounts" ]]; then
body+=""$'\n'
body+="Failed accounts:"$'\n'
body+="$failed_accounts"$'\n'
fi
if [[ -n "$LOG_FILE" ]]; then
body+=""$'\n'
body+="Log file: $LOG_FILE"$'\n'
fi
send_notification "Backup $status ($succeeded/$total)" "$body" "$success"
}

74
lib/pkgacct.sh Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# gniza/lib/pkgacct.sh — pkgacct execution, .sql gzipping, temp cleanup
run_pkgacct() {
local user="$1"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local output_dir="$temp_dir/$user"
mkdir -p "$temp_dir" || {
log_error "Failed to create temp directory: $temp_dir"
return 1
}
# Clean any previous attempt
[[ -d "$output_dir" ]] && rm -rf "$output_dir"
log_info "Running pkgacct for $user..."
log_debug "CMD: /usr/local/cpanel/bin/pkgacct --incremental --nocompress --backup --skiphomedir $user $temp_dir"
if ! /usr/local/cpanel/bin/pkgacct --incremental --nocompress --backup --skiphomedir "$user" "$temp_dir"; then
log_error "pkgacct failed for $user"
return 1
fi
if [[ ! -d "$output_dir" ]]; then
log_error "pkgacct output directory not found: $output_dir"
return 1
fi
log_info "pkgacct completed for $user"
return 0
}
gzip_sql_files() {
local user="$1"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local mysql_dir="$temp_dir/$user/mysql"
if [[ ! -d "$mysql_dir" ]]; then
log_debug "No mysql directory for $user, skipping gzip"
return 0
fi
local count=0
while IFS= read -r -d '' sql_file; do
log_debug "Compressing: $sql_file"
if gzip -f "$sql_file"; then
((count++)) || true
else
log_warn "Failed to gzip: $sql_file"
fi
done < <(find "$mysql_dir" -name "*.sql" -print0)
log_info "Compressed $count SQL file(s) for $user"
return 0
}
cleanup_pkgacct() {
local user="$1"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local output_dir="$temp_dir/$user"
if [[ -d "$output_dir" ]]; then
rm -rf "$output_dir"
log_debug "Cleaned up pkgacct temp for $user"
fi
}
cleanup_all_temp() {
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
if [[ -d "$temp_dir" ]]; then
rm -rf "$temp_dir"/*
log_debug "Cleaned up all temp contents: $temp_dir"
fi
}

335
lib/rclone.sh Normal file
View File

@@ -0,0 +1,335 @@
#!/usr/bin/env bash
# gniza/lib/rclone.sh — Rclone transport layer for S3 and Google Drive remotes
[[ -n "${_GNIZA_RCLONE_LOADED:-}" ]] && return 0
_GNIZA_RCLONE_LOADED=1
# ── Mode Detection ────────────────────────────────────────────
_is_rclone_mode() {
[[ "${REMOTE_TYPE:-ssh}" == "s3" || "${REMOTE_TYPE:-ssh}" == "gdrive" ]]
}
# ── Rclone Config Generation ─────────────────────────────────
_build_rclone_config() {
local tmpfile
tmpfile=$(mktemp /tmp/gniza-rclone-XXXXXX.conf) || {
log_error "Failed to create temp rclone config"
return 1
}
chmod 600 "$tmpfile"
case "${REMOTE_TYPE}" in
s3)
cat > "$tmpfile" <<EOF
[remote]
type = s3
provider = ${S3_PROVIDER:-AWS}
access_key_id = ${S3_ACCESS_KEY_ID}
secret_access_key = ${S3_SECRET_ACCESS_KEY}
region = ${S3_REGION:-$DEFAULT_S3_REGION}
EOF
if [[ -n "${S3_ENDPOINT:-}" ]]; then
echo "endpoint = ${S3_ENDPOINT}" >> "$tmpfile"
fi
;;
gdrive)
cat > "$tmpfile" <<EOF
[remote]
type = drive
scope = drive
service_account_file = ${GDRIVE_SERVICE_ACCOUNT_FILE}
EOF
if [[ -n "${GDRIVE_ROOT_FOLDER_ID:-}" ]]; then
echo "root_folder_id = ${GDRIVE_ROOT_FOLDER_ID}" >> "$tmpfile"
fi
;;
*)
rm -f "$tmpfile"
log_error "Unknown REMOTE_TYPE for rclone: ${REMOTE_TYPE}"
return 1
;;
esac
echo "$tmpfile"
}
_cleanup_rclone_config() {
local path="$1"
[[ -n "$path" && -f "$path" ]] && rm -f "$path"
}
# ── Path Construction ─────────────────────────────────────────
_rclone_remote_path() {
local subpath="${1:-}"
local hostname; hostname=$(hostname -f)
case "${REMOTE_TYPE}" in
s3)
echo "remote:${S3_BUCKET}${REMOTE_BASE}/${hostname}${subpath:+/$subpath}"
;;
gdrive)
echo "remote:${REMOTE_BASE}/${hostname}${subpath:+/$subpath}"
;;
esac
}
# ── Core Command Runner ──────────────────────────────────────
# Run an rclone subcommand with auto config lifecycle.
# Usage: _rclone_cmd <subcmd> [args...]
_rclone_cmd() {
local subcmd="$1"; shift
local conf
conf=$(_build_rclone_config) || return 1
local rclone_opts=(--config "$conf")
if [[ "${BWLIMIT:-0}" -gt 0 ]]; then
rclone_opts+=(--bwlimit "${BWLIMIT}k")
fi
log_debug "rclone $subcmd ${rclone_opts[*]} $*"
local rc=0
rclone "$subcmd" "${rclone_opts[@]}" "$@" || rc=$?
_cleanup_rclone_config "$conf"
return "$rc"
}
# ── Transfer Functions ────────────────────────────────────────
rclone_to_remote() {
local source_dir="$1"
local remote_subpath="$2"
local attempt=0
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
local remote_dest; remote_dest=$(_rclone_remote_path "$remote_subpath")
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
while (( attempt < max_retries )); do
((attempt++)) || true
log_debug "rclone copy attempt $attempt/$max_retries: $source_dir -> $remote_dest"
if _rclone_cmd copy "$source_dir" "$remote_dest"; then
log_debug "rclone copy succeeded on attempt $attempt"
return 0
fi
log_warn "rclone copy failed, attempt $attempt/$max_retries"
if (( attempt < max_retries )); then
local backoff=$(( attempt * 10 ))
log_info "Retrying in ${backoff}s..."
sleep "$backoff"
fi
done
log_error "rclone copy failed after $max_retries attempts"
return 1
}
rclone_from_remote() {
local remote_subpath="$1"
local local_dir="$2"
local attempt=0
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
local remote_src; remote_src=$(_rclone_remote_path "$remote_subpath")
mkdir -p "$local_dir" || {
log_error "Failed to create local dir: $local_dir"
return 1
}
while (( attempt < max_retries )); do
((attempt++)) || true
log_debug "rclone copy attempt $attempt/$max_retries: $remote_src -> $local_dir"
if _rclone_cmd copy "$remote_src" "$local_dir"; then
log_debug "rclone download succeeded on attempt $attempt"
return 0
fi
log_warn "rclone download failed, attempt $attempt/$max_retries"
if (( attempt < max_retries )); then
local backoff=$(( attempt * 10 ))
log_info "Retrying in ${backoff}s..."
sleep "$backoff"
fi
done
log_error "rclone download failed after $max_retries attempts"
return 1
}
# ── Snapshot Management ───────────────────────────────────────
rclone_list_dirs() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd lsf --dirs-only "$remote_path" 2>/dev/null | sed 's|/$||'
}
rclone_list_remote_snapshots() {
local user="$1"
local snap_subpath="accounts/${user}/snapshots"
local all_dirs; all_dirs=$(rclone_list_dirs "$snap_subpath") || true
[[ -z "$all_dirs" ]] && return 0
# Filter to dirs with .complete marker, sorted newest first
local completed=""
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
if rclone_exists "${snap_subpath}/${dir}/.complete"; then
completed+="${dir}"$'\n'
fi
done <<< "$all_dirs"
[[ -n "$completed" ]] && echo "$completed" | sort -r
}
rclone_get_latest_snapshot() {
local user="$1"
local snap_subpath="accounts/${user}/snapshots"
# Try reading latest.txt first
local latest; latest=$(rclone_cat "${snap_subpath}/latest.txt" 2>/dev/null) || true
if [[ -n "$latest" ]]; then
# Verify it still exists with .complete marker
if rclone_exists "${snap_subpath}/${latest}/.complete"; then
echo "$latest"
return 0
fi
fi
# Fall back to sorted list
rclone_list_remote_snapshots "$user" | head -1
}
rclone_clean_partial_snapshots() {
local user="$1"
local snap_subpath="accounts/${user}/snapshots"
local all_dirs; all_dirs=$(rclone_list_dirs "$snap_subpath") || true
[[ -z "$all_dirs" ]] && return 0
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
if ! rclone_exists "${snap_subpath}/${dir}/.complete"; then
log_info "Purging incomplete snapshot for $user: $dir"
rclone_purge "${snap_subpath}/${dir}" || {
log_warn "Failed to purge incomplete snapshot: $dir"
}
fi
done <<< "$all_dirs"
}
rclone_finalize_snapshot() {
local user="$1"
local ts="$2"
local snap_subpath="accounts/${user}/snapshots"
# Create .complete marker
rclone_rcat "${snap_subpath}/${ts}/.complete" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" || {
log_error "Failed to create .complete marker for $user/$ts"
return 1
}
# Update latest.txt
rclone_update_latest "$user" "$ts"
}
rclone_update_latest() {
local user="$1"
local ts="$2"
local snap_subpath="accounts/${user}/snapshots"
rclone_rcat "${snap_subpath}/latest.txt" "$ts" || {
log_warn "Failed to update latest.txt for $user"
return 1
}
log_debug "Updated latest.txt for $user -> $ts"
}
rclone_resolve_snapshot() {
local user="$1"
local requested="$2"
local snap_subpath="accounts/${user}/snapshots"
if rclone_exists "${snap_subpath}/${requested}/.complete"; then
echo "$requested"
else
log_error "Snapshot not found or incomplete for $user: $requested"
return 1
fi
}
# ── Remote Operations ─────────────────────────────────────────
rclone_ensure_dir() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd mkdir "$remote_path"
}
rclone_purge() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd purge "$remote_path"
}
rclone_exists() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd lsf "$remote_path" &>/dev/null
}
rclone_size() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd size --json "$remote_path" 2>/dev/null
}
rclone_list_files() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd lsf "$remote_path" 2>/dev/null
}
rclone_cat() {
local remote_subpath="$1"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
_rclone_cmd cat "$remote_path" 2>/dev/null
}
rclone_rcat() {
local remote_subpath="$1"
local content="$2"
local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath")
echo -n "$content" | _rclone_cmd rcat "$remote_path"
}
test_rclone_connection() {
local remote_path
case "${REMOTE_TYPE}" in
s3)
remote_path="remote:${S3_BUCKET}"
;;
gdrive)
remote_path="remote:${REMOTE_BASE}"
;;
*)
log_error "Unknown REMOTE_TYPE: ${REMOTE_TYPE}"
return 1
;;
esac
log_debug "Testing rclone connection to ${REMOTE_TYPE}..."
if _rclone_cmd lsd "$remote_path" &>/dev/null; then
log_debug "Rclone connection test passed"
return 0
else
log_error "Rclone connection test failed for ${REMOTE_TYPE}"
return 1
fi
}

271
lib/remotes.sh Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env bash
# gniza/lib/remotes.sh — Remote discovery and context switching
#
# Remote destinations are configured in /etc/gniza/remotes.d/<name>.conf.
# Each config overrides REMOTE_* globals so existing functions (ssh,
# transfer, snapshot, retention) work unchanged.
readonly REMOTES_DIR="/etc/gniza/remotes.d"
# ── Saved state for legacy globals ─────────────────────────────
declare -g _SAVED_REMOTE_HOST=""
declare -g _SAVED_REMOTE_PORT=""
declare -g _SAVED_REMOTE_USER=""
declare -g _SAVED_REMOTE_AUTH_METHOD=""
declare -g _SAVED_REMOTE_KEY=""
declare -g _SAVED_REMOTE_PASSWORD=""
declare -g _SAVED_REMOTE_BASE=""
declare -g _SAVED_BWLIMIT=""
declare -g _SAVED_RETENTION_COUNT=""
declare -g _SAVED_RSYNC_EXTRA_OPTS=""
declare -g _SAVED_REMOTE_TYPE=""
declare -g _SAVED_S3_ACCESS_KEY_ID=""
declare -g _SAVED_S3_SECRET_ACCESS_KEY=""
declare -g _SAVED_S3_REGION=""
declare -g _SAVED_S3_ENDPOINT=""
declare -g _SAVED_S3_BUCKET=""
declare -g _SAVED_GDRIVE_SERVICE_ACCOUNT_FILE=""
declare -g _SAVED_GDRIVE_ROOT_FOLDER_ID=""
declare -g CURRENT_REMOTE_NAME=""
_save_remote_globals() {
_SAVED_REMOTE_HOST="$REMOTE_HOST"
_SAVED_REMOTE_PORT="$REMOTE_PORT"
_SAVED_REMOTE_USER="$REMOTE_USER"
_SAVED_REMOTE_AUTH_METHOD="${REMOTE_AUTH_METHOD:-key}"
_SAVED_REMOTE_KEY="${REMOTE_KEY:-}"
_SAVED_REMOTE_PASSWORD="${REMOTE_PASSWORD:-}"
_SAVED_REMOTE_BASE="$REMOTE_BASE"
_SAVED_BWLIMIT="$BWLIMIT"
_SAVED_RETENTION_COUNT="$RETENTION_COUNT"
_SAVED_RSYNC_EXTRA_OPTS="$RSYNC_EXTRA_OPTS"
_SAVED_REMOTE_TYPE="${REMOTE_TYPE:-ssh}"
_SAVED_S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-}"
_SAVED_S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-}"
_SAVED_S3_REGION="${S3_REGION:-}"
_SAVED_S3_ENDPOINT="${S3_ENDPOINT:-}"
_SAVED_S3_BUCKET="${S3_BUCKET:-}"
_SAVED_GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}"
_SAVED_GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}"
}
_restore_remote_globals() {
REMOTE_HOST="$_SAVED_REMOTE_HOST"
REMOTE_PORT="$_SAVED_REMOTE_PORT"
REMOTE_USER="$_SAVED_REMOTE_USER"
REMOTE_AUTH_METHOD="$_SAVED_REMOTE_AUTH_METHOD"
REMOTE_KEY="$_SAVED_REMOTE_KEY"
REMOTE_PASSWORD="$_SAVED_REMOTE_PASSWORD"
REMOTE_BASE="$_SAVED_REMOTE_BASE"
BWLIMIT="$_SAVED_BWLIMIT"
RETENTION_COUNT="$_SAVED_RETENTION_COUNT"
RSYNC_EXTRA_OPTS="$_SAVED_RSYNC_EXTRA_OPTS"
REMOTE_TYPE="$_SAVED_REMOTE_TYPE"
S3_ACCESS_KEY_ID="$_SAVED_S3_ACCESS_KEY_ID"
S3_SECRET_ACCESS_KEY="$_SAVED_S3_SECRET_ACCESS_KEY"
S3_REGION="$_SAVED_S3_REGION"
S3_ENDPOINT="$_SAVED_S3_ENDPOINT"
S3_BUCKET="$_SAVED_S3_BUCKET"
GDRIVE_SERVICE_ACCOUNT_FILE="$_SAVED_GDRIVE_SERVICE_ACCOUNT_FILE"
GDRIVE_ROOT_FOLDER_ID="$_SAVED_GDRIVE_ROOT_FOLDER_ID"
CURRENT_REMOTE_NAME=""
}
# ── Discovery ──────────────────────────────────────────────────
# List remote names (filenames without .conf) sorted alphabetically.
list_remotes() {
if [[ ! -d "$REMOTES_DIR" ]]; then
return 0
fi
local f
for f in "$REMOTES_DIR"/*.conf; do
[[ -f "$f" ]] || continue
basename "$f" .conf
done
}
# Return 0 if at least one remote config exists.
has_remotes() {
local remotes
remotes=$(list_remotes)
[[ -n "$remotes" ]]
}
# ── Context switching ──────────────────────────────────────────
# Source a remote config and override REMOTE_* globals.
# Usage: load_remote <name>
load_remote() {
local name="$1"
local conf="$REMOTES_DIR/${name}.conf"
if [[ ! -f "$conf" ]]; then
log_error "Remote config not found: $conf"
return 1
fi
# shellcheck disable=SC1090
source "$conf" || {
log_error "Failed to parse remote config: $conf"
return 1
}
# Apply defaults for optional fields
REMOTE_TYPE="${REMOTE_TYPE:-$DEFAULT_REMOTE_TYPE}"
REMOTE_PORT="${REMOTE_PORT:-$DEFAULT_REMOTE_PORT}"
REMOTE_USER="${REMOTE_USER:-$DEFAULT_REMOTE_USER}"
REMOTE_AUTH_METHOD="${REMOTE_AUTH_METHOD:-$DEFAULT_REMOTE_AUTH_METHOD}"
REMOTE_KEY="${REMOTE_KEY:-}"
REMOTE_PASSWORD="${REMOTE_PASSWORD:-}"
REMOTE_BASE="${REMOTE_BASE:-$DEFAULT_REMOTE_BASE}"
BWLIMIT="${BWLIMIT:-$DEFAULT_BWLIMIT}"
RETENTION_COUNT="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}"
RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}"
# Cloud-specific defaults
S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-}"
S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-}"
S3_REGION="${S3_REGION:-$DEFAULT_S3_REGION}"
S3_ENDPOINT="${S3_ENDPOINT:-}"
S3_BUCKET="${S3_BUCKET:-}"
GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}"
GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}"
CURRENT_REMOTE_NAME="$name"
if [[ "$REMOTE_TYPE" == "ssh" ]]; then
log_debug "Loaded remote '$name': ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} -> ${REMOTE_BASE}"
else
log_debug "Loaded remote '$name': type=${REMOTE_TYPE} -> ${REMOTE_BASE}"
fi
}
# Load + validate a remote config.
validate_remote() {
local name="$1"
load_remote "$name" || return 1
local errors=0
# Common validations
if ! [[ "$RETENTION_COUNT" =~ ^[0-9]+$ ]] || (( RETENTION_COUNT < 1 )); then
log_error "Remote '$name': RETENTION_COUNT must be >= 1, got: $RETENTION_COUNT"
((errors++)) || true
fi
case "${REMOTE_TYPE:-ssh}" in
ssh)
if [[ -z "$REMOTE_HOST" ]]; then
log_error "Remote '$name': REMOTE_HOST is required"
((errors++)) || true
fi
if [[ "${REMOTE_AUTH_METHOD:-key}" != "key" && "${REMOTE_AUTH_METHOD:-key}" != "password" ]]; then
log_error "Remote '$name': REMOTE_AUTH_METHOD must be 'key' or 'password', got: $REMOTE_AUTH_METHOD"
((errors++)) || true
fi
if [[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]]; then
if [[ -z "${REMOTE_PASSWORD:-}" ]]; then
log_error "Remote '$name': REMOTE_PASSWORD is required when REMOTE_AUTH_METHOD=password"
((errors++)) || true
fi
if ! command -v sshpass &>/dev/null; then
log_error "Remote '$name': sshpass is required for password authentication (install: yum install sshpass)"
((errors++)) || true
fi
else
if [[ -z "$REMOTE_KEY" ]]; then
log_error "Remote '$name': REMOTE_KEY is required"
((errors++)) || true
elif [[ ! -f "$REMOTE_KEY" ]]; then
log_error "Remote '$name': REMOTE_KEY file not found: $REMOTE_KEY"
((errors++)) || true
fi
fi
if ! [[ "$REMOTE_PORT" =~ ^[0-9]+$ ]] || (( REMOTE_PORT < 1 || REMOTE_PORT > 65535 )); then
log_error "Remote '$name': REMOTE_PORT must be 1-65535, got: $REMOTE_PORT"
((errors++)) || true
fi
;;
s3)
if ! command -v rclone &>/dev/null; then
log_error "Remote '$name': rclone is required for S3 remotes (install: https://rclone.org/install/)"
((errors++)) || true
fi
if [[ -z "${S3_ACCESS_KEY_ID:-}" ]]; then
log_error "Remote '$name': S3_ACCESS_KEY_ID is required"
((errors++)) || true
fi
if [[ -z "${S3_SECRET_ACCESS_KEY:-}" ]]; then
log_error "Remote '$name': S3_SECRET_ACCESS_KEY is required"
((errors++)) || true
fi
if [[ -z "${S3_BUCKET:-}" ]]; then
log_error "Remote '$name': S3_BUCKET is required"
((errors++)) || true
fi
;;
gdrive)
if ! command -v rclone &>/dev/null; then
log_error "Remote '$name': rclone is required for Google Drive remotes (install: https://rclone.org/install/)"
((errors++)) || true
fi
if [[ -z "${GDRIVE_SERVICE_ACCOUNT_FILE:-}" ]]; then
log_error "Remote '$name': GDRIVE_SERVICE_ACCOUNT_FILE is required"
((errors++)) || true
elif [[ ! -f "${GDRIVE_SERVICE_ACCOUNT_FILE}" ]]; then
log_error "Remote '$name': GDRIVE_SERVICE_ACCOUNT_FILE not found: $GDRIVE_SERVICE_ACCOUNT_FILE"
((errors++)) || true
fi
;;
*)
log_error "Remote '$name': REMOTE_TYPE must be 'ssh', 's3', or 'gdrive', got: $REMOTE_TYPE"
((errors++)) || true
;;
esac
(( errors > 0 )) && return 1
return 0
}
# Resolve which remotes to operate on.
# - If --remote=NAME was given, return just that name.
# - Otherwise return all remotes from remotes.d/.
# - Errors if no remotes are configured.
#
# Usage: get_target_remotes "$remote_flag_value"
# Outputs one name per line.
get_target_remotes() {
local flag="${1:-}"
if [[ -n "$flag" ]]; then
# Split on commas, verify each remote exists
local IFS=','
local names
read -ra names <<< "$flag"
for name in "${names[@]}"; do
# Trim whitespace
name="${name#"${name%%[![:space:]]*}"}"
name="${name%"${name##*[![:space:]]}"}"
[[ -z "$name" ]] && continue
if [[ ! -f "$REMOTES_DIR/${name}.conf" ]]; then
log_error "Remote not found: $name (expected $REMOTES_DIR/${name}.conf)"
return 1
fi
echo "$name"
done
return 0
fi
if has_remotes; then
list_remotes
return 0
fi
# No remotes configured
log_error "No remotes configured. Run 'gniza init remote <name>' to add one."
return 1
}

1375
lib/restore.sh Normal file

File diff suppressed because it is too large Load Diff

52
lib/retention.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# gniza/lib/retention.sh — Delete old snapshots beyond RETENTION_COUNT on remote
enforce_retention() {
local user="$1"
local keep="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}"
log_debug "Enforcing retention for $user: keeping $keep snapshots"
# Get completed snapshots sorted newest first
local snapshots; snapshots=$(list_remote_snapshots "$user")
if [[ -z "$snapshots" ]]; then
log_debug "No snapshots found for $user, nothing to prune"
return 0
fi
local count=0
local pruned=0
while IFS= read -r snap; do
((count++)) || true
if (( count > keep )); then
log_info "Pruning old snapshot for $user: $snap"
if _is_rclone_mode; then
rclone_purge "accounts/${user}/snapshots/${snap}" || {
log_warn "Failed to purge snapshot: $snap"
}
else
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
remote_exec "rm -rf '$snap_dir/$snap'" || {
log_warn "Failed to prune snapshot: $snap_dir/$snap"
}
fi
((pruned++)) || true
fi
done <<< "$snapshots"
if (( pruned > 0 )); then
log_info "Pruned $pruned old snapshot(s) for $user"
fi
}
enforce_retention_all() {
local accounts; accounts=$(list_remote_accounts)
if [[ -z "$accounts" ]]; then
log_debug "No remote accounts found for retention"
return 0
fi
while IFS= read -r user; do
[[ -n "$user" ]] && enforce_retention "$user"
done <<< "$accounts"
}

294
lib/schedule.sh Normal file
View File

@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# gniza/lib/schedule.sh — Cron management for decoupled schedules
#
# Schedules are defined in /etc/gniza/schedules.d/<name>.conf:
# SCHEDULE="hourly|daily|weekly|monthly|custom"
# SCHEDULE_TIME="HH:MM"
# SCHEDULE_DAY="" # dow (0-6) for weekly, dom (1-28) for monthly
# SCHEDULE_CRON="" # full 5-field cron expr for custom
# REMOTES="" # comma-separated remote names (empty = all)
#
# Cron lines are tagged with "# gniza:<name>" for clean install/remove.
readonly GNIZA_CRON_TAG="# gniza:"
readonly SCHEDULES_DIR="/etc/gniza/schedules.d"
# ── Discovery ─────────────────────────────────────────────────
# List schedule names (filenames without .conf) sorted alphabetically.
list_schedules() {
if [[ ! -d "$SCHEDULES_DIR" ]]; then
return 0
fi
local f
for f in "$SCHEDULES_DIR"/*.conf; do
[[ -f "$f" ]] || continue
basename "$f" .conf
done
}
# Return 0 if at least one schedule config exists.
has_schedules() {
local schedules
schedules=$(list_schedules)
[[ -n "$schedules" ]]
}
# ── Loading ───────────────────────────────────────────────────
# Source a schedule config and set SCHEDULE/REMOTES globals.
# Usage: load_schedule <name>
load_schedule() {
local name="$1"
local conf="$SCHEDULES_DIR/${name}.conf"
if [[ ! -f "$conf" ]]; then
log_error "Schedule config not found: $conf"
return 1
fi
# Reset schedule globals before sourcing
SCHEDULE=""
SCHEDULE_TIME=""
SCHEDULE_DAY=""
SCHEDULE_CRON=""
SCHEDULE_REMOTES=""
# shellcheck disable=SC1090
source "$conf" || {
log_error "Failed to parse schedule config: $conf"
return 1
}
# Map REMOTES to SCHEDULE_REMOTES to avoid conflicts
SCHEDULE_REMOTES="${REMOTES:-}"
log_debug "Loaded schedule '$name': ${SCHEDULE} at ${SCHEDULE_TIME:-02:00}, remotes=${SCHEDULE_REMOTES:-all}"
}
# ── Cron Generation ───────────────────────────────────────────
# Convert schedule vars to a 5-field cron expression.
# Must be called after load_schedule() sets SCHEDULE/SCHEDULE_TIME/etc.
schedule_to_cron() {
local name="$1"
local schedule="${SCHEDULE:-}"
local stime="${SCHEDULE_TIME:-02:00}"
local sday="${SCHEDULE_DAY:-}"
local scron="${SCHEDULE_CRON:-}"
if [[ -z "$schedule" ]]; then
return 1 # no schedule configured
fi
local hour minute
hour="${stime%%:*}"
minute="${stime##*:}"
# Strip leading zeros for cron
hour=$((10#$hour))
minute=$((10#$minute))
case "$schedule" in
hourly)
if [[ -n "$sday" && "$sday" -gt 1 ]] 2>/dev/null; then
echo "$minute */$sday * * *"
else
echo "$minute * * * *"
fi
;;
daily)
echo "$minute $hour * * *"
;;
weekly)
if [[ -z "$sday" ]]; then
log_error "Schedule '$name': SCHEDULE_DAY required for weekly schedule"
return 1
fi
echo "$minute $hour * * $sday"
;;
monthly)
if [[ -z "$sday" ]]; then
log_error "Schedule '$name': SCHEDULE_DAY required for monthly schedule"
return 1
fi
echo "$minute $hour $sday * *"
;;
custom)
if [[ -z "$scron" ]]; then
log_error "Schedule '$name': SCHEDULE_CRON required for custom schedule"
return 1
fi
echo "$scron"
;;
*)
log_error "Schedule '$name': unknown SCHEDULE value: $schedule"
return 1
;;
esac
}
# Build the full cron line for a schedule.
# Uses SCHEDULE_REMOTES if set, otherwise targets all remotes.
build_cron_line() {
local name="$1"
local cron_expr
cron_expr=$(schedule_to_cron "$name") || return 1
local remote_flag=""
if [[ -n "$SCHEDULE_REMOTES" ]]; then
remote_flag=" --remote=$SCHEDULE_REMOTES"
fi
echo "$cron_expr /usr/local/bin/gniza backup${remote_flag} >> /var/log/gniza/cron-${name}.log 2>&1"
}
# ── Crontab Management ────────────────────────────────────────
# Install cron entries for all schedules in schedules.d/.
# Strips any existing gniza entries first, then appends new ones.
install_schedules() {
if ! has_schedules; then
log_error "No schedules configured in $SCHEDULES_DIR"
return 1
fi
# Collect new cron lines
local new_lines=""
local count=0
local schedules; schedules=$(list_schedules)
while IFS= read -r sname; do
[[ -z "$sname" ]] && continue
load_schedule "$sname" || { log_error "Skipping schedule '$sname': failed to load"; continue; }
if [[ -z "${SCHEDULE:-}" ]]; then
log_debug "Schedule '$sname' has no SCHEDULE type, skipping"
continue
fi
local cron_line
cron_line=$(build_cron_line "$sname") || { log_error "Skipping schedule '$sname': invalid schedule"; continue; }
new_lines+="${GNIZA_CRON_TAG}${sname}"$'\n'
new_lines+="${cron_line}"$'\n'
((count++)) || true
done <<< "$schedules"
if (( count == 0 )); then
log_warn "No valid schedules found"
return 1
fi
# Get current crontab, strip old gniza lines
local current_crontab=""
current_crontab=$(crontab -l 2>/dev/null) || true
local filtered=""
local skip_next=false
while IFS= read -r line; do
if [[ "$line" == "${GNIZA_CRON_TAG}"* ]]; then
skip_next=true
continue
fi
if [[ "$skip_next" == "true" ]]; then
skip_next=false
continue
fi
filtered+="$line"$'\n'
done <<< "$current_crontab"
# Append new lines
local final="${filtered}${new_lines}"
# Install
echo "$final" | crontab - || {
log_error "Failed to install crontab"
return 1
}
echo "Installed $count schedule(s):"
echo ""
# Show what was installed
while IFS= read -r sname; do
[[ -z "$sname" ]] && continue
load_schedule "$sname" 2>/dev/null || continue
[[ -z "${SCHEDULE:-}" ]] && continue
local cron_line; cron_line=$(build_cron_line "$sname" 2>/dev/null) || continue
echo " [$sname] $cron_line"
done <<< "$schedules"
}
# Display current gniza cron entries.
show_schedules() {
local current_crontab=""
current_crontab=$(crontab -l 2>/dev/null) || true
if [[ -z "$current_crontab" ]]; then
echo "No crontab entries found."
return 0
fi
local found=false
local next_is_command=false
local current_tag=""
while IFS= read -r line; do
if [[ "$line" == "${GNIZA_CRON_TAG}"* ]]; then
current_tag="${line#"$GNIZA_CRON_TAG"}"
next_is_command=true
continue
fi
if [[ "$next_is_command" == "true" ]]; then
next_is_command=false
if [[ "$found" == "false" ]]; then
echo "Current gniza schedules:"
echo ""
found=true
fi
echo " [$current_tag] $line"
fi
done <<< "$current_crontab"
if [[ "$found" == "false" ]]; then
echo "No gniza schedule entries in crontab."
fi
}
# Remove all gniza cron entries.
remove_schedules() {
local current_crontab=""
current_crontab=$(crontab -l 2>/dev/null) || true
if [[ -z "$current_crontab" ]]; then
echo "No crontab entries to remove."
return 0
fi
local filtered=""
local skip_next=false
local removed=0
while IFS= read -r line; do
if [[ "$line" == "${GNIZA_CRON_TAG}"* ]]; then
skip_next=true
((removed++)) || true
continue
fi
if [[ "$skip_next" == "true" ]]; then
skip_next=false
continue
fi
filtered+="$line"$'\n'
done <<< "$current_crontab"
if (( removed == 0 )); then
echo "No gniza schedule entries found in crontab."
return 0
fi
echo "$filtered" | crontab - || {
log_error "Failed to update crontab"
return 1
}
echo "Removed $removed gniza schedule(s) from crontab."
}

110
lib/snapshot.sh Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# gniza/lib/snapshot.sh — Timestamp naming, list/resolve snapshots, latest symlink
get_remote_account_base() {
local user="$1"
local hostname; hostname=$(hostname -f)
echo "${REMOTE_BASE}/${hostname}/accounts/${user}"
}
get_snapshot_dir() {
local user="$1"
echo "$(get_remote_account_base "$user")/snapshots"
}
list_remote_snapshots() {
local user="$1"
if _is_rclone_mode; then
rclone_list_remote_snapshots "$user"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
# List completed snapshots (no .partial suffix), sorted newest first
local raw; raw=$(remote_exec "ls -1d '$snap_dir'/[0-9]* 2>/dev/null | grep -v '\\.partial$' | sort -r" 2>/dev/null) || true
if [[ -n "$raw" ]]; then
echo "$raw" | xargs -I{} basename {} | sort -r
fi
}
get_latest_snapshot() {
local user="$1"
if _is_rclone_mode; then
rclone_get_latest_snapshot "$user"
return
fi
list_remote_snapshots "$user" | head -1
}
resolve_snapshot_timestamp() {
local user="$1"
local requested="$2"
if [[ -z "$requested" || "$requested" == "LATEST" || "$requested" == "latest" ]]; then
get_latest_snapshot "$user"
elif _is_rclone_mode; then
rclone_resolve_snapshot "$user" "$requested"
else
# Verify it exists
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
if remote_exec "test -d '$snap_dir/$requested'" 2>/dev/null; then
echo "$requested"
else
log_error "Snapshot not found for $user: $requested"
return 1
fi
fi
}
update_latest_symlink() {
local user="$1"
local timestamp="$2"
if _is_rclone_mode; then
rclone_update_latest "$user" "$timestamp"
return
fi
local base; base=$(get_remote_account_base "$user")
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
remote_exec "ln -sfn '$snap_dir/$timestamp' '$base/latest'" || {
log_warn "Failed to update latest symlink for $user"
return 1
}
log_debug "Updated latest symlink for $user -> $timestamp"
}
clean_partial_snapshots() {
local user="$1"
if _is_rclone_mode; then
rclone_clean_partial_snapshots "$user"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local partials; partials=$(remote_exec "ls -1d '$snap_dir'/*.partial 2>/dev/null" 2>/dev/null) || true
if [[ -n "$partials" ]]; then
log_info "Cleaning partial snapshots for $user..."
remote_exec "rm -rf '$snap_dir'/*.partial" || {
log_warn "Failed to clean partial snapshots for $user"
}
fi
}
list_remote_accounts() {
if _is_rclone_mode; then
rclone_list_dirs "accounts"
return
fi
local hostname; hostname=$(hostname -f)
local accounts_dir="${REMOTE_BASE}/${hostname}/accounts"
remote_exec "ls -1 '$accounts_dir' 2>/dev/null" 2>/dev/null || true
}

76
lib/ssh.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# gniza/lib/ssh.sh — SSH connectivity, remote exec, ssh_opts builder
_is_password_mode() {
[[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]]
}
build_ssh_opts() {
local opts=()
opts+=(-n)
if _is_password_mode; then
opts+=(-o "StrictHostKeyChecking=yes")
else
opts+=(-i "$REMOTE_KEY")
opts+=(-o "StrictHostKeyChecking=yes")
opts+=(-o "BatchMode=yes")
fi
opts+=(-p "$REMOTE_PORT")
opts+=(-o "ConnectTimeout=$SSH_TIMEOUT")
opts+=(-o "ServerAliveInterval=60")
opts+=(-o "ServerAliveCountMax=3")
echo "${opts[*]}"
}
build_ssh_cmd() {
if _is_password_mode; then
echo "sshpass -p $(printf '%q' "$REMOTE_PASSWORD") ssh $(build_ssh_opts)"
else
echo "ssh $(build_ssh_opts)"
fi
}
remote_exec() {
local cmd="$1"
local ssh_opts; ssh_opts=$(build_ssh_opts)
if _is_password_mode; then
log_debug "CMD: sshpass ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} '$cmd'"
# shellcheck disable=SC2086
sshpass -p "$REMOTE_PASSWORD" ssh $ssh_opts "${REMOTE_USER}@${REMOTE_HOST}" "$cmd"
else
log_debug "CMD: ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} '$cmd'"
# shellcheck disable=SC2086
ssh $ssh_opts "${REMOTE_USER}@${REMOTE_HOST}" "$cmd"
fi
}
remote_exec_quiet() {
remote_exec "$1" 2>/dev/null
}
test_ssh_connection() {
log_info "Testing SSH connection to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}..."
if remote_exec "echo ok" &>/dev/null; then
log_info "SSH connection successful"
return 0
else
log_error "SSH connection failed to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}"
return 1
fi
}
ensure_remote_dir() {
local dir="$1"
remote_exec "mkdir -p '$dir'" || {
log_error "Failed to create remote directory: $dir"
return 1
}
}
build_rsync_ssh_cmd() {
if _is_password_mode; then
echo "ssh -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o ConnectTimeout=$SSH_TIMEOUT"
else
echo "ssh -i $REMOTE_KEY -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=$SSH_TIMEOUT"
fi
}

172
lib/transfer.sh Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# gniza/lib/transfer.sh — rsync --link-dest to remote, .partial atomicity, retries
rsync_to_remote() {
local source_dir="$1"
local remote_dest="$2"
local link_dest="${3:-}"
local attempt=0
local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
local rsync_opts=(-aHAX --numeric-ids --delete --rsync-path="rsync --fake-super")
if [[ -n "$link_dest" ]]; then
rsync_opts+=(--link-dest="$link_dest")
fi
if [[ "${BWLIMIT:-0}" -gt 0 ]]; then
rsync_opts+=(--bwlimit="$BWLIMIT")
fi
if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]]; then
# shellcheck disable=SC2206
rsync_opts+=($RSYNC_EXTRA_OPTS)
fi
rsync_opts+=(-e "$rsync_ssh")
# Ensure source ends with /
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
while (( attempt < max_retries )); do
((attempt++)) || true
log_debug "rsync attempt $attempt/$max_retries: $source_dir -> $remote_dest"
log_debug "CMD: rsync ${rsync_opts[*]} $source_dir ${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}"
local rsync_cmd=(rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}")
if _is_password_mode; then
rsync_cmd=(sshpass -p "$REMOTE_PASSWORD" "${rsync_cmd[@]}")
fi
if "${rsync_cmd[@]}"; then
log_debug "rsync succeeded on attempt $attempt"
return 0
fi
local rc=$?
log_warn "rsync failed (exit $rc), attempt $attempt/$max_retries"
if (( attempt < max_retries )); then
local backoff=$(( attempt * 10 ))
log_info "Retrying in ${backoff}s..."
sleep "$backoff"
fi
done
log_error "rsync failed after $max_retries attempts"
return 1
}
transfer_pkgacct() {
local user="$1"
local timestamp="$2"
local prev_snapshot="${3:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local source="$temp_dir/$user"
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${timestamp}"
log_info "Transferring pkgacct data for $user (rclone)..."
rclone_to_remote "$source" "$snap_subpath"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local dest="$snap_dir/${timestamp}.partial/"
local link_dest=""
if [[ -n "$prev_snapshot" ]]; then
# Detect old format (pkgacct/ subdir) vs new format (content at root)
if remote_exec "test -d '$snap_dir/$prev_snapshot/pkgacct'" 2>/dev/null; then
link_dest="$snap_dir/$prev_snapshot/pkgacct"
else
link_dest="$snap_dir/$prev_snapshot"
fi
fi
ensure_remote_dir "$dest" || return 1
log_info "Transferring pkgacct data for $user..."
rsync_to_remote "$source" "$dest" "$link_dest"
}
transfer_homedir() {
local user="$1"
local timestamp="$2"
local prev_snapshot="${3:-}"
local homedir; homedir=$(get_account_homedir "$user")
if [[ ! -d "$homedir" ]]; then
log_warn "Home directory not found for $user: $homedir"
return 1
fi
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${timestamp}/homedir"
log_info "Transferring homedir for $user ($homedir) (rclone)..."
rclone_to_remote "$homedir" "$snap_subpath"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local dest="$snap_dir/${timestamp}.partial/homedir/"
local link_dest=""
if [[ -n "$prev_snapshot" ]]; then
link_dest="$snap_dir/$prev_snapshot/homedir"
fi
ensure_remote_dir "$dest" || return 1
log_info "Transferring homedir for $user ($homedir)..."
rsync_to_remote "$homedir" "$dest" "$link_dest"
}
finalize_snapshot() {
local user="$1"
local timestamp="$2"
if _is_rclone_mode; then
log_info "Finalizing snapshot for $user: $timestamp (rclone)"
rclone_finalize_snapshot "$user" "$timestamp"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
log_info "Finalizing snapshot for $user: $timestamp"
remote_exec "mv '$snap_dir/${timestamp}.partial' '$snap_dir/$timestamp'" || {
log_error "Failed to finalize snapshot for $user: $timestamp"
return 1
}
update_latest_symlink "$user" "$timestamp"
}
rsync_dry_run() {
if _is_rclone_mode; then
log_info "[DRY RUN] rclone mode — dry run not supported for cloud remotes"
return 0
fi
local source_dir="$1"
local remote_dest="$2"
local link_dest="${3:-}"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
local rsync_opts=(-aHAX --numeric-ids --delete --rsync-path="rsync --fake-super" --dry-run --stats)
if [[ -n "$link_dest" ]]; then
rsync_opts+=(--link-dest="$link_dest")
fi
rsync_opts+=(-e "$rsync_ssh")
[[ "$source_dir" != */ ]] && source_dir="$source_dir/"
if _is_password_mode; then
sshpass -p "$REMOTE_PASSWORD" rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}" 2>&1
else
rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}" 2>&1
fi
}

51
lib/utils.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# gniza/lib/utils.sh — Core utility functions
die() {
local code="${2:-$EXIT_FATAL}"
echo "${C_RED}FATAL: $1${C_RESET}" >&2
exit "$code"
}
require_root() {
[[ $EUID -eq 0 ]] || die "This command must be run as root"
}
timestamp() {
date -u +"%Y-%m-%dT%H%M%S"
}
human_size() {
local bytes="$1"
if (( bytes >= 1073741824 )); then
local whole=$(( bytes / 1073741824 ))
local frac=$(( (bytes % 1073741824) * 10 / 1073741824 ))
printf "%d.%d GB" "$whole" "$frac"
elif (( bytes >= 1048576 )); then
local whole=$(( bytes / 1048576 ))
local frac=$(( (bytes % 1048576) * 10 / 1048576 ))
printf "%d.%d MB" "$whole" "$frac"
elif (( bytes >= 1024 )); then
local whole=$(( bytes / 1024 ))
local frac=$(( (bytes % 1024) * 10 / 1024 ))
printf "%d.%d KB" "$whole" "$frac"
else
printf "%d B" "$bytes"
fi
}
human_duration() {
local seconds="$1"
if (( seconds >= 3600 )); then
printf "%dh %dm %ds" $((seconds/3600)) $((seconds%3600/60)) $((seconds%60))
elif (( seconds >= 60 )); then
printf "%dm %ds" $((seconds/60)) $((seconds%60))
else
printf "%ds" "$seconds"
fi
}
# Check if a command exists
require_cmd() {
command -v "$1" &>/dev/null || die "Required command not found: $1"
}

132
lib/verify.sh Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# gniza/lib/verify.sh — Remote backup integrity checks
verify_account_backup() {
local user="$1"
local timestamp="${2:-}"
local errors=0
# Resolve timestamp
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
log_info "Verifying backup for $user (snapshot: $ts)..."
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
# Check .complete marker
if ! rclone_exists "${snap_subpath}/.complete"; then
log_error "Snapshot missing .complete marker: $snap_subpath"
return 1
fi
# Count files
local file_list; file_list=$(rclone_list_files "$snap_subpath" 2>/dev/null) || true
local file_count=0
[[ -n "$file_list" ]] && file_count=$(echo "$file_list" | wc -l)
if (( file_count == 0 )); then
log_warn "No files found in snapshot"
((errors++)) || true
else
log_info " files: $file_count file(s)"
fi
# Check homedir
if rclone_exists "${snap_subpath}/homedir/"; then
local size_json; size_json=$(rclone_size "${snap_subpath}/homedir" 2>/dev/null) || true
local bytes=0
if [[ -n "$size_json" ]]; then
bytes=$(echo "$size_json" | grep -oP '"bytes":\s*\K[0-9]+' || echo 0)
fi
log_info " homedir: $(human_size "$bytes")"
else
log_warn "homedir directory missing in snapshot"
fi
# Check latest.txt
local latest; latest=$(rclone_cat "accounts/${user}/snapshots/latest.txt" 2>/dev/null) || true
if [[ -n "$latest" ]]; then
log_info " latest -> $latest"
else
log_warn " latest.txt not set"
fi
else
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
# Check snapshot directory exists
if ! remote_exec "test -d '$snap_path'" 2>/dev/null; then
log_error "Snapshot directory not found: $snap_path"
return 1
fi
# Detect old format (pkgacct/ subdir) vs new format (content at root)
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
# Check for expected pkgacct files
local file_count; file_count=$(remote_exec "find '$pkgacct_base' -maxdepth 1 -type f | wc -l" 2>/dev/null)
if [[ "$file_count" -eq 0 ]]; then
log_warn "No pkgacct files found in snapshot"
((errors++)) || true
else
log_info " pkgacct: $file_count file(s)"
fi
# Check for SQL files
local sql_count; sql_count=$(remote_exec "find '$pkgacct_base/mysql' -name '*.sql.gz' 2>/dev/null | wc -l" 2>/dev/null)
log_info " databases: $sql_count compressed SQL file(s)"
# Check homedir directory
if ! remote_exec "test -d '$snap_path/homedir'" 2>/dev/null; then
log_warn "homedir directory missing in snapshot"
else
local homedir_size; homedir_size=$(remote_exec "du -sb '$snap_path/homedir' | cut -f1" 2>/dev/null)
log_info " homedir: $(human_size "${homedir_size:-0}")"
fi
# Check latest symlink
local base; base=$(get_remote_account_base "$user")
local latest_target; latest_target=$(remote_exec "readlink '$base/latest' 2>/dev/null" 2>/dev/null)
if [[ -n "$latest_target" ]]; then
log_info " latest -> $(basename "$latest_target")"
else
log_warn " latest symlink not set"
fi
fi
if (( errors > 0 )); then
log_error "Verification found $errors issue(s) for $user"
return 1
fi
log_info "Verification passed for $user"
return 0
}
verify_all_accounts() {
local accounts; accounts=$(list_remote_accounts)
local total=0 passed=0 failed=0
if [[ -z "$accounts" ]]; then
log_warn "No remote accounts found to verify"
return 0
fi
while IFS= read -r user; do
[[ -z "$user" ]] && continue
((total++)) || true
if verify_account_backup "$user"; then
((passed++)) || true
else
((failed++)) || true
fi
done <<< "$accounts"
echo ""
log_info "Verification complete: $passed/$total passed, $failed failed"
(( failed > 0 )) && return 1
return 0
}

72
scripts/install.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# gniza install script
# Installs to /usr/local/gniza and creates symlink in /usr/local/bin
set -euo pipefail
INSTALL_DIR="/usr/local/gniza"
BIN_LINK="/usr/local/bin/gniza"
if [[ $EUID -ne 0 ]]; then
echo "Error: install.sh must be run as root" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR="$(dirname "$SCRIPT_DIR")"
echo "Installing gniza to $INSTALL_DIR..."
# Create install directory
mkdir -p "$INSTALL_DIR"
# Copy files
cp -r "$SOURCE_DIR/bin" "$INSTALL_DIR/"
cp -r "$SOURCE_DIR/lib" "$INSTALL_DIR/"
cp -r "$SOURCE_DIR/etc" "$INSTALL_DIR/"
# Make bin executable
chmod +x "$INSTALL_DIR/bin/gniza"
# Create symlink
ln -sf "$INSTALL_DIR/bin/gniza" "$BIN_LINK"
# Create working directory
mkdir -p "$INSTALL_DIR/workdir"
# Create config directory structure
mkdir -p /etc/gniza
mkdir -p /etc/gniza/remotes.d
mkdir -p /etc/gniza/schedules.d
# Copy example configs if no config exists
if [[ ! -f /etc/gniza/gniza.conf ]]; then
cp "$INSTALL_DIR/etc/gniza.conf.example" /etc/gniza/gniza.conf.example
echo "Example config copied to /etc/gniza/gniza.conf.example"
fi
cp "$INSTALL_DIR/etc/remote.conf.example" /etc/gniza/remote.conf.example
cp "$INSTALL_DIR/etc/schedule.conf.example" /etc/gniza/schedule.conf.example
# Create log directory
mkdir -p /var/log/gniza
echo "gniza installed successfully!"
# ── WHM Plugin (if cPanel/WHM is present) ─────────────────────
WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi"
if [[ -d "$WHM_CGI_DIR" ]]; then
echo "Installing WHM plugin..."
cp -r "$SOURCE_DIR/whm/gniza-whm" "$WHM_CGI_DIR/"
cp "$SOURCE_DIR/whm/gniza-whm.conf" "$WHM_CGI_DIR/gniza-whm/"
chmod +x "$WHM_CGI_DIR/gniza-whm/"*.cgi
/usr/local/cpanel/bin/register_appconfig "$WHM_CGI_DIR/gniza-whm/gniza-whm.conf"
echo "WHM plugin installed — access via WHM > Plugins > gniza Backup Manager"
else
echo "WHM not detected, skipping WHM plugin installation."
fi
echo ""
echo "Next steps:"
echo " 1. Run 'gniza init' to create your configuration"
echo " 2. Or copy /etc/gniza/gniza.conf.example to /etc/gniza/gniza.conf"
echo " 3. Run 'gniza status' to verify your setup"

45
scripts/uninstall.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# gniza uninstall script
set -euo pipefail
INSTALL_DIR="/usr/local/gniza"
BIN_LINK="/usr/local/bin/gniza"
if [[ $EUID -ne 0 ]]; then
echo "Error: uninstall.sh must be run as root" >&2
exit 1
fi
echo "Uninstalling gniza..."
# Remove symlink
if [[ -L "$BIN_LINK" ]]; then
rm -f "$BIN_LINK"
echo "Removed $BIN_LINK"
fi
# Remove install directory
if [[ -d "$INSTALL_DIR" ]]; then
rm -rf "$INSTALL_DIR"
echo "Removed $INSTALL_DIR"
fi
# ── WHM Plugin ────────────────────────────────────────────────
WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi"
if [[ -d "$WHM_CGI_DIR/gniza-whm" ]]; then
echo "Removing WHM plugin..."
/usr/local/cpanel/bin/unregister_appconfig gniza-whm 2>/dev/null || true
rm -rf "$WHM_CGI_DIR/gniza-whm"
echo "WHM plugin removed."
fi
echo ""
echo "gniza uninstalled."
echo ""
echo "The following were NOT removed (manual cleanup if desired):"
echo " /etc/gniza/ (configuration + remotes.d/)"
echo " /var/log/gniza/ (log files)"
echo " /var/run/gniza.lock (lock file)"
echo ""
echo "To remove gniza cron entries: crontab -l | grep -v '# gniza:' | grep -v '/usr/local/bin/gniza' | crontab -"

149
tests/test_utils.sh Executable file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# gniza tests — utility functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")"
source "$BASE_DIR/lib/constants.sh"
source "$BASE_DIR/lib/utils.sh"
source "$BASE_DIR/lib/logging.sh"
source "$BASE_DIR/lib/config.sh"
source "$BASE_DIR/lib/accounts.sh"
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
assert_eq() {
local expected="$1"
local actual="$2"
local msg="${3:-assertion}"
((TESTS_RUN++))
if [[ "$expected" == "$actual" ]]; then
((TESTS_PASSED++))
echo " ${C_GREEN}PASS${C_RESET}: $msg"
else
((TESTS_FAILED++))
echo " ${C_RED}FAIL${C_RESET}: $msg"
echo " expected: '$expected'"
echo " actual: '$actual'"
fi
}
assert_ok() {
local msg="${1:-assertion}"
((TESTS_RUN++))
((TESTS_PASSED++))
echo " ${C_GREEN}PASS${C_RESET}: $msg"
}
assert_fail() {
local msg="${1:-assertion}"
((TESTS_RUN++))
((TESTS_FAILED++))
echo " ${C_RED}FAIL${C_RESET}: $msg"
}
print_summary() {
echo ""
echo "=============================="
echo "Tests: $TESTS_RUN | Passed: $TESTS_PASSED | Failed: $TESTS_FAILED"
echo "=============================="
(( TESTS_FAILED > 0 )) && exit 1
exit 0
}
# ── Tests: utils.sh ───────────────────────────────────────────
echo "Testing utils.sh..."
# timestamp format
ts=$(timestamp)
if [[ "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}$ ]]; then
assert_ok "timestamp format matches YYYY-MM-DDTHHMMSS"
else
assert_fail "timestamp format: got '$ts'"
fi
# human_size
assert_eq "500 B" "$(human_size 500)" "human_size 500B"
assert_eq "1.0 KB" "$(human_size 1024)" "human_size 1KB"
assert_eq "1.0 MB" "$(human_size 1048576)" "human_size 1MB"
assert_eq "1.0 GB" "$(human_size 1073741824)" "human_size 1GB"
assert_eq "2.5 GB" "$(human_size 2684354560)" "human_size 2.5GB"
assert_eq "5.3 KB" "$(human_size 5500)" "human_size 5.3KB"
# human_duration
assert_eq "5s" "$(human_duration 5)" "human_duration 5s"
assert_eq "2m 30s" "$(human_duration 150)" "human_duration 2m30s"
assert_eq "1h 5m 30s" "$(human_duration 3930)" "human_duration 1h5m30s"
# require_cmd
if require_cmd bash 2>/dev/null; then
assert_ok "require_cmd bash"
else
assert_fail "require_cmd bash"
fi
# ── Tests: accounts.sh filter ─────────────────────────────────
echo ""
echo "Testing accounts.sh filter_accounts..."
# filter with exclusions
EXCLUDE_ACCOUNTS="nobody,system"
INCLUDE_ACCOUNTS=""
result=$(filter_accounts $'alice\nbob\nnobody\nsystem\ncharlie')
assert_eq $'alice\nbob\ncharlie' "$result" "filter excludes nobody,system"
# filter with inclusions
INCLUDE_ACCOUNTS="alice,bob"
EXCLUDE_ACCOUNTS="nobody"
result=$(filter_accounts $'alice\nbob\nnobody\ncharlie')
assert_eq $'alice\nbob' "$result" "filter includes only alice,bob"
# filter with both include and exclude
INCLUDE_ACCOUNTS="alice,bob,nobody"
EXCLUDE_ACCOUNTS="nobody"
result=$(filter_accounts $'alice\nbob\nnobody')
assert_eq $'alice\nbob' "$result" "filter include+exclude: nobody excluded from include list"
# ── Tests: config.sh validation ───────────────────────────────
echo ""
echo "Testing config.sh validation..."
# Suppress log output for validation tests
LOG_FILE="/dev/null"
# Create a temp file to use as fake SSH key
_test_key=$(mktemp)
trap 'rm -f "$_test_key"' EXIT
# Test validation with invalid LOG_LEVEL
NOTIFY_ON="failure"
LOG_LEVEL="invalid"
if validate_config 2>/dev/null; then
assert_fail "validate_config should fail with invalid LOG_LEVEL"
else
assert_ok "validate_config catches invalid LOG_LEVEL"
fi
# Test validation with valid config (REMOTE_* not validated here — per-remote only)
NOTIFY_ON="failure"
LOG_LEVEL="info"
if validate_config 2>/dev/null; then
assert_ok "validate_config passes with valid config"
else
assert_fail "validate_config should pass with valid config"
fi
# Test invalid NOTIFY_ON
NOTIFY_ON="invalid"
if validate_config 2>/dev/null; then
assert_fail "validate_config should fail with invalid NOTIFY_ON"
else
assert_ok "validate_config catches invalid NOTIFY_ON"
fi
print_summary

7
whm/gniza-whm.conf Normal file
View File

@@ -0,0 +1,7 @@
name=gniza-whm
service=whostmgr
user=root
url=/cgi/gniza-whm/
acls=all
displayname=gniza Backup Manager
entryurl=gniza-whm/index.cgi

File diff suppressed because one or more lines are too long

1001
whm/gniza-whm/assets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"private": true,
"scripts": {
"build:css": "tailwindcss -i src/input.css -o gniza-whm.css --minify",
"dev:css": "tailwindcss -i src/input.css -o gniza-whm.css --watch"
},
"devDependencies": {
"@tailwindcss/cli": "^4",
"tailwindcss": "^4",
"daisyui": "^5"
}
}

View File

@@ -0,0 +1,11 @@
@import "tailwindcss/theme" important;
@import "tailwindcss/utilities" important;
@source "../*.cgi";
@source "../lib/GnizaWHM/*.pm";
@source "./safelist.html";
@plugin "daisyui" {
themes: light --default;
}
@theme {
--font-sans: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

View File

@@ -0,0 +1,2 @@
<!-- Tailwind/DaisyUI class safelist for gniza WHM plugin -->
<div class="alert alert-error alert-info alert-success alert-warning badge badge-error badge-sm badge-success badge-warning bg-base-100 bg-base-200 bg-neutral bg-primary/10 border border-base-300 border-base-content/5 breadcrumbs btn btn-error btn-ghost btn-primary btn-secondary btn-sm btn-xs card card-body card-title checkbox checkbox-sm cursor-pointer flex flex-1 flex-col flex-wrap font-bold font-medium font-mono font-semibold gap-1 gap-2 gap-3 hidden inline input input-bordered input-sm items-center items-start mx-auto join join-item link list-disc loading loading-spinner loading-xs max-h-48 max-w-2xl max-w-xs mb-1 mb-2.5 mb-3 mb-4 mb-5 mb-6 ml-2 modal modal-action modal-backdrop modal-box mt-2 mt-3 mt-4 mt-5 my-2 my-4 overflow-x-auto overflow-y-auto p-3 p-4 pt-1 pt-2 pl-5 px-4 py-1 py-3 py-4 radio radio-sm rounded-box rounded-lg select select-bordered select-sm shadow-sm steps tab tab-content table hover tabs tabs-box tabs-lg tab-active text-center text-error text-lg textarea textarea-bordered textarea-sm text-base-content/60 text-neutral-content text-sm text-xl text-xs toggle toggle-sm toggle-success w-11/12 w-44 w-full whitespace-pre-wrap font-sans text-[1.7rem]"></div>

86
whm/gniza-whm/index.cgi Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/local/cpanel/3rdparty/bin/perl
# gniza WHM Plugin — Dashboard
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::Cron;
use GnizaWHM::UI;
# Redirect to setup wizard if gniza is not configured
unless (GnizaWHM::UI::is_configured()) {
print "Status: 302 Found\r\n";
print "Location: setup.cgi\r\n\r\n";
exit;
}
print "Content-Type: text/html\r\n\r\n";
Whostmgr::HTMLInterface::defheader('gniza Backup Manager — Dashboard', '', '/cgi/gniza-whm/index.cgi');
print GnizaWHM::UI::page_header('gniza Backup Manager');
print GnizaWHM::UI::render_nav('index.cgi');
print GnizaWHM::UI::render_flash();
# Quick links
print qq{<div class="flex gap-3 mb-5">\n};
print qq{ <a href="setup.cgi" class="btn btn-primary btn-sm">Run Setup Wizard</a>\n};
print qq{</div>\n};
# Version
my $version = GnizaWHM::UI::get_gniza_version();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Overview</h2>\n};
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<tr><td class="font-semibold w-44">gniza version</td><td>} . GnizaWHM::UI::esc($version) . qq{</td></tr>\n};
print qq{</table></div>\n};
print qq{</div>\n</div>\n};
# Remote destinations
my @remotes = GnizaWHM::UI::list_remotes();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Configured Remotes</h2>\n};
if (@remotes) {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>Retention</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (@remotes) {
my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::remote_conf_path($name), 'remote');
my $host = GnizaWHM::UI::esc($conf->{REMOTE_HOST} // '');
my $port = GnizaWHM::UI::esc($conf->{REMOTE_PORT} // '22');
my $retention = GnizaWHM::UI::esc($conf->{RETENTION_COUNT} // '30');
my $esc_name = GnizaWHM::UI::esc($name);
print qq{<tr class="hover"><td>$esc_name</td><td>$host</td><td>$port</td><td>$retention</td></tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No remotes configured. <a href="setup.cgi" class="link">Run the setup wizard</a> to add one.</p>\n};
}
print qq{</div>\n</div>\n};
# Active schedules
my $schedules = GnizaWHM::Cron::get_current_schedules();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Active Cron Schedules</h2>\n};
if (keys %$schedules) {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<thead><tr><th>Schedule</th><th>Cron Entry</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (sort keys %$schedules) {
my $esc_name = GnizaWHM::UI::esc($name);
my $esc_line = GnizaWHM::UI::esc($schedules->{$name});
print qq{<tr class="hover"><td>$esc_name</td><td><code>$esc_line</code></td></tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No active gniza cron entries.</p>\n};
}
print qq{</div>\n</div>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();

View File

@@ -0,0 +1,142 @@
package GnizaWHM::Config;
# Pure Perl config file parser/writer for bash-style KEY="value" files.
# No shell calls — reads/writes via Perl file I/O only.
use strict;
use warnings;
use Fcntl qw(:flock);
our @MAIN_KEYS = qw(
TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS
RSYNC_EXTRA_OPTS LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON
LOCK_FILE SSH_TIMEOUT SSH_RETRIES
);
our @REMOTE_KEYS = qw(
REMOTE_TYPE REMOTE_HOST REMOTE_PORT REMOTE_USER REMOTE_AUTH_METHOD REMOTE_KEY
REMOTE_PASSWORD REMOTE_BASE BWLIMIT RETENTION_COUNT RSYNC_EXTRA_OPTS
S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_REGION S3_ENDPOINT S3_BUCKET
GDRIVE_SERVICE_ACCOUNT_FILE GDRIVE_ROOT_FOLDER_ID
);
our @SCHEDULE_KEYS = qw(
SCHEDULE SCHEDULE_TIME SCHEDULE_DAY SCHEDULE_CRON REMOTES
);
my %MAIN_KEY_SET = map { $_ => 1 } @MAIN_KEYS;
my %REMOTE_KEY_SET = map { $_ => 1 } @REMOTE_KEYS;
my %SCHEDULE_KEY_SET = map { $_ => 1 } @SCHEDULE_KEYS;
# parse($filepath, $type)
# $type: 'main', 'remote', or 'schedule' — determines which keys are allowed.
# Returns hashref of KEY => value.
sub parse {
my ($filepath, $type) = @_;
$type //= 'main';
my $allowed = ($type eq 'schedule') ? \%SCHEDULE_KEY_SET
: ($type eq 'remote') ? \%REMOTE_KEY_SET
: \%MAIN_KEY_SET;
my %config;
open my $fh, '<', $filepath or return \%config;
while (my $line = <$fh>) {
chomp $line;
# Skip blank lines and comments
next if $line =~ /^\s*$/;
next if $line =~ /^\s*#/;
# Match KEY="value", KEY='value', or KEY=value
if ($line =~ /^([A-Z_]+)=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
my $key = $1;
my $val = defined $2 ? $2 : (defined $3 ? $3 : ($4 // ''));
if ($allowed->{$key}) {
$config{$key} = $val;
}
}
}
close $fh;
return \%config;
}
# escape_value($string)
# Strips everything except safe characters for bash config values.
sub escape_value {
my ($val) = @_;
$val //= '';
$val =~ s/[^a-zA-Z0-9\@._\/: ,=+\-]//g;
return $val;
}
# Keys whose values are written with single quotes (preserves special chars).
my %SINGLE_QUOTE_KEYS = (REMOTE_PASSWORD => 1, S3_SECRET_ACCESS_KEY => 1);
# escape_password($string)
# For single-quoted bash values: only strip single quotes (can't appear in single-quoted strings).
sub escape_password {
my ($val) = @_;
$val //= '';
$val =~ s/'//g;
return $val;
}
# write($filepath, \%values, \@allowed_keys)
# Updates a config file preserving comments and structure.
# Keys not in @allowed_keys are ignored. Values are escaped.
# Uses flock for concurrency safety.
sub write {
my ($filepath, $values, $allowed_keys) = @_;
my %allowed = map { $_ => 1 } @$allowed_keys;
my %to_write;
for my $key (keys %$values) {
if ($allowed{$key}) {
$to_write{$key} = $SINGLE_QUOTE_KEYS{$key}
? escape_password($values->{$key})
: escape_value($values->{$key});
}
}
# Read existing file
my @lines;
if (-f $filepath) {
open my $rfh, '<', $filepath or return (0, "Cannot read $filepath: $!");
@lines = <$rfh>;
close $rfh;
}
# Track which keys we've updated in-place
my %written;
my @output;
for my $line (@lines) {
if ($line =~ /^([A-Z_]+)=/) {
my $key = $1;
if (exists $to_write{$key}) {
my $val = $to_write{$key};
my $q = $SINGLE_QUOTE_KEYS{$key} ? "'" : '"';
push @output, "$key=$q$val$q\n";
$written{$key} = 1;
next;
}
}
push @output, $line;
}
# Append any new keys not already in the file
for my $key (@$allowed_keys) {
next unless exists $to_write{$key};
next if $written{$key};
my $val = $to_write{$key};
my $q = $SINGLE_QUOTE_KEYS{$key} ? "'" : '"';
push @output, "$key=$q$val$q\n";
}
# Write with flock
open my $wfh, '>', $filepath or return (0, "Cannot write $filepath: $!");
flock($wfh, LOCK_EX) or return (0, "Cannot lock $filepath: $!");
print $wfh @output;
flock($wfh, Fcntl::LOCK_UN);
close $wfh;
return (1, undef);
}
1;

View File

@@ -0,0 +1,229 @@
package GnizaWHM::Cron;
# Per-schedule cron manipulation and gniza schedule CLI wrappers.
use strict;
use warnings;
use IPC::Open3;
use Symbol 'gensym';
use GnizaWHM::Config;
my $GNIZA_BIN = '/usr/local/bin/gniza';
my $GNIZA_TAG = '# gniza:';
my $SCHEDULES_DIR = '/etc/gniza/schedules.d';
# get_current_schedules()
# Reads crontab for gniza entries tagged with "# gniza:<name>".
# Returns hashref of { name => cron_line }.
sub get_current_schedules {
my %schedules;
my $crontab = '';
if (open my $pipe, '-|', 'crontab', '-l') {
local $/;
$crontab = <$pipe> // '';
close $pipe;
}
my @lines = split /\n/, $crontab;
for (my $i = 0; $i < @lines; $i++) {
if ($lines[$i] =~ /^\Q$GNIZA_TAG\E(.+)$/) {
my $name = $1;
if ($i + 1 < @lines) {
$schedules{$name} = $lines[$i + 1];
$i++; # skip the command line
}
}
}
return \%schedules;
}
# install_schedules()
# Runs: /usr/local/bin/gniza schedule install
# Returns ($success, $stdout, $stderr).
sub install_schedules {
return _run_gniza_command('schedule', 'install');
}
# remove_schedules()
# Runs: /usr/local/bin/gniza schedule remove
# Returns ($success, $stdout, $stderr).
sub remove_schedules {
return _run_gniza_command('schedule', 'remove');
}
# show_schedules()
# Runs: /usr/local/bin/gniza schedule show
# Returns ($success, $stdout, $stderr).
sub show_schedules {
return _run_gniza_command('schedule', 'show');
}
# install_schedule($name)
# Builds a cron entry for a single schedule and installs it.
# Returns ($success, $error_message).
sub install_schedule {
my ($name) = @_;
my $conf_path = "$SCHEDULES_DIR/$name.conf";
return (0, "Schedule config not found: $name") unless -f $conf_path;
my $conf = GnizaWHM::Config::parse($conf_path, 'schedule');
my $schedule = $conf->{SCHEDULE} // '';
return (0, "SCHEDULE not set in $name") unless $schedule;
# Build 5-field cron expression
my ($cron_expr, $err) = _schedule_to_cron($conf);
return (0, $err) unless defined $cron_expr;
# Build full cron command line
my $remote_flag = '';
my $remotes = $conf->{REMOTES} // '';
$remotes =~ s/^\s+|\s+$//g;
if ($remotes ne '') {
$remote_flag = " --remote=$remotes";
}
my $cmd_line = "$cron_expr $GNIZA_BIN backup${remote_flag} >> /var/log/gniza/cron-${name}.log 2>&1";
# Read current crontab, strip existing entry for this schedule, append new
my $crontab = _read_crontab();
$crontab = _strip_schedule_entries($crontab, $name);
# Append new entry
$crontab .= "\n" if $crontab ne '' && $crontab !~ /\n$/;
$crontab .= "$GNIZA_TAG$name\n$cmd_line\n";
return _write_crontab($crontab);
}
# remove_schedule($name)
# Removes the cron entry for a single schedule.
# Returns ($success, $error_message).
sub remove_schedule {
my ($name) = @_;
my $crontab = _read_crontab();
my $new_crontab = _strip_schedule_entries($crontab, $name);
return (1, undef) if $new_crontab eq $crontab; # nothing to remove
return _write_crontab($new_crontab);
}
# -- Private --
# Only these exact commands are allowed.
my %ALLOWED_COMMANDS = (
'schedule install' => 1,
'schedule show' => 1,
'schedule remove' => 1,
);
sub _run_gniza_command {
my (@args) = @_;
my $cmd_key = join(' ', @args);
unless ($ALLOWED_COMMANDS{$cmd_key}) {
return (0, '', "Command not allowed: gniza $cmd_key");
}
my $err = gensym;
my $pid = open3(my $in, my $out, $err, $GNIZA_BIN, @args);
close $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err> } // '';
close $out;
close $err;
waitpid($pid, 0);
my $exit_code = $? >> 8;
return ($exit_code == 0, $stdout, $stderr);
}
# _schedule_to_cron(\%conf)
# Converts schedule config to a 5-field cron expression.
# Returns ($expr, undef) on success or (undef, $error) on failure.
sub _schedule_to_cron {
my ($conf) = @_;
my $schedule = $conf->{SCHEDULE} // '';
my $time = $conf->{SCHEDULE_TIME} // '02:00';
my $day = $conf->{SCHEDULE_DAY} // '';
my $cron_raw = $conf->{SCHEDULE_CRON} // '';
my ($hour, $minute) = (2, 0);
if ($time =~ /^(\d{1,2}):(\d{2})$/) {
($hour, $minute) = ($1 + 0, $2 + 0);
}
if ($schedule eq 'hourly') {
return ("$minute * * * *", undef);
} elsif ($schedule eq 'daily') {
return ("$minute $hour * * *", undef);
} elsif ($schedule eq 'weekly') {
return (undef, "SCHEDULE_DAY required for weekly") if $day eq '';
return ("$minute $hour * * $day", undef);
} elsif ($schedule eq 'monthly') {
return (undef, "SCHEDULE_DAY required for monthly") if $day eq '';
return ("$minute $hour $day * *", undef);
} elsif ($schedule eq 'custom') {
return (undef, "SCHEDULE_CRON required for custom") if $cron_raw eq '';
return ($cron_raw, undef);
}
return (undef, "Unknown schedule type: $schedule");
}
# _read_crontab()
# Returns current crontab as string (empty string if none).
sub _read_crontab {
my $crontab = '';
if (open my $pipe, '-|', 'crontab', '-l') {
local $/;
$crontab = <$pipe> // '';
close $pipe;
}
return $crontab;
}
# _write_crontab($content)
# Writes new crontab via 'crontab -'. Returns ($success, $error).
sub _write_crontab {
my ($content) = @_;
open my $pipe, '|-', 'crontab', '-'
or return (0, "Failed to open crontab for writing: $!");
print $pipe $content;
close $pipe;
if ($? != 0) {
return (0, "crontab command failed with exit code " . ($? >> 8));
}
return (1, undef);
}
# _strip_schedule_entries($crontab, $name)
# Removes the tag line and following command line for a specific schedule.
sub _strip_schedule_entries {
my ($crontab, $name) = @_;
my @lines = split /\n/, $crontab, -1;
my @out;
my $i = 0;
while ($i < @lines) {
if ($lines[$i] eq "$GNIZA_TAG$name") {
# Skip tag line and the following command line
$i++;
$i++ if $i < @lines; # skip command line
next;
}
push @out, $lines[$i];
$i++;
}
return join("\n", @out);
}
1;

View File

@@ -0,0 +1,127 @@
package GnizaWHM::Runner;
# Pattern-based command runner for gniza CLI.
# Each allowed command has a regex per argument position for safe execution.
use strict;
use warnings;
use IPC::Open3;
use Symbol 'gensym';
my $GNIZA_BIN = '/usr/local/bin/gniza';
# Allowed command patterns.
# Each entry: [ subcommand, arg_patterns... ]
# arg_patterns are regexes applied to each positional argument.
my @ALLOWED = (
# restore subcommands
{ cmd => 'restore', subcmd => 'account', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'files', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'database', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'database', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9_]+$/] },
{ cmd => 'restore', subcmd => 'mailbox', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'mailbox', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/] },
{ cmd => 'restore', subcmd => 'cron', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'dbusers', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'dbusers', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9_]+$/] },
{ cmd => 'restore', subcmd => 'cpconfig', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'domains', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'domains', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9._-]+$/] },
{ cmd => 'restore', subcmd => 'ssl', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'ssl', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9._-]+$/] },
{ cmd => 'restore', subcmd => 'list-databases', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'list-mailboxes', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'list-files', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'list-dbusers', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'list-cron', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'list-dns', args => [qr/^[a-z][a-z0-9_-]*$/] },
{ cmd => 'restore', subcmd => 'list-ssl', args => [qr/^[a-z][a-z0-9_-]*$/] },
# list
{ cmd => 'list', subcmd => undef, args => [] },
);
# Named option patterns (--key=value).
my %OPT_PATTERNS = (
remote => qr/^[a-zA-Z0-9_,-]+$/,
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
path => qr/^[a-zA-Z0-9_.\/@ -]+$/,
account => qr/^[a-z][a-z0-9_-]*$/,
);
# run($cmd, $subcmd, \@args, \%opts)
# Returns ($success, $stdout, $stderr).
sub run {
my ($cmd, $subcmd, $args, $opts) = @_;
$args //= [];
$opts //= {};
# Find matching allowed pattern
my $matched;
for my $pattern (@ALLOWED) {
next unless $pattern->{cmd} eq $cmd;
if (defined $pattern->{subcmd}) {
next unless defined $subcmd && $subcmd eq $pattern->{subcmd};
} else {
next if defined $subcmd;
}
# Check arg count
next unless scalar(@$args) == scalar(@{$pattern->{args}});
# Validate each arg
my $ok = 1;
for my $i (0 .. $#$args) {
unless ($args->[$i] =~ $pattern->{args}[$i]) {
$ok = 0;
last;
}
}
if ($ok) {
$matched = $pattern;
last;
}
}
unless ($matched) {
my $desc = "gniza $cmd" . (defined $subcmd ? " $subcmd" : "") . " " . join(" ", @$args);
return (0, '', "Command not allowed: $desc");
}
# Validate options
for my $key (keys %$opts) {
my $pat = $OPT_PATTERNS{$key};
unless ($pat) {
return (0, '', "Unknown option: --$key");
}
unless ($opts->{$key} =~ $pat) {
return (0, '', "Invalid value for --$key: $opts->{$key}");
}
}
# Build command
my @exec_args = ($cmd);
push @exec_args, $subcmd if defined $subcmd;
push @exec_args, @$args;
for my $key (sort keys %$opts) {
push @exec_args, "--$key=$opts->{$key}";
}
return _exec(@exec_args);
}
sub _exec {
my (@args) = @_;
my $err = gensym;
my $pid = open3(my $in, my $out, $err, $GNIZA_BIN, @args);
close $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err> } // '';
close $out;
close $err;
waitpid($pid, 0);
my $exit_code = $? >> 8;
return ($exit_code == 0, $stdout, $stderr);
}
1;

View File

@@ -0,0 +1,586 @@
package GnizaWHM::UI;
# Shared UI helpers: navigation, flash messages, CSRF, HTML escaping,
# account list, version detection, mode detection, schedule discovery.
use strict;
use warnings;
use Fcntl qw(:flock);
use IPC::Open3;
use Symbol 'gensym';
my $CSRF_DIR = '/var/cpanel/.gniza-whm-csrf';
my $FLASH_DIR = '/tmp';
my $CONSTANTS_FILE = '/usr/local/gniza/lib/constants.sh';
my $MAIN_CONFIG = '/etc/gniza/gniza.conf';
my $REMOTES_DIR = '/etc/gniza/remotes.d';
my $SCHEDULES_DIR = '/etc/gniza/schedules.d';
my $TRUEUSERDOMAINS = '/etc/trueuserdomains';
my $REMOTE_EXAMPLE = '/usr/local/gniza/etc/remote.conf.example';
my $SCHEDULE_EXAMPLE = '/usr/local/gniza/etc/schedule.conf.example';
my $SSH_DIR = '/root/.ssh';
my $CSS_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-whm.css';
# ── HTML Escaping ─────────────────────────────────────────────
sub esc {
my ($str) = @_;
$str //= '';
$str =~ s/&/&amp;/g;
$str =~ s/</&lt;/g;
$str =~ s/>/&gt;/g;
$str =~ s/"/&quot;/g;
$str =~ s/'/&#39;/g;
return $str;
}
# ── Navigation ────────────────────────────────────────────────
my @NAV_ITEMS = (
{ url => 'index.cgi', label => 'Dashboard' },
{ url => 'remotes.cgi', label => 'Remotes' },
{ url => 'schedules.cgi', label => 'Schedules' },
{ url => 'restore.cgi', label => 'Restore' },
{ url => 'settings.cgi', label => 'Settings' },
);
sub render_nav {
my ($current_page) = @_;
my $html = qq{<div role="tablist" class="tabs tabs-box tabs-lg mb-5 mx-auto" style="width:fit-content">\n};
for my $item (@NAV_ITEMS) {
my $active = ($item->{url} eq $current_page) ? ' tab-active' : '';
my $label = esc($item->{label});
$html .= qq{ <a role="tab" class="tab$active" href="$item->{url}">$label</a>\n};
}
$html .= qq{</div>\n};
return $html;
}
# ── Flash Messages ────────────────────────────────────────────
sub _flash_file {
return "$FLASH_DIR/gniza-whm-flash-$$";
}
sub set_flash {
my ($type, $text) = @_;
my $file = "$FLASH_DIR/gniza-whm-flash";
if (open my $fh, '>', $file) {
print $fh "$type\n$text\n";
close $fh;
}
}
sub get_flash {
my $file = "$FLASH_DIR/gniza-whm-flash";
return undef unless -f $file;
my ($type, $text);
if (open my $fh, '<', $file) {
$type = <$fh>;
$text = <$fh>;
close $fh;
}
unlink $file;
return undef unless defined $type && defined $text;
chomp $type;
chomp $text;
return ($type, $text);
}
sub render_flash {
my @flash = get_flash();
return '' unless defined $flash[0];
my ($type, $text) = @flash;
my $escaped = esc($text);
return qq{<div class="alert alert-$type mb-4">$escaped</div>\n};
}
# ── CSRF Protection ──────────────────────────────────────────
sub _csrf_file {
return $CSRF_DIR;
}
my $_current_csrf_token;
sub generate_csrf_token {
# Reuse the same token within a single request so multiple forms
# on one page all share the same valid token.
return $_current_csrf_token if defined $_current_csrf_token;
my $token = '';
for (1..32) {
$token .= sprintf('%02x', int(rand(256)));
}
if (open my $fh, '>', $CSRF_DIR) {
print $fh time() . "\n" . $token . "\n";
close $fh;
}
$_current_csrf_token = $token;
return $token;
}
sub verify_csrf_token {
my ($submitted) = @_;
return 0 unless defined $submitted && $submitted ne '';
return 0 unless -f $CSRF_DIR;
my ($stored_time, $stored_token);
if (open my $fh, '<', $CSRF_DIR) {
$stored_time = <$fh>;
$stored_token = <$fh>;
close $fh;
}
return 0 unless defined $stored_time && defined $stored_token;
chomp $stored_time;
chomp $stored_token;
# Delete after use (single-use)
unlink $CSRF_DIR;
# Check expiry (1 hour)
return 0 if (time() - $stored_time) > 3600;
# Constant-time comparison
return 0 if length($submitted) != length($stored_token);
my $result = 0;
for my $i (0 .. length($submitted) - 1) {
$result |= ord(substr($submitted, $i, 1)) ^ ord(substr($stored_token, $i, 1));
}
return $result == 0;
}
sub csrf_hidden_field {
my $token = generate_csrf_token();
return qq{<input type="hidden" name="gniza_csrf" value="} . esc($token) . qq{">};
}
# ── Account List ──────────────────────────────────────────────
sub get_cpanel_accounts {
my @accounts;
if (open my $fh, '<', $TRUEUSERDOMAINS) {
while (my $line = <$fh>) {
chomp $line;
if ($line =~ /:\s*(\S+)/) {
push @accounts, $1;
}
}
close $fh;
}
my %seen;
@accounts = sort grep { !$seen{$_}++ } @accounts;
return @accounts;
}
# ── gniza Version ────────────────────────────────────────────
sub get_gniza_version {
if (open my $fh, '<', $CONSTANTS_FILE) {
while (my $line = <$fh>) {
if ($line =~ /GNIZA_VERSION="([^"]+)"/) {
close $fh;
return $1;
}
}
close $fh;
}
return 'unknown';
}
# ── Remote Discovery ─────────────────────────────────────────
sub has_remotes {
return 0 unless -d $REMOTES_DIR;
if (opendir my $dh, $REMOTES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /\.conf$/) {
closedir $dh;
return 1;
}
}
closedir $dh;
}
return 0;
}
sub list_remotes {
my @remotes;
if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /^(.+)\.conf$/) {
push @remotes, $1;
}
}
closedir $dh;
}
return sort @remotes;
}
sub remote_conf_path {
my ($name) = @_;
return "$REMOTES_DIR/$name.conf";
}
sub remote_example_path {
return $REMOTE_EXAMPLE;
}
# ── Schedule Discovery ───────────────────────────────────────
sub has_schedules {
return 0 unless -d $SCHEDULES_DIR;
if (opendir my $dh, $SCHEDULES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /\.conf$/) {
closedir $dh;
return 1;
}
}
closedir $dh;
}
return 0;
}
sub list_schedules {
my @schedules;
if (-d $SCHEDULES_DIR && opendir my $dh, $SCHEDULES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /^(.+)\.conf$/) {
push @schedules, $1;
}
}
closedir $dh;
}
return sort @schedules;
}
sub schedule_conf_path {
my ($name) = @_;
return "$SCHEDULES_DIR/$name.conf";
}
sub schedule_example_path {
return $SCHEDULE_EXAMPLE;
}
# ── Configuration Detection ──────────────────────────────────
sub is_configured {
return has_remotes();
}
# ── SSH Key Detection ────────────────────────────────────────
my @SSH_KEY_TYPES = (
{ file => 'id_ed25519', type => 'ed25519' },
{ file => 'id_rsa', type => 'rsa' },
{ file => 'id_ecdsa', type => 'ecdsa' },
{ file => 'id_dsa', type => 'dsa' },
);
sub detect_ssh_keys {
my @keys;
for my $kt (@SSH_KEY_TYPES) {
my $path = "$SSH_DIR/$kt->{file}";
next unless -f $path;
push @keys, {
path => $path,
type => $kt->{type},
has_pub => (-f "$path.pub") ? 1 : 0,
};
}
return \@keys;
}
sub render_ssh_guidance {
my $keys = detect_ssh_keys();
my $html = qq{<div class="card bg-base-200 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
$html .= qq{<h2 class="card-title text-sm">SSH Key Setup</h2>\n};
if (@$keys) {
$html .= qq{<p>Existing SSH keys detected on this server:</p>\n};
$html .= qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
$html .= qq{<tr><th>Type</th><th>Path</th><th>Public Key</th></tr>\n};
for my $k (@$keys) {
my $pub = $k->{has_pub} ? 'Available' : 'Missing';
$html .= qq{<tr><td>} . esc($k->{type}) . qq{</td>};
$html .= qq{<td><code>} . esc($k->{path}) . qq{</code></td>};
$html .= qq{<td>$pub</td></tr>\n};
}
$html .= qq{</table></div>\n};
} else {
$html .= qq{<p>No SSH keys found in <code>/root/.ssh/</code>.</p>\n};
}
$html .= qq{<p class="mt-3"><strong>Generate a new key</strong> (if needed):</p>\n};
$html .= qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""</pre>\n};
$html .= qq{<p><strong>Copy the public key</strong> to the remote server:</p>\n};
$html .= qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host</pre>\n};
$html .= qq{<p class="text-xs text-base-content/60 mt-2">Run these commands in WHM &rarr; Server Configuration &rarr; Terminal, or via SSH.</p>\n};
$html .= qq{</div>\n</div>\n};
return $html;
}
# ── SSH Connection Test ──────────────────────────────────────
sub test_ssh_connection {
my (%args) = @_;
# Support legacy positional args: ($host, $port, $user, $key)
if (@_ == 4 && !ref $_[0]) {
%args = (host => $_[0], port => $_[1], user => $_[2], key => $_[3]);
}
my $host = $args{host};
my $port = $args{port} || '22';
my $user = $args{user} || 'root';
my $auth_method = $args{auth_method} || 'key';
my $key = $args{key} // '';
my $password = $args{password} // '';
my @ssh_args = (
'ssh', '-n',
'-p', $port,
'-o', 'StrictHostKeyChecking=accept-new',
'-o', 'ConnectTimeout=10',
'-o', 'ServerAliveInterval=60',
'-o', 'ServerAliveCountMax=3',
);
my @cmd;
if ($auth_method eq 'password') {
push @ssh_args, "$user\@$host", 'echo ok';
@cmd = ('sshpass', '-p', $password, @ssh_args);
} else {
push @ssh_args, '-i', $key, '-o', 'BatchMode=yes';
push @ssh_args, "$user\@$host", 'echo ok';
@cmd = @ssh_args;
}
my ($in, $out, $err_fh) = (undef, undef, gensym);
my $pid = eval { open3($in, $out, $err_fh, @cmd) };
unless ($pid) {
return (0, "Failed to run ssh: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
chomp $stdout;
chomp $stderr;
if ($exit_code == 0 && $stdout eq 'ok') {
return (1, undef);
}
my $msg = $stderr || "SSH exited with code $exit_code";
return (0, $msg);
}
# ── Rclone Connection Test ────────────────────────────────────
sub test_rclone_connection {
my (%args) = @_;
my $type = $args{type} // 's3';
# Build temp rclone config
my $tmpfile = "/tmp/gniza-rclone-test-$$.conf";
my $conf_content = '';
my $test_path = '';
if ($type eq 's3') {
my $key_id = $args{s3_access_key_id} // '';
my $secret = $args{s3_secret_access_key} // '';
my $region = $args{s3_region} || 'us-east-1';
my $endpoint = $args{s3_endpoint} // '';
my $bucket = $args{s3_bucket} // '';
$conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $key_id\nsecret_access_key = $secret\nregion = $region\n";
$conf_content .= "endpoint = $endpoint\n" if $endpoint ne '';
$test_path = "remote:$bucket";
}
elsif ($type eq 'gdrive') {
my $sa_file = $args{gdrive_service_account_file} // '';
my $folder_id = $args{gdrive_root_folder_id} // '';
$conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $sa_file\n";
$conf_content .= "root_folder_id = $folder_id\n" if $folder_id ne '';
$test_path = "remote:";
}
else {
return (0, "Unknown remote type: $type");
}
# Write config
if (open my $fh, '>', $tmpfile) {
print $fh $conf_content;
close $fh;
chmod 0600, $tmpfile;
} else {
return (0, "Failed to write temp rclone config: $!");
}
# Run rclone lsd
my @cmd = ('rclone', '--config', $tmpfile, 'lsd', $test_path);
my ($in, $out, $err_fh) = (undef, undef, gensym);
my $pid = eval { open3($in, $out, $err_fh, @cmd) };
unless ($pid) {
unlink $tmpfile;
return (0, "Failed to run rclone: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
unlink $tmpfile;
chomp $stderr;
if ($exit_code == 0) {
return (1, undef);
}
my $msg = $stderr || "rclone exited with code $exit_code";
return (0, $msg);
}
# ── Page Wrappers ────────────────────────────────────────────
sub page_header {
my ($title) = @_;
$title = esc($title // 'gniza Backup Manager');
my $css = '';
if (open my $fh, '<', $CSS_FILE) {
local $/;
$css = <$fh>;
close $fh;
}
# Strip @layer wrappers so our styles are un-layered and compete
# with WHM's CSS on normal specificity instead of losing to it.
$css = _unwrap_layers($css);
# Scope :root/:host to our container so DaisyUI base styles
# (background, color, overflow, scrollbar) don't leak into WHM.
$css = _scope_to_container($css);
return qq{<style>$css</style>\n}
. qq{<div data-theme="light" class="font-sans text-[1.7rem]" style="background:#fafafa">\n}
. qq{<h1 class="text-xl font-bold mb-4">$title</h1>\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 "</div>\n";
}
sub render_errors {
my ($errors) = @_;
return '' unless $errors && @$errors;
my $html = qq{<div class="alert alert-error mb-4">\n<ul class="list-disc pl-5">\n};
for my $err (@$errors) {
$html .= ' <li>' . esc($err) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
return $html;
}
1;

View File

@@ -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;

741
whm/gniza-whm/remotes.cgi Normal file
View File

@@ -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{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
if (@remotes) {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<thead><tr><th>Name</th><th>Type</th><th>Destination</th><th>Retention</th><th>Actions</th></tr></thead>\n};
print qq{<tbody>\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{<tr class="hover">};
print qq{<td><strong>$esc_name</strong></td>};
print qq{<td><span class="badge badge-sm">$type_label</span></td>};
print qq{<td>$dest</td><td>$retention</td>};
print qq{<td>};
print qq{<div class="flex items-center gap-2">};
print qq{<a href="remotes.cgi?action=edit&amp;name=$esc_name" class="btn btn-primary btn-sm">Edit</a>};
print qq{<form method="POST" action="remotes.cgi" class="inline">};
print qq{<input type="hidden" name="action" value="delete">};
print qq{<input type="hidden" name="name" value="$esc_name">};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Delete remote $esc_name?')">Delete</button>};
print qq{</form>};
print qq{</div>};
print qq{</td>};
print qq{</tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No remote destinations configured. Add a remote to enable multi-remote backups.</p>\n};
print qq{<p class="text-xs text-base-content/60 mt-2">Remote configs are stored in <code>/etc/gniza/remotes.d/</code>.</p>\n};
}
print qq{</div>\n</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <a href="remotes.cgi?action=add" class="btn btn-primary btn-sm">Add Remote</a>\n};
print qq{</div>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Add ──────────────────────────────────────────────────────
sub handle_add {
my @errors;
if ($method eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
push @errors, 'Invalid or expired form token. Please try again.';
}
my $name = $form->{'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{<form method="POST" action="remotes.cgi">\n};
print qq{<input type="hidden" name="action" value="$action_val">\n};
print GnizaWHM::UI::csrf_hidden_field();
if ($is_edit) {
print qq{<input type="hidden" name="name" value="$name_val">\n};
}
# Remote name
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Remote Identity</h2>\n};
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="remote_name">Remote Name</label>\n};
if ($is_edit) {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" value="$name_val" disabled>\n};
} else {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="remote_name" name="remote_name" value="$name_val" required>\n};
print qq{ <span class="text-xs text-base-content/60 ml-2">Letters, digits, hyphens, underscores</span>\n};
}
print qq{</div>\n};
# 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{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm">Remote Type</label>\n};
print qq{ <div class="join">\n};
print qq{ <input type="radio" name="REMOTE_TYPE" class="join-item btn btn-sm" aria-label="SSH" value="ssh" onchange="gnizaTypeChanged()"$ssh_checked>\n};
print qq{ <input type="radio" name="REMOTE_TYPE" class="join-item btn btn-sm" aria-label="Amazon S3" value="s3" onchange="gnizaTypeChanged()"$s3_checked>\n};
print qq{ <input type="radio" name="REMOTE_TYPE" class="join-item btn btn-sm" aria-label="Google Drive" value="gdrive" onchange="gnizaTypeChanged()"$gdrive_checked>\n};
print qq{ </div>\n};
print qq{</div>\n};
print qq{</div>\n</div>\n};
# ── SSH fields ────────────────────────────────────────────
my $ssh_hidden = ($remote_type ne 'ssh') ? ' hidden' : '';
# SSH key guidance (add mode only)
print qq{<div id="type-ssh-guidance"$ssh_hidden>\n};
unless ($is_edit) {
print GnizaWHM::UI::render_ssh_guidance();
}
print qq{</div>\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{<div id="type-ssh-fields"$ssh_hidden>\n};
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">SSH Connection</h2>\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{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm">Auth Method</label>\n};
print qq{ <div class="join">\n};
print qq{ <input type="radio" name="REMOTE_AUTH_METHOD" class="join-item btn btn-sm" aria-label="SSH Key" value="key" onchange="gnizaAuthChanged()"$key_checked>\n};
print qq{ <input type="radio" name="REMOTE_AUTH_METHOD" class="join-item btn btn-sm" aria-label="Password" value="password" onchange="gnizaAuthChanged()"$pw_checked>\n};
print qq{ </div>\n};
print qq{</div>\n};
# Key field
print qq{<div id="auth-key-field"$key_hidden>\n};
_field($conf, 'REMOTE_KEY', 'SSH Private Key', 'Absolute path');
print qq{</div>\n};
# Password field
my $pw_val = GnizaWHM::UI::esc($conf->{REMOTE_PASSWORD} // '');
print qq{<div id="auth-password-field"$pw_hidden>\n};
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="REMOTE_PASSWORD">SSH Password</label>\n};
print qq{ <input type="password" class="input input-bordered input-sm w-full max-w-xs" id="REMOTE_PASSWORD" name="REMOTE_PASSWORD" value="$pw_val">\n};
print qq{ <span class="text-xs text-base-content/60 ml-2">Requires sshpass on server</span>\n};
print qq{</div>\n};
print qq{</div>\n};
print qq{</div>\n</div>\n};
print qq{</div>\n};
# ── S3 fields ─────────────────────────────────────────────
my $s3_hidden = ($remote_type ne 's3') ? ' hidden' : '';
print qq{<div id="type-s3-fields"$s3_hidden>\n};
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Amazon S3 / S3-Compatible</h2>\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{<p class="text-xs text-base-content/60 mt-2">Requires <code>rclone</code> installed on this server.</p>\n};
print qq{</div>\n</div>\n};
print qq{</div>\n};
# ── Google Drive fields ───────────────────────────────────
my $gdrive_hidden = ($remote_type ne 'gdrive') ? ' hidden' : '';
print qq{<div id="type-gdrive-fields"$gdrive_hidden>\n};
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Google Drive</h2>\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{<p class="text-xs text-base-content/60 mt-2">Requires <code>rclone</code> installed on this server.</p>\n};
print qq{</div>\n</div>\n};
print qq{</div>\n};
# ── Common fields ─────────────────────────────────────────
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Storage Path</h2>\n};
_field($conf, 'REMOTE_BASE', 'Remote Base Dir', 'Default: /backups');
print qq{</div>\n</div>\n};
# Transfer
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Transfer Settings</h2>\n};
_field($conf, 'BWLIMIT', 'Bandwidth Limit', 'KB/s, 0 = unlimited');
print qq{<div id="rsync-opts-field"$ssh_hidden>\n};
_field($conf, 'RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'SSH only');
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Retention
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Retention</h2>\n};
_field($conf, 'RETENTION_COUNT', 'Snapshots to Keep', 'Default: 30');
print qq{</div>\n</div>\n};
# Submit
print qq{<div class="flex gap-2 mt-4">\n};
my $btn_label = $is_edit ? 'Save Changes' : 'Create Remote';
print qq{ <button type="submit" class="btn btn-primary btn-sm">$btn_label</button>\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" id="test-conn-btn" onclick="gnizaTestConnection()">Test Connection</button>\n};
print qq{ <a href="remotes.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
print <<'JS';
<script>
function gnizaGetType() {
var radios = document.querySelectorAll('input[name="REMOTE_TYPE"]');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return radios[i].value;
}
return 'ssh';
}
function gnizaGetAuthMethod() {
var radios = document.querySelectorAll('input[name="REMOTE_AUTH_METHOD"]');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return radios[i].value;
}
return 'key';
}
function gnizaTypeChanged() {
var type = gnizaGetType();
var sshFields = document.getElementById('type-ssh-fields');
var sshGuidance = document.getElementById('type-ssh-guidance');
var s3Fields = document.getElementById('type-s3-fields');
var gdriveFields = document.getElementById('type-gdrive-fields');
var rsyncOpts = document.getElementById('rsync-opts-field');
sshFields.hidden = (type !== 'ssh');
sshGuidance.hidden = (type !== 'ssh');
s3Fields.hidden = (type !== 's3');
gdriveFields.hidden = (type !== 'gdrive');
rsyncOpts.hidden = (type !== 'ssh');
}
function gnizaAuthChanged() {
var method = gnizaGetAuthMethod();
var keyField = document.getElementById('auth-key-field');
var pwField = document.getElementById('auth-password-field');
if (method === 'password') {
keyField.hidden = true;
pwField.hidden = false;
} else {
keyField.hidden = false;
pwField.hidden = true;
}
}
function gnizaTestConnection() {
var type = gnizaGetType();
var btn = document.getElementById('test-conn-btn');
var fd = new FormData();
fd.append('action', 'test');
fd.append('remote_type', type);
if (type === 'ssh') {
var host = document.getElementById('REMOTE_HOST').value;
var port = document.getElementById('REMOTE_PORT').value;
var user = document.getElementById('REMOTE_USER').value;
var authMethod = gnizaGetAuthMethod();
var key = document.getElementById('REMOTE_KEY').value;
var pw = document.getElementById('REMOTE_PASSWORD').value;
if (!host) { gnizaToast('error', 'Host is required.'); return; }
if (authMethod === 'password' && !pw) { gnizaToast('error', 'Password is required.'); return; }
if (authMethod === 'key' && !key) { gnizaToast('error', 'SSH key path is required.'); return; }
fd.append('host', host);
fd.append('port', port);
fd.append('user', user);
fd.append('auth_method', authMethod);
fd.append('key', key);
fd.append('password', pw);
}
else if (type === 's3') {
var keyId = document.getElementById('S3_ACCESS_KEY_ID').value;
var secret = document.getElementById('S3_SECRET_ACCESS_KEY').value;
var bucket = document.getElementById('S3_BUCKET').value;
if (!keyId || !secret) { gnizaToast('error', 'S3 access key and secret are required.'); return; }
if (!bucket) { gnizaToast('error', 'S3 bucket is required.'); return; }
fd.append('S3_ACCESS_KEY_ID', keyId);
fd.append('S3_SECRET_ACCESS_KEY', secret);
fd.append('S3_REGION', document.getElementById('S3_REGION').value);
fd.append('S3_ENDPOINT', document.getElementById('S3_ENDPOINT').value);
fd.append('S3_BUCKET', bucket);
}
else if (type === 'gdrive') {
var saFile = document.getElementById('GDRIVE_SERVICE_ACCOUNT_FILE').value;
if (!saFile) { gnizaToast('error', 'Service account file path is required.'); return; }
fd.append('GDRIVE_SERVICE_ACCOUNT_FILE', saFile);
fd.append('GDRIVE_ROOT_FOLDER_ID', document.getElementById('GDRIVE_ROOT_FOLDER_ID').value);
}
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Testing\u2026';
fetch('remotes.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
gnizaToast(data.success ? 'success' : 'error', data.message);
})
.catch(function(err) {
gnizaToast('error', 'Request failed: ' + err.toString());
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = 'Test Connection';
});
}
function gnizaToast(type, msg) {
var el = document.createElement('div');
el.className = 'alert alert-' + type;
el.textContent = msg;
el.style.cssText = 'position:fixed;top:24px;right:24px;z-index:9999;max-width:480px;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:opacity .3s';
document.body.appendChild(el);
setTimeout(function() { el.style.opacity = '0'; }, type === 'error' ? 6000 : 3000);
setTimeout(function() { el.remove(); }, type === 'error' ? 6500 : 3500);
}
</script>
JS
}
sub _field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}
sub _password_field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="password" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}

1017
whm/gniza-whm/restore.cgi Normal file

File diff suppressed because it is too large Load Diff

511
whm/gniza-whm/schedules.cgi Normal file
View File

@@ -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{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Configured Schedules</h2>\n};
if (@schedules) {
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
print qq{<thead><tr><th>Name</th><th>Type</th><th>Time</th><th>Day</th><th>Remotes</th><th>Active</th><th>Actions</th></tr></thead>\n};
print qq{<tbody>\n};
for my $name (@schedules) {
my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($name), 'schedule');
my $esc_name = GnizaWHM::UI::esc($name);
my $esc_sched = GnizaWHM::UI::esc($conf->{SCHEDULE} // '');
my $esc_time = GnizaWHM::UI::esc($conf->{SCHEDULE_TIME} // '02:00');
my $esc_day = GnizaWHM::UI::esc($conf->{SCHEDULE_DAY} // '-');
my $esc_remotes = GnizaWHM::UI::esc($conf->{REMOTES} // '(all)');
$esc_remotes = '(all)' if $esc_remotes eq '';
my $in_cron = exists $cron_schedules->{$name};
my $checked = $in_cron ? ' checked' : '';
print qq{<tr class="hover">};
print qq{<td><strong>$esc_name</strong></td>};
print qq{<td>$esc_sched</td><td>$esc_time</td><td>$esc_day</td><td>$esc_remotes</td>};
print qq{<td>};
print qq{<input type="checkbox" class="toggle toggle-sm toggle-success" data-schedule="$esc_name" onchange="gnizaToggleCron(this)"$checked>};
print qq{</td>};
print qq{<td>};
print qq{<a href="schedules.cgi?action=edit&amp;name=$esc_name" class="btn btn-ghost btn-sm">Edit</a> };
print qq{<form method="POST" action="schedules.cgi" style="display:inline">};
print qq{<input type="hidden" name="action" value="delete">};
print qq{<input type="hidden" name="name" value="$esc_name">};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Delete schedule $esc_name?')">Delete</button>};
print qq{</form>};
print qq{</td>};
print qq{</tr>\n};
}
print qq{</tbody>\n</table></div>\n};
} else {
print qq{<p>No schedules configured. Add a schedule to define when backups run.</p>\n};
}
print qq{</div>\n</div>\n};
# CSRF token + AJAX toggle script
my $csrf_token = GnizaWHM::UI::generate_csrf_token();
print qq{<script>
var gnizaCsrf = '} . GnizaWHM::UI::esc($csrf_token) . qq{';
function gnizaToggleCron(el) {
var name = el.getAttribute('data-schedule');
el.disabled = true;
var fd = new FormData();
fd.append('action', 'toggle_cron');
fd.append('name', name);
fd.append('gniza_csrf', gnizaCsrf);
fetch('schedules.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
gnizaCsrf = d.csrf;
el.checked = d.active;
el.disabled = false;
})
.catch(function() {
el.checked = !el.checked;
el.disabled = false;
});
}
</script>\n};
# Action buttons
print qq{<div class="flex gap-2 mb-6">\n};
print qq{ <a href="schedules.cgi?action=add" class="btn btn-primary btn-sm">Add Schedule</a>\n};
print qq{</div>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();
}
# ── Add ──────────────────────────────────────────────────────
sub handle_add {
my @errors;
if ($method eq 'POST') {
unless (GnizaWHM::UI::verify_csrf_token($form->{'gniza_csrf'})) {
push @errors, 'Invalid or expired form token. Please try again.';
}
my $name = $form->{'schedule_name'} // '';
my $name_err = GnizaWHM::Validator::validate_schedule_name($name);
push @errors, $name_err if $name_err;
if (!@errors && -f GnizaWHM::UI::schedule_conf_path($name)) {
push @errors, "A schedule named '$name' already exists.";
}
my %data;
for my $key (@GnizaWHM::Config::SCHEDULE_KEYS) {
$data{$key} = $form->{$key} // '';
}
if (!@errors) {
my $validation_errors = GnizaWHM::Validator::validate_schedule_config(\%data);
push @errors, @$validation_errors;
}
if (!@errors) {
my $dest = GnizaWHM::UI::schedule_conf_path($name);
my $example = GnizaWHM::UI::schedule_example_path();
if (-f $example) {
File::Copy::copy($example, $dest)
or do { push @errors, "Failed to create schedule file: $!"; goto RENDER_ADD; };
}
my ($ok, $err) = GnizaWHM::Config::write($dest, \%data, \@GnizaWHM::Config::SCHEDULE_KEYS);
if ($ok) {
my ($cron_ok, $cron_err) = GnizaWHM::Cron::install_schedule($name);
if ($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{<form method="POST" action="schedules.cgi">\n};
print qq{<input type="hidden" name="action" value="$action_val">\n};
print GnizaWHM::UI::csrf_hidden_field();
if ($is_edit) {
print qq{<input type="hidden" name="name" value="$name_val">\n};
}
# Schedule name
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Schedule Identity</h2>\n};
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="schedule_name">Schedule Name</label>\n};
if ($is_edit) {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" value="$name_val" disabled>\n};
} else {
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="schedule_name" name="schedule_name" value="$name_val" required>\n};
print qq{ <span class="text-xs text-base-content/60 ml-2">Letters, digits, hyphens, underscores</span>\n};
}
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Schedule settings
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Schedule Settings</h2>\n};
my $sched = $conf->{SCHEDULE} // '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="SCHEDULE">Schedule Type</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="SCHEDULE" name="SCHEDULE" onchange="gnizaScheduleChange()">\n};
for my $opt ('hourly', 'daily', 'weekly', 'monthly', 'custom') {
my $sel = ($sched eq $opt) ? ' selected' : '';
print qq{ <option value="} . GnizaWHM::UI::esc($opt) . qq{"$sel>$opt</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
_sched_field($conf, 'SCHEDULE_TIME', 'Time (HH:MM)', 'Default: 02:00');
print qq{<div id="gniza-schedule-day">\n};
_sched_field($conf, 'SCHEDULE_DAY', 'Day', 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)');
print qq{</div>\n};
print qq{<div id="gniza-schedule-cron">\n};
_sched_field($conf, 'SCHEDULE_CRON', 'Cron Expression', '5-field cron (for custom only)');
print qq{</div>\n};
print qq{</div>\n</div>\n};
# Target remotes
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Target Remotes</h2>\n};
print qq{<p class="text-xs text-base-content/60 mb-3">Select which remotes this schedule targets. Leave all unchecked to target all remotes.</p>\n};
my @remotes = GnizaWHM::UI::list_remotes();
my %selected_remotes;
my $remotes_str = $conf->{REMOTES} // '';
for my $r (split /,/, $remotes_str) {
$r =~ s/^\s+|\s+$//g;
$selected_remotes{$r} = 1 if $r ne '';
}
if (@remotes) {
for my $rname (@remotes) {
my $esc_name = GnizaWHM::UI::esc($rname);
my $checked = $selected_remotes{$rname} ? ' checked' : '';
print qq{<label class="flex items-center gap-2 mb-1 cursor-pointer">\n};
print qq{ <input type="checkbox" class="checkbox checkbox-sm" name="remote_$esc_name" value="1"$checked>\n};
print qq{ <span class="text-sm">$esc_name</span>\n};
print qq{</label>\n};
}
# Hidden field to collect selected remotes via JS
print qq{<input type="hidden" name="REMOTES" id="remotes_hidden" value="} . GnizaWHM::UI::esc($remotes_str) . qq{">\n};
} else {
print qq{<p class="text-sm">No remotes configured. <a href="remotes.cgi?action=add" class="link">Add a remote</a> first.</p>\n};
}
print qq{</div>\n</div>\n};
# Submit
print qq{<div class="flex gap-2 mt-4">\n};
my $btn_label = $is_edit ? 'Save Changes' : 'Create Schedule';
print qq{ <button type="submit" class="btn btn-primary btn-sm" onclick="return gnizaCollectRemotes()">$btn_label</button>\n};
print qq{ <a href="schedules.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
# JS for schedule field visibility and remote collection
print <<'JS';
<script>
function gnizaScheduleChange() {
var sel = document.getElementById('SCHEDULE').value;
var dayDiv = document.getElementById('gniza-schedule-day');
var cronDiv = document.getElementById('gniza-schedule-cron');
dayDiv.style.display = (sel === 'hourly' || sel === 'weekly' || sel === 'monthly') ? '' : 'none';
cronDiv.style.display = (sel === 'custom') ? '' : 'none';
var dayLabel = dayDiv.querySelector('label');
var dayInput = dayDiv.querySelector('input');
var dayHint = dayDiv.querySelector('.text-xs');
if (sel === 'hourly') {
if (dayLabel) dayLabel.textContent = 'Every N hours';
if (dayHint) dayHint.textContent = '1-23 (default: 1 = every hour)';
} else {
if (dayLabel) dayLabel.textContent = 'Day';
if (dayHint) dayHint.textContent = 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)';
}
}
function gnizaCollectRemotes() {
var checks = document.querySelectorAll('input[type="checkbox"][name^="remote_"]');
var selected = [];
for (var i = 0; i < checks.length; i++) {
if (checks[i].checked) {
selected.push(checks[i].name.replace('remote_', ''));
}
}
var hidden = document.getElementById('remotes_hidden');
if (hidden) { hidden.value = selected.join(','); }
return true;
}
gnizaScheduleChange();
</script>
JS
}
sub _sched_field {
my ($conf, $key, $label, $hint) = @_;
my $val = GnizaWHM::UI::esc($conf->{$key} // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}

171
whm/gniza-whm/settings.cgi Normal file
View File

@@ -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{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$key" name="$key" value="$val" $extra>\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\n};
}
# Helper to output a select field row
sub field_select {
my ($key, $label, $options_ref) = @_;
my $current = $conf->{$key} // '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$key">$label</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="$key" name="$key">\n};
for my $opt (@$options_ref) {
my $sel = ($current eq $opt) ? ' selected' : '';
my $esc_opt = GnizaWHM::UI::esc($opt);
print qq{ <option value="$esc_opt"$sel>$esc_opt</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
}
# ── Form ─────────────────────────────────────────────────────
print qq{<form method="POST" action="settings.cgi">\n};
print GnizaWHM::UI::csrf_hidden_field();
# Section: Local Settings
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Local Settings</h2>\n};
field_text('TEMP_DIR', 'Working Directory', 'Default: /usr/local/gniza/workdir');
print qq{</div>\n</div>\n};
# Section: Account Filtering
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Account Filtering</h2>\n};
my $inc_val = GnizaWHM::UI::esc($conf->{INCLUDE_ACCOUNTS} // '');
my $exc_val = GnizaWHM::UI::esc($conf->{EXCLUDE_ACCOUNTS} // '');
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="INCLUDE_ACCOUNTS">Include Accounts</label>\n};
print qq{ <textarea class="textarea textarea-bordered textarea-sm w-full max-w-xs" id="INCLUDE_ACCOUNTS" name="INCLUDE_ACCOUNTS" placeholder="Comma-separated, empty = all">$inc_val</textarea>\n};
print qq{</div>\n};
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="EXCLUDE_ACCOUNTS">Exclude Accounts</label>\n};
print qq{ <textarea class="textarea textarea-bordered textarea-sm w-full max-w-xs" id="EXCLUDE_ACCOUNTS" name="EXCLUDE_ACCOUNTS" placeholder="Comma-separated">$exc_val</textarea>\n};
print qq{</div>\n};
my @accounts = GnizaWHM::UI::get_cpanel_accounts();
if (@accounts) {
print qq{<div class="text-xs text-base-content/60 mt-2">};
print qq{Available accounts: } . GnizaWHM::UI::esc(join(', ', @accounts));
print qq{</div>\n};
}
print qq{</div>\n</div>\n};
# Section: Logging
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Logging</h2>\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{</div>\n</div>\n};
# Section: Notifications
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Notifications</h2>\n};
field_text('NOTIFY_EMAIL', 'Email Address', 'Empty = disabled');
field_select('NOTIFY_ON', 'Notify On', ['always', 'failure', 'never']);
print qq{</div>\n</div>\n};
# Section: Advanced
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Advanced</h2>\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{</div>\n</div>\n};
# Submit
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Save Settings</button>\n};
print qq{</div>\n};
print qq{</form>\n};
print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer();

523
whm/gniza-whm/setup.cgi Normal file
View File

@@ -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{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 1: SSH Key</h2>\n};
print qq{<p>gniza uses SSH keys to connect to remote backup destinations. An SSH key must be set up before adding a remote.</p>\n};
if (@$keys) {
print qq{<div class="my-4">\n};
print qq{<p><strong>Existing keys found:</strong></p>\n};
print qq{<table class="table table-zebra w-full">\n};
print qq{<thead><tr><th></th><th>Type</th><th>Path</th><th>Public Key</th></tr></thead>\n};
print qq{<tbody>\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{<tr>};
print qq{<td><input type="radio" class="radio radio-sm" name="selected_key" value="$esc_path" form="step1form"$checked></td>};
print qq{<td>$esc_type</td>};
print qq{<td><code>$esc_path</code></td>};
print qq{<td>$pub</td>};
print qq{</tr>\n};
$first = 0;
}
print qq{</tbody>\n</table>\n};
print qq{<div class="flex items-center gap-3 mt-3">\n};
print qq{ <input type="radio" class="radio radio-sm" name="selected_key" value="_custom" form="step1form" id="key_custom_radio">\n};
print qq{ <label for="key_custom_path">Custom path:</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="key_custom_path" name="custom_key_path" form="step1form" placeholder="/root/.ssh/id_ed25519" onfocus="document.getElementById('key_custom_radio').checked=true">\n};
print qq{</div>\n};
print qq{</div>\n};
print qq{<form id="step1form" method="GET" action="setup.cgi">\n};
print qq{<input type="hidden" name="step" value="2">\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm" onclick="return gnizaPrepStep2()">Next: Configure Remote</button>\n};
print qq{ <a href="index.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
} else {
print qq{<div class="alert alert-info mb-4">No SSH keys found in <code>/root/.ssh/</code>. You need to create one first.</div>\n};
}
# Always show key generation instructions
print qq{<div class="mt-5">\n};
print qq{<p><strong>Generate a new SSH key</strong> (if needed):</p>\n};
print qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""</pre>\n};
print qq{<p><strong>Copy the public key</strong> to the remote server:</p>\n};
print qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host</pre>\n};
print qq{<p class="text-xs text-base-content/60 mt-2">Run these commands in WHM &rarr; Server Configuration &rarr; Terminal, or via SSH.</p>\n};
print qq{</div>\n};
unless (@$keys) {
print qq{<form method="GET" action="setup.cgi" class="mt-4">\n};
print qq{<input type="hidden" name="step" value="2">\n};
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="key_path_manual">Key path:</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="key_path_manual" name="key_path" value="/root/.ssh/id_ed25519">\n};
print qq{</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Next: Configure Remote</button>\n};
print qq{ <a href="index.cgi" class="btn btn-ghost btn-sm">Cancel</a>\n};
print qq{</div>\n};
print qq{</form>\n};
}
print qq{</div>\n</div>\n};
# JS to resolve selected key into key_path param
print <<'JS';
<script>
function gnizaPrepStep2() {
var form = document.getElementById('step1form');
var radios = document.querySelectorAll('input[name="selected_key"]');
var selected = '';
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) { selected = radios[i].value; break; }
}
if (selected === '_custom') {
selected = document.querySelector('input[name="custom_key_path"]').value;
}
if (!selected) { alert('Please select an SSH key.'); return false; }
var hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = 'key_path'; hidden.value = selected;
form.appendChild(hidden);
return true;
}
</script>
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{<form method="POST" action="setup.cgi">\n};
print qq{<input type="hidden" name="step" value="2">\n};
print qq{<input type="hidden" name="key_path" value="} . GnizaWHM::UI::esc($key_path) . qq{">\n};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 2: Remote Destination</h2>\n};
print qq{<p>Configure the remote server where backups will be stored.</p>\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{</div>\n</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Next: Set Schedule</button>\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" id="test-conn-btn" onclick="gnizaTestConnection()">Test Connection</button>\n};
print qq{ <a href="setup.cgi" class="btn btn-ghost btn-sm">Back</a>\n};
print qq{</div>\n};
print qq{</form>\n};
print <<'JS';
<script>
function gnizaTestConnection() {
var host = document.getElementById('REMOTE_HOST').value;
var port = document.getElementById('REMOTE_PORT').value;
var user = document.getElementById('REMOTE_USER').value;
var key = document.getElementById('REMOTE_KEY').value;
var btn = document.getElementById('test-conn-btn');
if (!host || !key) {
gnizaToast('error', 'Host and SSH key path are required.');
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Testing\u2026';
var fd = new FormData();
fd.append('step', 'test');
fd.append('host', host);
fd.append('port', port);
fd.append('user', user);
fd.append('key', key);
fetch('setup.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
gnizaToast(data.success ? 'success' : 'error', data.message);
})
.catch(function(err) {
gnizaToast('error', 'Request failed: ' + err.toString());
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = 'Test Connection';
});
}
function gnizaToast(type, msg) {
var el = document.createElement('div');
el.className = 'alert alert-' + type;
el.textContent = msg;
el.style.cssText = 'position:fixed;top:24px;right:24px;z-index:9999;max-width:480px;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:opacity .3s';
document.body.appendChild(el);
setTimeout(function() { el.style.opacity = '0'; }, type === 'error' ? 6000 : 3000);
setTimeout(function() { el.remove(); }, type === 'error' ? 6500 : 3500);
}
</script>
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{<form method="POST" action="setup.cgi">\n};
print qq{<input type="hidden" name="step" value="3">\n};
print qq{<input type="hidden" name="remote_name" value="$esc_name">\n};
print GnizaWHM::UI::csrf_hidden_field();
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 3: Backup Schedule</h2>\n};
print qq{<p>Set up an automatic backup schedule for remote <strong>$esc_name</strong>. You can skip this and configure it later.</p>\n};
my $sched = $conf->{SCHEDULE} // '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="SCHEDULE">Schedule Type</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="SCHEDULE" name="SCHEDULE" onchange="gnizaScheduleChange()">\n};
for my $opt ('', 'hourly', 'daily', 'weekly', 'monthly', 'custom') {
my $sel = ($sched eq $opt) ? ' selected' : '';
my $display = $opt eq '' ? '(none)' : $opt;
print qq{ <option value="} . GnizaWHM::UI::esc($opt) . qq{"$sel>$display</option>\n};
}
print qq{ </select>\n};
print qq{</div>\n};
_wiz_field_conf($conf, 'SCHEDULE_TIME', 'Time (HH:MM)', 'Default: 02:00');
print qq{<div id="gniza-schedule-day">\n};
_wiz_field_conf($conf, 'SCHEDULE_DAY', 'Day', 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)');
print qq{</div>\n};
print qq{<div id="gniza-schedule-cron">\n};
_wiz_field_conf($conf, 'SCHEDULE_CRON', 'Cron Expression', '5-field cron (for custom only)');
print qq{</div>\n};
print qq{</div>\n</div>\n};
print qq{<div class="flex gap-2 mt-4">\n};
print qq{ <button type="submit" class="btn btn-primary btn-sm">Finish Setup</button>\n};
print qq{ <a href="index.cgi" class="btn btn-ghost btn-sm">Skip</a>\n};
print qq{</div>\n};
print qq{</form>\n};
print <<'JS';
<script>
function gnizaScheduleChange() {
var sel = document.getElementById('SCHEDULE').value;
var dayDiv = document.getElementById('gniza-schedule-day');
var cronDiv = document.getElementById('gniza-schedule-cron');
dayDiv.style.display = (sel === 'hourly' || sel === 'weekly' || sel === 'monthly') ? '' : 'none';
cronDiv.style.display = (sel === 'custom') ? '' : 'none';
var dayLabel = dayDiv.querySelector('label');
var dayHint = dayDiv.querySelector('.text-xs');
if (sel === 'hourly') {
if (dayLabel) dayLabel.textContent = 'Every N hours';
if (dayHint) dayHint.textContent = '1-23 (default: 1 = every hour)';
} else {
if (dayLabel) dayLabel.textContent = 'Day';
if (dayHint) dayHint.textContent = 'Day-of-week 0-6 (weekly) or day-of-month 1-28 (monthly)';
}
}
gnizaScheduleChange();
</script>
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{<ul class="steps mb-6 w-full">\n};
for my $i (1..3) {
my $class = 'step';
$class .= ' step-primary' if $i <= $current;
print qq{ <li class="$class">$labels[$i-1]</li>\n};
}
print qq{</ul>\n};
}
sub _wiz_field {
my ($name, $val, $label, $hint) = @_;
$val = GnizaWHM::UI::esc($val // '');
my $hint_html = $hint ? qq{ <span class="text-xs text-base-content/60 ml-2">$hint</span>} : '';
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="$name">$label</label>\n};
print qq{ <input type="text" class="input input-bordered input-sm w-full max-w-xs" id="$name" name="$name" value="$val">\n};
print qq{ $hint_html\n} if $hint;
print qq{</div>\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;
}