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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
whm/gniza-whm/assets/node_modules/
|
||||
577
CLAUDE.md
Normal file
577
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
381
README.md
Normal 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
|
||||
27
etc/gniza.conf.example
Normal file
27
etc/gniza.conf.example
Normal 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
40
etc/remote.conf.example
Normal 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
17
etc/schedule.conf.example
Normal 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
62
lib/accounts.sh
Normal 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
58
lib/config.sh
Normal 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
51
lib/constants.sh
Normal 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
28
lib/locking.sh
Normal 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
57
lib/logging.sh
Normal 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
81
lib/notify.sh
Normal 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
74
lib/pkgacct.sh
Normal 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
335
lib/rclone.sh
Normal 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
271
lib/remotes.sh
Normal 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
1375
lib/restore.sh
Normal file
File diff suppressed because it is too large
Load Diff
52
lib/retention.sh
Normal file
52
lib/retention.sh
Normal 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
294
lib/schedule.sh
Normal 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
110
lib/snapshot.sh
Normal 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
76
lib/ssh.sh
Normal 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
172
lib/transfer.sh
Normal 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
51
lib/utils.sh
Normal 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
132
lib/verify.sh
Normal 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
72
scripts/install.sh
Executable 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
45
scripts/uninstall.sh
Executable 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
149
tests/test_utils.sh
Executable 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
7
whm/gniza-whm.conf
Normal 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
|
||||
2
whm/gniza-whm/assets/gniza-whm.css
Normal file
2
whm/gniza-whm/assets/gniza-whm.css
Normal file
File diff suppressed because one or more lines are too long
1001
whm/gniza-whm/assets/package-lock.json
generated
Normal file
1001
whm/gniza-whm/assets/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
whm/gniza-whm/assets/package.json
Normal file
12
whm/gniza-whm/assets/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
whm/gniza-whm/assets/src/input.css
Normal file
11
whm/gniza-whm/assets/src/input.css
Normal 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;
|
||||
}
|
||||
2
whm/gniza-whm/assets/src/safelist.html
Normal file
2
whm/gniza-whm/assets/src/safelist.html
Normal 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
86
whm/gniza-whm/index.cgi
Normal 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();
|
||||
142
whm/gniza-whm/lib/GnizaWHM/Config.pm
Normal file
142
whm/gniza-whm/lib/GnizaWHM/Config.pm
Normal 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;
|
||||
229
whm/gniza-whm/lib/GnizaWHM/Cron.pm
Normal file
229
whm/gniza-whm/lib/GnizaWHM/Cron.pm
Normal 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;
|
||||
127
whm/gniza-whm/lib/GnizaWHM/Runner.pm
Normal file
127
whm/gniza-whm/lib/GnizaWHM/Runner.pm
Normal 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;
|
||||
586
whm/gniza-whm/lib/GnizaWHM/UI.pm
Normal file
586
whm/gniza-whm/lib/GnizaWHM/UI.pm
Normal 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/&/&/g;
|
||||
$str =~ s/</</g;
|
||||
$str =~ s/>/>/g;
|
||||
$str =~ s/"/"/g;
|
||||
$str =~ s/'/'/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 → Server Configuration → 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;
|
||||
284
whm/gniza-whm/lib/GnizaWHM/Validator.pm
Normal file
284
whm/gniza-whm/lib/GnizaWHM/Validator.pm
Normal 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
741
whm/gniza-whm/remotes.cgi
Normal 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&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
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
511
whm/gniza-whm/schedules.cgi
Normal 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&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
171
whm/gniza-whm/settings.cgi
Normal 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
523
whm/gniza-whm/setup.cgi
Normal 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 → Server Configuration → 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;
|
||||
}
|
||||
Reference in New Issue
Block a user