diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go
index 45881fa..d4184a0 100644
--- a/cmd/proxsave/main.go
+++ b/cmd/proxsave/main.go
@@ -121,6 +121,54 @@ func run() int {
return types.ExitSuccess.Int()
}
+ if args.CleanupGuards {
+ incompatible := make([]string, 0, 8)
+ if args.Support {
+ incompatible = append(incompatible, "--support")
+ }
+ if args.Restore {
+ incompatible = append(incompatible, "--restore")
+ }
+ if args.Decrypt {
+ incompatible = append(incompatible, "--decrypt")
+ }
+ if args.Install {
+ incompatible = append(incompatible, "--install")
+ }
+ if args.NewInstall {
+ incompatible = append(incompatible, "--new-install")
+ }
+ if args.Upgrade {
+ incompatible = append(incompatible, "--upgrade")
+ }
+ if args.ForceNewKey {
+ incompatible = append(incompatible, "--newkey")
+ }
+ if args.EnvMigration || args.EnvMigrationDry {
+ incompatible = append(incompatible, "--env-migration/--env-migration-dry-run")
+ }
+ if args.UpgradeConfig || args.UpgradeConfigDry {
+ incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run")
+ }
+
+ if len(incompatible) > 0 {
+ bootstrap.Error("--cleanup-guards cannot be combined with: %s", strings.Join(incompatible, ", "))
+ return types.ExitConfigError.Int()
+ }
+
+ level := types.LogLevelInfo
+ if args.LogLevel != types.LogLevelNone {
+ level = args.LogLevel
+ }
+ logger := logging.New(level, false)
+
+ if err := orchestrator.CleanupMountGuards(ctx, logger, args.DryRun); err != nil {
+ bootstrap.Error("ERROR: %v", err)
+ return types.ExitGenericError.Int()
+ }
+ return types.ExitSuccess.Int()
+ }
+
// Validate support mode compatibility with other CLI modes
logging.DebugStepBootstrap(bootstrap, "main run", "support_mode=%v", args.Support)
if args.Support {
@@ -560,7 +608,6 @@ func run() int {
// If the installed version is up to date, nothing is printed at INFO/WARNING level
// (only a DEBUG message is logged). If a newer version exists, a WARNING is emitted
// suggesting the use of --upgrade.
- logging.DebugStep(logger, "main", "checking for updates")
updateInfo := checkForUpdates(ctx, logger, toolVersion)
// Apply backup permissions (optional, Bash-compatible behavior)
@@ -990,7 +1037,7 @@ func run() int {
}
}()
- logging.Debug("✓ Pre-backup checks configured")
+ logging.Info("✓ Pre-backup checks configured")
fmt.Println()
// Initialize storage backends
@@ -1688,9 +1735,14 @@ func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
+ logger.Debug("Checking for ProxSave updates (current: %s)", currentVersion)
+
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
+ logger.Debug("Fetching latest release from GitHub: %s", apiURL)
+
_, latestVersion, err := fetchLatestRelease(checkCtx)
if err != nil {
- logger.Debug("Update check skipped (GitHub unreachable): %v", err)
+ logger.Debug("Update check skipped: GitHub unreachable: %v", err)
return &UpdateInfo{
NewVersion: false,
Current: currentVersion,
@@ -1707,7 +1759,7 @@ func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion
}
if !isNewerVersion(currentVersion, latestVersion) {
- logger.Debug("Update check: current version (%s) is up to date (latest: %s)", currentVersion, latestVersion)
+ logger.Debug("Update check completed: latest=%s current=%s (up to date)", latestVersion, currentVersion)
return &UpdateInfo{
NewVersion: false,
Current: currentVersion,
@@ -1715,7 +1767,8 @@ func checkForUpdates(ctx context.Context, logger *logging.Logger, currentVersion
}
}
- logger.Warning("A newer ProxSave version is available %s (current %s): consider running 'proxsave --upgrade' to install the latest release.", latestVersion, currentVersion)
+ logger.Debug("Update check completed: latest=%s current=%s (new version available)", latestVersion, currentVersion)
+ logger.Warning("New ProxSave version %s (current %s): run 'proxsave --upgrade' to install.", latestVersion, currentVersion)
return &UpdateInfo{
NewVersion: true,
diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md
index 8a33a0e..4aea693 100644
--- a/docs/CLI_REFERENCE.md
+++ b/docs/CLI_REFERENCE.md
@@ -414,6 +414,7 @@ Next step: ./build/proxsave --dry-run
```
**Use `--cli` when**: TUI rendering issues occur or advanced debugging is needed.
+**Note**: CLI and TUI run the same workflow logic; `--cli` only changes the interface (prompts/progress rendering), not the restore/decrypt behavior.
**`--restore` workflow** (14 phases):
1. Scans configured storage locations (local/secondary/cloud)
@@ -449,9 +450,24 @@ Next step: ./build/proxsave --dry-run
| Flag | Description |
|------|-------------|
| `--restore` | Run interactive restore workflow (select bundle, decrypt if needed, apply to system) |
+| `--cleanup-guards` | Cleanup ProxSave mount guards under `/var/lib/proxsave/guards` (useful after restores with offline mountpoints; use with `--dry-run` to preview) |
---
+### Cleanup Mount Guards (Optional)
+
+During some restores (notably PBS datastores on mountpoints under `/mnt`), ProxSave may apply **mount guards** to prevent accidental writes to `/` when the underlying storage is offline/not mounted yet.
+
+If you want to remove those guards manually (optional):
+
+```bash
+# Preview (no changes)
+./build/proxsave --cleanup-guards --dry-run --log-level debug
+
+# Apply cleanup (requires root)
+./build/proxsave --cleanup-guards
+```
+
## Logging
### Set Log Level
diff --git a/docs/CLOUD_STORAGE.md b/docs/CLOUD_STORAGE.md
index 7707fc8..240b112 100644
--- a/docs/CLOUD_STORAGE.md
+++ b/docs/CLOUD_STORAGE.md
@@ -383,7 +383,7 @@ RETENTION_YEARLY=3
| `CLOUD_PARALLEL_MAX_JOBS` | `2` | Max concurrent uploads (parallel mode) |
| `CLOUD_PARALLEL_VERIFICATION` | `true` | Verify checksums after upload |
| `CLOUD_WRITE_HEALTHCHECK` | `false` | Use write test for connectivity check |
-| `RCLONE_TIMEOUT_CONNECTION` | `30` | Connection timeout (seconds). Also used by restore/decrypt when scanning cloud backups (list + manifest read). |
+| `RCLONE_TIMEOUT_CONNECTION` | `30` | Per-command timeout (seconds). Also used by restore/decrypt cloud scan (`rclone lsf` + per-backup manifest/metadata read). |
| `RCLONE_TIMEOUT_OPERATION` | `300` | Operation timeout (seconds) |
| `RCLONE_BANDWIDTH_LIMIT` | _(empty)_ | Upload rate limit (e.g., `5M` = 5 MB/s) |
| `RCLONE_TRANSFERS` | `4` | Number of parallel transfers |
@@ -437,7 +437,7 @@ path inside the remote, and uses that consistently for:
- **uploads** (cloud backend);
- **cloud retention**;
- **restore / decrypt menus** (entry “Cloud backups (rclone)”).
- - Restore/decrypt cloud scanning is protected by `RCLONE_TIMEOUT_CONNECTION` (increase it on slow remotes or very large directories).
+ - Restore/decrypt cloud scanning applies `RCLONE_TIMEOUT_CONNECTION` per rclone command (the timer resets on each `lsf`/manifest read).
You can choose the style you prefer; they are equivalent from the tool’s point of view.
@@ -617,7 +617,7 @@ rm /tmp/test*.txt
| `couldn't find configuration section 'gdrive'` | Remote not configured | `rclone config` → create remote |
| `401 unauthorized` | Credentials expired | `rclone config reconnect gdrive` or regenerate keys |
| `connection timeout (30s)` | Slow network | Increase `RCLONE_TIMEOUT_CONNECTION=60` |
-| `Timed out while scanning ... (rclone)` | Slow remote / huge directory | Increase `RCLONE_TIMEOUT_CONNECTION` and re-try restore/decrypt scan |
+| `Timed out while scanning ... (rclone)` | Slow remote / huge directory | Increase `RCLONE_TIMEOUT_CONNECTION` and ensure the remote path points to the directory that contains the backups (scan is non-recursive) |
| `operation timeout (300s exceeded)` | Large file + slow network | Increase `RCLONE_TIMEOUT_OPERATION=900` |
| `429 Too Many Requests` | API rate limiting | Reduce `RCLONE_TRANSFERS=2`, increase `CLOUD_BATCH_PAUSE=3` |
| `directory not found` | Path doesn't exist | `rclone mkdir gdrive:pbs-backups` |
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index f17a209..868c47f 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -352,6 +352,14 @@ BACKUP_EXCLUDE_PATTERNS="*/cache/**, /var/tmp/**, *.log"
- `**`: Match any directory recursively
- Example: `*/cache/**` excludes all `cache/` subdirectories
+### Exclusion Behavior (Guaranteed)
+
+- Exclusions are enforced consistently for anything that would end up inside the backup archive (files/directories copied from the host, full PVE/PBS snapshots, command outputs under `var/lib/proxsave-info/commands/`, and generated metadata such as `manifest.json` and `var/lib/proxsave-info/backup_metadata.txt`).
+- Patterns are matched against both:
+ - the original host path (e.g. `/etc/pve/nodes/node1/qemu-server/100.conf`), and
+ - the path inside the backup archive (e.g. `etc/pve/nodes/node1/qemu-server/100.conf`).
+ This means you can write patterns with or without a leading `/` and get consistent results.
+
---
## Secondary Storage
@@ -548,7 +556,7 @@ Quick comparison to help you choose the right storage configuration:
```bash
# Connection timeout (seconds)
-RCLONE_TIMEOUT_CONNECTION=30 # Remote accessibility check (also used for restore/decrypt cloud scan)
+RCLONE_TIMEOUT_CONNECTION=30 # Remote accessibility check (also used as per-command timeout for restore/decrypt cloud scan)
# Operation timeout (seconds)
RCLONE_TIMEOUT_OPERATION=300 # Upload/download operations (5 minutes default)
@@ -571,7 +579,7 @@ RCLONE_FLAGS="--checkers=4 --stats=0 --drive-use-trash=false --drive-pacer-min-s
### Timeout Tuning
-- **CONNECTION**: Short timeout for quick accessibility check (default 30s); also caps restore/decrypt cloud scanning (listing backups + reading manifests)
+- **CONNECTION**: Short timeout for quick accessibility check (default 30s); also applies per rclone command during restore/decrypt cloud scanning (listing backups + reading manifests)
- **OPERATION**: Long timeout for large file uploads (increase for slow networks)
### Bandwidth Limit Format
@@ -918,6 +926,8 @@ CEPH_CONFIG_PATH=/etc/ceph # Ceph config directory
BACKUP_VM_CONFIGS=true # VM/CT config files
```
+**Note (PVE snapshot behavior)**: ProxSave snapshots `PVE_CONFIG_PATH` for completeness. When a PVE feature is disabled, proxsave also excludes its well-known files from that snapshot to avoid “still included via full directory copy” surprises (e.g. `qemu-server/` + `lxc/` for `BACKUP_VM_CONFIGS=false`, `firewall/` + `host.fw` for `BACKUP_PVE_FIREWALL=false`, `user.cfg`/`acl.cfg`/`domains.cfg` for `BACKUP_PVE_ACL=false`, `jobs.cfg` + `vzdump.cron` for `BACKUP_PVE_JOBS=false`, `corosync.conf` (and `config.db` capture) for `BACKUP_CLUSTER_CONFIG=false`).
+
### PBS-Specific
```bash
@@ -955,6 +965,8 @@ PXAR_FILE_INCLUDE_PATTERN= # Include patterns (default: *.pxar, catalog.
PXAR_FILE_EXCLUDE_PATTERN= # Exclude patterns (e.g., *.tmp, *.lock)
```
+**Note (PBS snapshot behavior)**: ProxSave snapshots `PBS_CONFIG_PATH` (`/etc/proxmox-backup`) for completeness. When a PBS feature is disabled, proxsave excludes the corresponding well-known config files from that snapshot (for example, `remote.cfg` is excluded when `BACKUP_REMOTE_CONFIGS=false`) and also skips the related command outputs.
+
**PXAR scanning**: Collects metadata from Proxmox Backup Server .pxar archives.
### Override Collection Paths
@@ -1030,6 +1042,8 @@ BACKUP_SCRIPT_REPOSITORY=false # Include .git directory
BACKUP_CONFIG_FILE=true # Include this backup.env configuration file in the backup
```
+**Note (SSH keys)**: `BACKUP_SSH_KEYS=false` also suppresses `.ssh/` directories when collecting home directories (root and users), so keys aren’t included indirectly via `BACKUP_ROOT_HOME`/home collection.
+
**Note**: `BACKUP_CONFIG_FILE=true` automatically includes the `configs/backup.env` file in the backup archive. This is highly recommended for disaster recovery, as it allows you to restore your exact backup configuration along with the system files. If you have sensitive credentials in `backup.env`, ensure your backups are encrypted (`ENCRYPT_ARCHIVE=true`).
---
diff --git a/docs/RESTORE_DIAGRAMS.md b/docs/RESTORE_DIAGRAMS.md
index 919a88f..5007fb8 100644
--- a/docs/RESTORE_DIAGRAMS.md
+++ b/docs/RESTORE_DIAGRAMS.md
@@ -98,15 +98,15 @@ flowchart TD
Mode -->|4. CUSTOM| Custom[CUSTOM Mode]
Full --> SystemFull{System Type?}
- SystemFull -->|PVE| PVEFull[PVE Categories:
- pve_cluster
- storage_pve
- pve_jobs
- corosync
- ceph
+ Common]
- SystemFull -->|PBS| PBSFull[PBS Categories:
- pbs_config
- datastore_pbs
- pbs_jobs
+ Common]
- SystemFull -->|Unknown| CommonFull[Common Only:
- network
- ssl
- ssh
- scripts
- crontabs
- services
- zfs]
+ SystemFull -->|PVE| PVEFull[PVE Categories:
- pve_cluster
- storage_pve
- pve_jobs
- pve_notifications
- pve_access_control
- pve_firewall
- pve_ha
- pve_sdn
- corosync
- ceph
+ Common]
+ SystemFull -->|PBS| PBSFull[PBS Categories:
- pbs_host
- datastore_pbs
- maintenance_pbs
- pbs_jobs
- pbs_remotes
- pbs_notifications
- pbs_access_control
- pbs_tape
+ Common]
+ SystemFull -->|Unknown| CommonFull[Common Only:
- filesystem
- storage_stack
- network
- ssl
- ssh
- scripts
- crontabs
- services
- user_data
- zfs
- proxsave_info]
Storage --> SystemStorage{System Type?}
- SystemStorage -->|PVE| PVEStorage[- pve_cluster
- storage_pve
- pve_jobs
- zfs]
- SystemStorage -->|PBS| PBSStorage[- pbs_config
- datastore_pbs
- pbs_jobs
- zfs]
+ SystemStorage -->|PVE| PVEStorage[- pve_cluster
- storage_pve
- pve_jobs
- filesystem
- storage_stack
- zfs]
+ SystemStorage -->|PBS| PBSStorage[- datastore_pbs
- maintenance_pbs
- pbs_jobs
- pbs_remotes
- filesystem
- storage_stack
- zfs]
- Base --> BaseCats[- network
- ssl
- ssh
- services]
+ Base --> BaseCats[- network
- ssl
- ssh
- services
- filesystem]
Custom --> CheckboxMenu[Interactive Menu]
CheckboxMenu --> ToggleLoop{Toggle Categories}
diff --git a/docs/RESTORE_GUIDE.md b/docs/RESTORE_GUIDE.md
index cffac04..b2d25f3 100644
--- a/docs/RESTORE_GUIDE.md
+++ b/docs/RESTORE_GUIDE.md
@@ -71,46 +71,69 @@ The `--restore` command provides an **interactive, category-based restoration sy
### What Does NOT Get Restored
- VM/CT disk images (use Proxmox native tools)
-- Application data (databases, user data)
+- Application data (databases and app state under `/var/lib/*`)
- System packages (use apt/dpkg)
-- Active cluster filesystem (`/etc/pve` - export-only)
+- Direct tar extraction writes to the pmxcfs mount (`/etc/pve`) (ProxSave uses staged apply: API or controlled pmxcfs file apply)
---
## Category System
-Restore operations are organized into **15+ categories** that group related configuration files.
+Restore operations are organized into **20–22 categories** (PBS = 20, PVE = 22) that group related configuration files.
-### PVE-Specific Categories (6 categories)
+### Category Handling Types
+
+Each category is handled in one of three ways:
+
+- **Normal**: extracted directly to `/` (system paths) after safety backup
+- **Staged**: extracted to `/tmp/proxsave/restore-stage-*` and then applied in a controlled way (file copy/validation or `pvesh`)
+- **Export-only**: extracted to an export directory for manual review (never written to system paths)
+
+### PVE-Specific Categories (11 categories)
| Category | Name | Description | Paths |
|----------|------|-------------|-------|
| `pve_config_export` | PVE Config Export | **Export-only** copy of /etc/pve (never written to system) | `./etc/pve/` |
| `pve_cluster` | PVE Cluster Configuration | Cluster configuration and database | `./var/lib/pve-cluster/` |
-| `storage_pve` | PVE Storage Configuration | Storage definitions | `./etc/vzdump.conf` |
-| `pve_jobs` | PVE Backup Jobs | Scheduled backup jobs | `./etc/pve/jobs.cfg`
`./etc/pve/vzdump.cron` |
+| `storage_pve` | PVE Storage Configuration | **Staged** storage definitions (applied via API) + VZDump config | `./etc/pve/storage.cfg`
`./etc/pve/datacenter.cfg`
`./etc/vzdump.conf` |
+| `pve_jobs` | PVE Backup Jobs | **Staged** scheduled backup jobs (applied via API) | `./etc/pve/jobs.cfg`
`./etc/pve/vzdump.cron` |
+| `pve_notifications` | PVE Notifications | **Staged** notification targets and matchers (applied via API) | `./etc/pve/notifications.cfg`
`./etc/pve/priv/notifications.cfg` |
+| `pve_access_control` | PVE Access Control | **Staged** access control + secrets restored 1:1 via pmxcfs file apply (root@pam safety rail) | `./etc/pve/user.cfg`
`./etc/pve/domains.cfg`
`./etc/pve/priv/shadow.cfg`
`./etc/pve/priv/token.cfg`
`./etc/pve/priv/tfa.cfg` |
+| `pve_firewall` | PVE Firewall | **Staged** firewall rules and node host firewall (pmxcfs file apply + rollback timer) | `./etc/pve/firewall/`
`./etc/pve/nodes/*/host.fw` |
+| `pve_ha` | PVE High Availability (HA) | **Staged** HA resources/groups/rules (pmxcfs file apply + rollback timer) | `./etc/pve/ha/resources.cfg`
`./etc/pve/ha/groups.cfg`
`./etc/pve/ha/rules.cfg` |
+| `pve_sdn` | PVE SDN | **Staged** SDN definitions (pmxcfs file apply; definitions only) | `./etc/pve/sdn/`
`./etc/pve/sdn.cfg` |
| `corosync` | Corosync Configuration | Cluster communication settings | `./etc/corosync/` |
| `ceph` | Ceph Configuration | Ceph storage cluster config | `./etc/ceph/` |
-### PBS-Specific Categories (3 categories)
+### PBS-Specific Categories (9 categories)
| Category | Name | Description | Paths |
|----------|------|-------------|-------|
-| `pbs_config` | PBS Configuration | Main PBS configuration | `./etc/proxmox-backup/` |
-| `datastore_pbs` | PBS Datastore Configuration | Datastore definitions | `./etc/proxmox-backup/datastore.cfg` |
-| `pbs_jobs` | PBS Jobs | Sync, verify, prune jobs | `./etc/proxmox-backup/sync.cfg`
`./etc/proxmox-backup/verification.cfg`
`./etc/proxmox-backup/prune.cfg` |
-
-### Common Categories (7 categories)
+| `pbs_config` | PBS Config Export | **Export-only** copy of /etc/proxmox-backup (never written to system) | `./etc/proxmox-backup/` |
+| `pbs_host` | PBS Host & Integrations | **Staged** node settings, ACME, proxy, metric servers and traffic control | `./etc/proxmox-backup/node.cfg`
`./etc/proxmox-backup/proxy.cfg`
`./etc/proxmox-backup/acme/accounts.cfg`
`./etc/proxmox-backup/acme/plugins.cfg`
`./etc/proxmox-backup/metricserver.cfg`
`./etc/proxmox-backup/traffic-control.cfg` |
+| `datastore_pbs` | PBS Datastore Configuration | **Staged** datastore definitions (incl. S3 endpoints) | `./etc/proxmox-backup/datastore.cfg`
`./etc/proxmox-backup/s3.cfg` |
+| `maintenance_pbs` | PBS Maintenance | Maintenance settings | `./etc/proxmox-backup/maintenance.cfg` |
+| `pbs_jobs` | PBS Jobs | **Staged** sync/verify/prune jobs | `./etc/proxmox-backup/sync.cfg`
`./etc/proxmox-backup/verification.cfg`
`./etc/proxmox-backup/prune.cfg` |
+| `pbs_remotes` | PBS Remotes | **Staged** remotes for sync/verify (may include credentials) | `./etc/proxmox-backup/remote.cfg` |
+| `pbs_notifications` | PBS Notifications | **Staged** notification targets and matchers | `./etc/proxmox-backup/notifications.cfg`
`./etc/proxmox-backup/notifications-priv.cfg` |
+| `pbs_access_control` | PBS Access Control | **Staged** access control + secrets restored 1:1 (root@pam safety rail) | `./etc/proxmox-backup/user.cfg`
`./etc/proxmox-backup/domains.cfg`
`./etc/proxmox-backup/acl.cfg`
`./etc/proxmox-backup/token.cfg`
`./etc/proxmox-backup/shadow.json`
`./etc/proxmox-backup/token.shadow`
`./etc/proxmox-backup/tfa.json` |
+| `pbs_tape` | PBS Tape Backup | **Staged** tape config, jobs and encryption keys | `./etc/proxmox-backup/tape.cfg`
`./etc/proxmox-backup/tape-job.cfg`
`./etc/proxmox-backup/media-pool.cfg`
`./etc/proxmox-backup/tape-encryption-keys.json` |
+
+### Common Categories (11 categories)
| Category | Name | Description | Paths |
|----------|------|-------------|-------|
-| `network` | Network Configuration | Network interfaces and routing | `./etc/network/`
`./etc/hosts`
`./etc/hostname`
`./etc/resolv.conf`
`./etc/cloud/cloud.cfg.d/99-disable-network-config.cfg`
`./etc/dnsmasq.d/lxc-vmbr1.conf` |
-| `ssl` | SSL Certificates | SSL/TLS certificates and keys | `./etc/proxmox-backup/proxy.pem` |
+| `filesystem` | Filesystem Configuration | Mount points and filesystems (/etc/fstab) - WARNING: Critical for boot | `./etc/fstab` |
+| `storage_stack` | Storage Stack (Mounts/Targets) | Storage stack configuration used by mounts (iSCSI/LVM/MDADM/multipath/autofs/crypttab) | `./etc/crypttab`
`./etc/iscsi/`
`./var/lib/iscsi/`
`./etc/multipath/`
`./etc/multipath.conf`
`./etc/mdadm/`
`./etc/lvm/backup/`
`./etc/lvm/archive/`
`./etc/autofs.conf`
`./etc/auto.master`
`./etc/auto.master.d/`
`./etc/auto.*` |
+| `network` | Network Configuration | Network interfaces and routing | `./etc/network/`
`./etc/netplan/`
`./etc/systemd/network/`
`./etc/NetworkManager/system-connections/`
`./etc/hosts`
`./etc/hostname`
`./etc/resolv.conf`
`./etc/cloud/cloud.cfg.d/99-disable-network-config.cfg`
`./etc/dnsmasq.d/lxc-vmbr1.conf` |
+| `ssl` | SSL Certificates | SSL/TLS certificates and keys | `./etc/ssl/`
`./etc/proxmox-backup/proxy.pem`
`./etc/proxmox-backup/proxy.key`
`./etc/proxmox-backup/ssl/` |
| `ssh` | SSH Configuration | SSH keys and authorized_keys | `./root/.ssh/`
`./etc/ssh/` |
| `scripts` | Custom Scripts | User scripts and tools | `./usr/local/bin/`
`./usr/local/sbin/` |
| `crontabs` | Scheduled Tasks | Cron jobs and systemd timers | `./etc/cron.d/`
`./etc/crontab`
`./var/spool/cron/` |
-| `services` | System Services | Systemd service configs and udev rules | `./etc/systemd/system/`
`./etc/default/`
`./etc/udev/rules.d/` |
+| `services` | System Services | Systemd service configs and related system settings | `./etc/systemd/system/`
`./etc/default/`
`./etc/udev/rules.d/`
`./etc/apt/`
`./etc/logrotate.d/`
`./etc/timezone`
`./etc/sysctl.conf`
`./etc/sysctl.d/`
`./etc/modprobe.d/`
`./etc/modules`
`./etc/iptables/`
`./etc/nftables.conf`
`./etc/nftables.d/` |
+| `user_data` | User Data (Home Directories) | Root and user home directories (/root and /home) | `./root/`
`./home/` |
| `zfs` | ZFS Configuration | ZFS pool cache and configs | `./etc/zfs/`
`./etc/hostid` |
+| `proxsave_info` | ProxSave Diagnostics (Export Only) | **Export-only** ProxSave command outputs and inventory reports (never written to system) | `./var/lib/proxsave-info/`
`./manifest.json` |
### Category Availability
@@ -119,6 +142,42 @@ Not all categories are available in every backup. The restore workflow:
2. Detects which categories contain files
3. Displays only available categories for selection
+### PVE Access Control 1:1 (pve_access_control)
+
+On standalone PVE restores (non-cluster backups), ProxSave restores `pve_access_control` **1:1** by applying staged files directly to the pmxcfs mount (`/etc/pve`):
+- `/etc/pve/user.cfg` + `/etc/pve/domains.cfg`
+- `/etc/pve/priv/{shadow,token,tfa}.cfg`
+
+**Root safety rail**:
+- `root@pam` is preserved from the fresh install (not imported from the backup).
+- ProxSave forces `root@pam` to keep `Administrator` on `/` (propagate), to prevent lockout.
+
+**Cluster backups**:
+- In cluster SAFE mode (no `config.db` restore), ProxSave prompts you to either **skip** access control (recommended) or apply it **1:1 cluster-wide** (including secrets) with a rollback timer.
+
+**TFA**:
+- Restored 1:1. Default behavior is **warn** (do not disable users). Some methods (notably WebAuthn) may require re-enrollment if the hostname/FQDN/origin changes.
+- For maximum 1:1, keep the same URL origin (same FQDN/hostname and port), restore `network` + `ssl`, and avoid accessing the UI via raw IP.
+- In CUSTOM mode, if you select access control without `network`/`ssl`, ProxSave suggests adding them to maximize WebAuthn compatibility.
+
+---
+
+### PBS Access Control 1:1 (pbs_access_control)
+
+ProxSave restores `pbs_access_control` by applying staged files to `/etc/proxmox-backup`:
+- `/etc/proxmox-backup/{user,domains,token}.cfg` (when present)
+- `/etc/proxmox-backup/acl.cfg`
+- `/etc/proxmox-backup/{shadow.json,token.shadow,tfa.json}` (when present)
+
+**Root safety rail**:
+- `root@pam` is preserved from the fresh install (not imported from the backup).
+- ProxSave forces `root@pam` to keep `Admin` on `/`, to prevent lockout.
+
+**TFA**:
+- Restored 1:1. Default behavior is **warn** (do not disable users). Some methods (notably WebAuthn) may require re-enrollment if the hostname/FQDN/origin changes.
+- For maximum 1:1, keep the same URL origin (same FQDN/hostname and port), restore `network` + `ssl`, and avoid accessing the UI via raw IP.
+- In CUSTOM mode, if you select access control without `network`/`ssl`, ProxSave suggests adding them to maximize WebAuthn compatibility.
+
---
## Restore Modes
@@ -134,7 +193,10 @@ Four predefined modes provide common restoration scenarios, plus custom selectio
- Migrating to new hardware
- Restoring after system failure
-**Categories Included**: All available categories (export-only categories such as `pve_config_export` are extracted to the export directory for manual application)
+**Categories Included**: All available categories
+- **Normal** categories are restored to system paths
+- **Staged** categories are extracted under `/tmp/proxsave/restore-stage-*` and applied automatically (API/file apply)
+- **Export-only** categories (e.g. `pve_config_export`, `pbs_config`) are extracted to the export directory for manual review/application
**Command Flow**:
```
@@ -155,14 +217,19 @@ Select restore mode:
**PVE Categories**:
- `pve_cluster` - Cluster configuration
-- `storage_pve` - Storage definitions
+- `storage_pve` - Storage + datacenter + vzdump config (staged apply)
- `pve_jobs` - Backup jobs
+- `filesystem` - /etc/fstab
+- `storage_stack` - Storage stack config (mount prerequisites)
- `zfs` - ZFS configuration
**PBS Categories**:
-- `pbs_config` - PBS configuration
-- `datastore_pbs` - Datastore definitions
-- `pbs_jobs` - Sync/verify/prune jobs
+- `datastore_pbs` - Datastore definitions (staged apply)
+- `maintenance_pbs` - Maintenance settings
+- `pbs_jobs` - Sync/verify/prune jobs (staged apply)
+- `pbs_remotes` - Remotes for sync jobs (staged apply)
+- `filesystem` - /etc/fstab
+- `storage_stack` - Storage stack config (mount prerequisites)
- `zfs` - ZFS configuration
**Command Flow**:
@@ -188,6 +255,7 @@ Select restore mode:
- `ssl` - SSL/TLS certificates
- `ssh` - SSH daemon configuration (`/etc/ssh`) and SSH keys (root/home users)
- `services` - Systemd service configs and udev rules
+- `filesystem` - /etc/fstab (Smart merge prompt)
**Command Flow**:
```
@@ -316,11 +384,13 @@ Phase 12: Export-Only Extraction
├─ Destination: /proxmox-config-export-YYYYMMDD-HHMMSS/
└─ Separate detailed log
-Phase 13: pvesh SAFE Apply (Cluster SAFE Mode Only)
- ├─ Scan exported VM/CT configs
- ├─ Offer to apply via pvesh API
- ├─ Offer to apply storage.cfg via pvesh
- └─ Offer to apply datacenter.cfg via pvesh
+Phase 13: SAFE Apply (Cluster SAFE Mode Only)
+ ├─ Scan exported datacenter objects (mappings, pools, VM/CT configs)
+ ├─ Offer to apply resource mappings via pvesh (`/cluster/mapping/*`)
+ ├─ Offer to apply pools via pveum (`pveum pool add/modify`)
+ ├─ Offer to apply VM/CT configs via pvesh API
+ ├─ Offer to apply storage/datacenter via pvesh (only if `storage_pve` is NOT selected)
+ └─ Otherwise handled by `storage_pve` staged restore
Phase 14: Post-Restore Tasks
├─ Optional: Apply restored network config with rollback timer (requires COMMIT)
@@ -424,24 +494,43 @@ See [Cluster Restore Modes](#cluster-restore-modes-safe-vs-recovery) for detaile
RESTORE PLAN
═══════════════════════════════════════════════════════════════
-Restore mode: STORAGE only (4 categories)
+Restore mode: STORAGE only (cluster + storage + jobs + mounts)
System type: Proxmox Virtual Environment (PVE)
Categories to restore:
1. PVE Cluster Configuration
Proxmox VE cluster configuration and database
- 2. PVE Storage Configuration
- Storage definitions and backup jobs
+ 2. PVE Storage Configuration
+ Storage definitions (applied via API) and VZDump configuration
3. PVE Backup Jobs
- Scheduled backup jobs
- 4. ZFS Configuration
- ZFS pool cache and configs
-
-Files/directories that will be restored:
- • /var/lib/pve-cluster/
- • /etc/vzdump.conf
- • /etc/pve/jobs.cfg
- • /etc/pve/vzdump.cron
+ Scheduled backup job definitions
+ 4. Filesystem Configuration
+ Mount points and filesystems (/etc/fstab) - WARNING: Critical for boot
+ 5. Storage Stack (Mounts/Targets)
+ Storage stack configuration used by mounts (iSCSI/LVM/MDADM/multipath/autofs/crypttab)
+ 6. ZFS Configuration
+ ZFS pool cache and configuration files
+
+ Files/directories that will be restored:
+ • /var/lib/pve-cluster/
+ • /etc/pve/storage.cfg
+ • /etc/pve/datacenter.cfg
+ • /etc/vzdump.conf
+ • /etc/pve/jobs.cfg
+ • /etc/pve/vzdump.cron
+ • /etc/fstab
+ • /etc/crypttab
+ • /etc/iscsi/
+ • /var/lib/iscsi/
+ • /etc/multipath/
+ • /etc/multipath.conf
+ • /etc/mdadm/
+ • /etc/lvm/backup/
+ • /etc/lvm/archive/
+ • /etc/autofs.conf
+ • /etc/auto.master
+ • /etc/auto.master.d/
+ • /etc/auto.*
• /etc/zfs/
• /etc/hostid
@@ -1069,9 +1158,15 @@ Hard guard in code prevents ANY write to /etc/pve when restoring to /
(see internal/orchestrator/restore.go:880-884)
```
+### Export-Only Category: pbs_config
+
+**Category**: `pbs_config`
+**Path**: `./etc/proxmox-backup/`
+**Reason**: The full PBS configuration directory contains high-risk and secret-bearing files; ProxSave restores specific subsets via staged categories (e.g. `pbs_access_control`, `pbs_notifications`, `pbs_remotes`) and leaves the full directory as export-only for manual review.
+
### Export Process
-**Two-Pass Extraction**:
+**Extraction Passes**:
```
Pass 1: Normal Categories
├─ Destination: / (system root)
@@ -1081,9 +1176,14 @@ Pass 1: Normal Categories
Pass 2: Export-Only Categories
├─ Destination: /proxmox-config-export-YYYYMMDD-HHMMSS/
- ├─ Categories: Only export-only (pve_config_export)
+ ├─ Categories: Export-only (e.g. pve_config_export, pbs_config)
├─ Safety backup: Not created (not overwriting system)
└─ Log: Separate section in same log file
+
+Pass 3: Staged Categories
+ ├─ Destination: /tmp/proxsave/restore-stage-*
+ ├─ Categories: Sensitive staged apply (e.g. network, notifications, access control)
+ └─ Apply: Written after extraction via safe file/API apply steps
```
**Export Directory Structure**:
@@ -1163,8 +1263,9 @@ Pass 2: Export-Only Categories
### Export-Only in Custom Mode
**Visibility**:
-- Export-only categories **NOT shown** in Full/Storage/Base modes
-- **Only available** in Custom selection mode
+- In **FULL restore**, export-only categories are included automatically (but extracted to a separate export directory)
+- In **STORAGE** and **SYSTEM BASE** modes, export-only categories are not included
+- In **CUSTOM selection**, you can explicitly select export-only categories
**Selection**:
```
@@ -1204,11 +1305,24 @@ Result:
### pvesh SAFE Apply (Cluster SAFE Mode)
-When using **SAFE cluster restore mode**, the workflow offers to apply exported configurations via the Proxmox VE API (`pvesh`). This allows you to restore individual configurations without replacing the entire cluster database.
+When using **SAFE cluster restore mode**, the workflow offers to apply exported configurations and datacenter-wide objects via the Proxmox VE tooling (`pvesh` / `pveum`). This allows you to restore individual configurations without replacing the entire cluster database.
**Available Actions**:
-#### 1. VM/CT Configuration Apply
+#### 1. Resource Mappings Apply (PCI/USB/Dir)
+
+If your VM/CT configs use `mapping=` (notably for PCI/USB passthrough), ProxSave can apply the cluster resource mappings first:
+- Applied via `pvesh` to `/cluster/mapping/{pci,usb,dir}` (when present in the backup)
+- Recommended to run **before** VM/CT apply to avoid missing-mapping errors
+
+#### 2. Resource Pools Apply (Pools)
+
+ProxSave can restore pool definitions and membership (merge semantics):
+- Pools are parsed from the exported `user.cfg`
+- Applied via `pveum pool add` / `pveum pool modify`
+- Membership (VMIDs + storages) is applied later in the SAFE apply flow (after VM/CT configs and storage)
+
+#### 3. VM/CT Configuration Apply
```
Found 5 VM/CT configs for node pve01
@@ -1222,7 +1336,7 @@ For each VM/CT config found in the export:
**Note**: This creates or updates VM configurations in the cluster. Disk images are NOT affected.
-#### 2. Storage Configuration Apply
+#### 4. Storage Configuration Apply
```
Storage configuration found: /opt/proxsave/proxmox-config-export-*/etc/pve/storage.cfg
@@ -1236,7 +1350,7 @@ Parses `storage.cfg` and applies each storage definition:
**Note**: Storage directories are NOT created automatically. Run `pvesm status` to verify, then create missing directories manually.
-#### 3. Datacenter Configuration Apply
+#### 5. Datacenter Configuration Apply
```
Datacenter configuration found: /opt/proxsave/proxmox-config-export-*/etc/pve/datacenter.cfg
@@ -1251,6 +1365,12 @@ Applies datacenter-wide settings:
```
SAFE cluster restore: applying configs via pvesh (node=pve01)
+Apply PVE resource mappings (pvesh)? (y/N): y
+Applied pci mapping device1
+
+Apply PVE resource pools (merge)? (y/N): y
+Applied pool definition dev
+
Found 3 VM/CT configs for node pve01
Apply all VM/CT configs via pvesh? (y/N): y
Applied VM/CT config 100 (webserver)
@@ -1267,6 +1387,8 @@ Storage apply completed: ok=2 failed=0
Datacenter configuration found: .../etc/pve/datacenter.cfg
Apply datacenter.cfg via pvesh? (y/N): n
Skipping datacenter.cfg apply
+
+Pools apply (membership) completed: ok=1 failed=0
```
**Benefits of pvesh Apply**:
@@ -1647,7 +1769,7 @@ Type "RESTORE" (exact case) to proceed, or "cancel"/"0" to abort: _
- If the user does not answer before the countdown reaches 0, ProxSave proceeds with a **safe default** (no destructive action) and logs the decision.
Current auto-skip prompts:
-- **Smart `/etc/fstab` merge**: defaults to **Skip** (no changes).
+- **Smart `/etc/fstab` merge**: defaults to **Skip** unless the backup root (and swap, when comparable) match the current system; when they match, the default is **Yes**.
- **Live network apply** (“Apply restored network configuration now…”): defaults to **No** (stays staged/on-disk only; no live reload).
### 3. Compatibility Validation
@@ -1793,10 +1915,12 @@ If the restore includes filesystem configuration (notably `/etc/fstab`), ProxSav
- Compares the current `/etc/fstab` with the backup copy.
- Keeps existing critical entries (for example, root and swap) when they already match the running system.
- Detects **safe mount candidates** from the backup (for example, additional NFS mounts) and offers to add them.
+- If ProxSave inventory data is present in the backup, ProxSave can remap **unstable** `/dev/*` devices from the backup (for example `/dev/sdb1`) to stable `UUID=`/`PARTUUID=`/`LABEL=` references **on the restore host** (only when the stable reference exists on the system).
+- Normalizes restored entries by adding `nofail` (and `_netdev` for network mounts) so offline storage does not block boot/restore.
**Safety behavior**:
- The user is prompted before any change is written.
-- The prompt includes a **90-second** countdown; if you do not answer in time, ProxSave defaults to **Skip** (no changes).
+- The prompt includes a **90-second** countdown; if you do not answer in time, ProxSave uses a safe default (**Skip** unless root/swap match, in which case the default is **Yes**).
### 6. Hard Guards
@@ -1815,6 +1939,18 @@ if cleanDestRoot == "/" && strings.HasPrefix(target, "/etc/pve") {
```
- Absolute prevention of `/etc/pve` corruption
+**PBS Datastore Mount Guards**:
+- When restoring PBS datastore definitions, ProxSave can apply a temporary mount guard (read-only bind mount; fallback `chattr +i`) on mount roots that currently resolve to the root filesystem.
+- Purpose: prevent accidental writes to `/` if a datastore mountpoint is missing/offline at restore time (PBS will show the datastore as unavailable until storage is mounted).
+- Optional cleanup: `./build/proxsave --cleanup-guards` (use `--dry-run` to preview).
+
+**PVE Storage Mount Guards**:
+- When restoring PVE storage definitions (from `storage.cfg`), ProxSave applies the same “restore even if offline” strategy for mount-backed storage:
+ - Network storages (`nfs`, `cifs`, `cephfs`, `glusterfs`) use mountpoints under `/mnt/pve/`. ProxSave attempts `pvesm activate `; if the mountpoint still resolves to the root filesystem, it applies a temporary mount guard (read-only bind mount; fallback `chattr +i`).
+ - `dir` storages are guarded only when their `path` lives under a mountpoint restored via `/etc/fstab` (to avoid guarding local root filesystem paths).
+- Purpose: prevent PVE from writing into `/mnt/pve/...` (or other mount roots) when the backing storage is offline at restore time.
+- Optional cleanup: `./build/proxsave --cleanup-guards` (use `--dry-run` to preview).
+
### 7. Service Management Fail-Fast
**Service Stop**: If ANY service fails to stop → ABORT entire restore
@@ -2163,20 +2299,22 @@ zpool import
# If directory-based datastore (non-ZFS), verify permissions for backup user
# NOTE:
-# - On live restores, ProxSave stages PBS datastore/job configuration first under `/tmp/proxsave/restore-stage-*`
-# and applies it safely after checking the current system state.
-# - If a datastore path looks like a mountpoint location (e.g. under `/mnt`) but resolves to the root filesystem,
-# ProxSave will **defer** that datastore definition (it will NOT be written to `datastore.cfg`), to avoid ending up
-# with a broken datastore entry that blocks re-creation on a new/empty disk. Deferred entries are saved under
-# `/tmp/proxsave/datastore.cfg.deferred.*` for manual review.
-# - ProxSave may create missing datastore directories and fix `.lock`/ownership, but it will NOT format disks.
-# - To avoid accidental writes to the wrong disk, ProxSave will skip datastore directory initialization if the
-# datastore path looks like a mountpoint location (e.g. under /mnt) but resolves to the root filesystem.
-# In that case, mount/import the datastore disk/pool first, then restart PBS (or re-run restore).
-# - If the datastore path is not empty and contains unexpected files/directories, ProxSave will not touch it.
+# - ProxSave runs filesystem mount restore (Smart `/etc/fstab` merge) before applying PBS datastore configuration.
+# - Datastore definitions are applied even if the underlying storage is offline/not mounted (PBS will show them as unavailable),
+# so you do not lose datastore entries after a restore.
+# - If a datastore path looks like a mount-root location (e.g. under `/mnt`) but currently resolves to the root filesystem,
+# ProxSave applies a temporary **mount guard** (read-only bind mount; fallback `chattr +i`) on the mount root to prevent writes to `/`
+# until the storage becomes available. When the real storage is mounted later, it overlays the guard and the datastore becomes available.
+# - If the datastore path is not empty and contains unexpected files/directories (not a PBS datastore), ProxSave will defer that datastore block
+# and save it under `/tmp/proxsave/datastore.cfg.deferred.*` for manual review.
+# - ProxSave does not format disks or import ZFS pools: mount/import the underlying storage first, then restart PBS.
ls -ld /mnt/datastore /mnt/datastore/ 2>/dev/null
namei -l /mnt/datastore/ 2>/dev/null || true
+# If you need to remove ProxSave mount guards (optional / troubleshooting, run as root):
+./build/proxsave --cleanup-guards --dry-run
+./build/proxsave --cleanup-guards
+
# Common fix (adjust to your datastore path)
chown backup:backup /mnt/datastore && chmod 750 /mnt/datastore
chown -R backup:backup /mnt/datastore/ && chmod 750 /mnt/datastore/
@@ -2186,14 +2324,14 @@ chown -R backup:backup /mnt/datastore/ && chmod 750 /mnt/datastor
**Issue: "Bad Request (400) unable to read /etc/resolv.conf (No such file or directory)"**
-**Cause**: `/etc/resolv.conf` is missing or a broken symlink. This can happen after a restore if a previous backup contained an invalid symlink (e.g. pointing to `../commands/resolv_conf.txt`), or if the target system uses `systemd-resolved` and the expected `/run/systemd/resolve/*` files are not present.
+**Cause**: `/etc/resolv.conf` is missing or a broken symlink. This can happen after a restore if a previous backup contained an invalid symlink (e.g. pointing to `../var/lib/proxsave-info/commands/system/resolv_conf.txt` or legacy `../commands/resolv_conf.txt`), or if the target system uses `systemd-resolved` and the expected `/run/systemd/resolve/*` files are not present.
**Solution**:
```bash
ls -la /etc/resolv.conf
readlink /etc/resolv.conf 2>/dev/null || true
-# If the link is broken or points to commands/resolv_conf.txt, replace it:
+# If the link is broken or points to a proxsave diagnostics file, replace it:
rm -f /etc/resolv.conf
if [ -e /run/systemd/resolve/resolv.conf ]; then
@@ -2568,10 +2706,10 @@ A: Yes, in two ways:
`CLOUD_REMOTE` / `CLOUD_REMOTE_PATH` combination and show an entry:
- `Cloud backups (rclone)`
- When selected, the tool:
- - lists `.bundle.tar` bundles on the remote with `rclone lsf`;
- - reads metadata/manifest via `rclone cat` (without downloading everything);
+ - lists backup candidates on the remote with `rclone lsf` (`.bundle.tar` bundles and legacy `.metadata`+archive pairs);
+ - reads the manifest/metadata via `rclone cat` (without downloading full archives; for bundles the manifest is at the beginning, so this is typically fast);
- when you pick a backup, downloads it to `/tmp/proxsave` and proceeds with decrypt/restore.
- - If scanning times out (slow remote / huge directory), increase `RCLONE_TIMEOUT_CONNECTION` and retry.
+ - Cloud scan applies `RCLONE_TIMEOUT_CONNECTION` per rclone command (the timer resets on each list/inspect step). If scanning times out (slow remote / huge directory), increase `RCLONE_TIMEOUT_CONNECTION` and retry. Also ensure the selected remote path points directly to the directory that contains the backups (scan is non-recursive).
2. **From a local rclone mount (restore-only)**
If you prefer to mount the rclone backend as a local filesystem:
diff --git a/docs/RESTORE_TECHNICAL.md b/docs/RESTORE_TECHNICAL.md
index c9392cb..c7da91b 100644
--- a/docs/RESTORE_TECHNICAL.md
+++ b/docs/RESTORE_TECHNICAL.md
@@ -1166,7 +1166,7 @@ func redirectClusterCategoryToExport(normal []Category, export []Category) ([]Ca
### pvesh SAFE Apply
-After extraction in SAFE mode, `runSafeClusterApply()` offers API-based restoration:
+After extraction in SAFE mode, `runSafeClusterApply()` offers API-based restoration (primarily VM/CT configs). When the user selects the `storage_pve` category, storage.cfg + datacenter.cfg are applied later via the staged restore pipeline and SAFE apply will skip prompting for them.
**Key Functions**:
- `scanVMConfigs()`: Scans `/etc/pve/nodes//qemu-server/` and `lxc/`
@@ -1469,7 +1469,50 @@ if cleanDestRoot == string(os.PathSeparator) &&
**Does NOT apply**:
- Export-only extraction (different `destRoot`)
-### 3. Root Privilege Check
+### 3. Smart `/etc/fstab` Merge + Device Remap
+
+When restoring to the real system root (`/`), ProxSave avoids blindly overwriting `/etc/fstab`. Instead, it can run a **Smart Merge** workflow:
+
+- Extracts the backup copy of `/etc/fstab` into a temporary directory.
+- Compares it against the current system `/etc/fstab`.
+- Proposes only **safe candidates**:
+ - Network mounts (NFS/CIFS style entries)
+ - Data mounts that use stable references (`UUID=`/`LABEL=`/`PARTUUID=` or `/dev/disk/by-*`) that exist on the restore host
+
+**Device remap** (newer backups):
+- If the backup contains ProxSave inventory (`var/lib/proxsave-info/commands/system/{blkid.txt,lsblk_json.json,lsblk.txt}` or PBS datastore inventory),
+ ProxSave can remap unstable device paths from the backup (e.g. `/dev/sdb1`) to stable references (`UUID=`/`PARTUUID=`/`LABEL=`) **when the stable reference exists on the restore host**.
+- This reduces the risk of mounting the wrong disk after a reinstall where `/dev/sdX` ordering changes.
+
+**Normalization**:
+- Entries written by the merge are normalized to include `nofail` (and `_netdev` for network mounts) to prevent offline storage from blocking boot/restore.
+
+### 4. PBS Datastore Mount Guards (Offline Storage)
+
+For PBS datastores whose paths live under typical mount roots (for example `/mnt/...`), ProxSave aims for a “restore even if offline” behavior:
+
+- PBS datastore definitions are applied even when the underlying storage is offline/not mounted, so PBS shows them as **unavailable** rather than silently dropping them.
+- When a mountpoint used by a datastore currently resolves to the root filesystem (mount missing), ProxSave applies a **temporary mount guard** on the mount root:
+ - Preferred: read-only bind-mount guard
+ - Fallback: `chattr +i` on the mountpoint directory
+- Guards prevent PBS from writing into `/` if the storage is missing at restore time. When the real storage is mounted later, it overlays the guard and the datastore becomes available again.
+
+Optional maintenance:
+- `proxsave --cleanup-guards` removes guard bind mounts and the guard directory when they are still visible on mountpoints.
+
+#### PVE Storage Mount Guards (Offline Storage)
+
+For PVE storages that use mountpoints (notably `nfs`, `cifs`, `cephfs`, `glusterfs`, and `dir` storages on dedicated mountpoints), ProxSave applies the same “restore even if offline” safety model:
+
+- Network storages use `/mnt/pve/`. ProxSave attempts `pvesm activate ` with a short timeout.
+- If the mountpoint still resolves to the root filesystem afterwards (mount missing/offline), ProxSave applies a **temporary mount guard** on the mountpoint:
+ - Preferred: read-only bind-mount guard
+ - Fallback: `chattr +i` on the mountpoint directory
+- For `dir` storages, guards are only applied when the storage `path` can be associated with a mountpoint present in `/etc/fstab` (to avoid guarding local root filesystem paths).
+
+This prevents accidental writes into the root filesystem when storage is offline at restore time. When the real mount comes back, it overlays the guard and normal operation resumes.
+
+### 5. Root Privilege Check
**Pre-Extraction Check** (`restore.go:568-570`, `588-590`):
@@ -1480,7 +1523,7 @@ if destRoot == "/" && os.Geteuid() != 0 {
}
```
-### 4. Checksum Verification
+### 6. Checksum Verification
**After Decryption** (`decrypt.go:272-289`):
@@ -1506,7 +1549,7 @@ if checksumFile exists {
}
```
-### 5. Interactive Confirmation Gates
+### 7. Interactive Confirmation Gates
**Multiple abort points**:
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 67905f2..373ccd7 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -279,7 +279,7 @@ iptables -L -n | grep -i drop
#### Restore/Decrypt: stuck on “Scanning backup path…” or timeout (cloud/rclone)
-**Cause**: The tool scans cloud backups by listing the remote (`rclone lsf`) and reading each backup manifest (`rclone cat`). On slow remotes or very large directories this can time out.
+**Cause**: ProxSave scans cloud backups by listing the remote (`rclone lsf`) and inspecting each candidate by reading the manifest/metadata (`rclone cat`). Each rclone call is protected by `RCLONE_TIMEOUT_CONNECTION` (the timer resets per command). On slow remotes or very large directories this can time out.
**Solution**:
```bash
@@ -287,8 +287,12 @@ iptables -L -n | grep -i drop
nano configs/backup.env
RCLONE_TIMEOUT_CONNECTION=120
-# Re-run restore with debug logs (restore log path is printed on start)
+# Ensure you selected the remote directory that contains the backups (scan is non-recursive),
+# then re-run restore with debug logs (restore log path is printed on start)
./build/proxsave --restore --log-level debug
+
+# Or use support mode to capture full diagnostics
+./build/proxsave --restore --support
```
If it still fails, run the equivalent manual checks:
diff --git a/go.mod b/go.mod
index c69945e..1886f12 100644
--- a/go.mod
+++ b/go.mod
@@ -2,11 +2,11 @@ module github.com/tis24dev/proxsave
go 1.25
-toolchain go1.25.5
+toolchain go1.25.6
require (
filippo.io/age v1.3.1
- github.com/gdamore/tcell/v2 v2.13.7
+ github.com/gdamore/tcell/v2 v2.13.8
github.com/rivo/tview v0.42.0
golang.org/x/crypto v0.47.0
golang.org/x/term v0.39.0
diff --git a/go.sum b/go.sum
index c36b931..ec71ed8 100644
--- a/go.sum
+++ b/go.sum
@@ -8,8 +8,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
-github.com/gdamore/tcell/v2 v2.13.7 h1:yfHdeC7ODIYCc6dgRos8L1VujQtXHmUpU6UZotzD6os=
-github.com/gdamore/tcell/v2 v2.13.7/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
+github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
+github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
diff --git a/install.sh b/install.sh
index e6a4a6b..a293d3d 100644
--- a/install.sh
+++ b/install.sh
@@ -78,14 +78,41 @@ echo " Dir: ${TARGET_DIR}"
echo "--------------------------------------------"
###############################################
-# 4) Fetch latest release tag
+# 4) HTTP helper (curl/wget)
###############################################
-LATEST_TAG="$(
- curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
- | grep '"tag_name"' \
- | head -n1 \
- | cut -d '"' -f4
-)"
+fetch() {
+ local url="$1"
+
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "${url}"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -q -O - "${url}"
+ else
+ echo "❌ Neither curl nor wget is installed" >&2
+ exit 1
+ fi
+}
+
+download() {
+ local url="$1"
+ local out="$2"
+
+ fetch "${url}" > "${out}"
+}
+
+###############################################
+# 5) Fetch latest release tag
+###############################################
+LATEST_JSON="$(fetch "https://api.github.com/repos/${REPO}/releases/latest")"
+
+LATEST_TAG=""
+if command -v jq >/dev/null 2>&1; then
+ LATEST_TAG="$(jq -r '.tag_name // empty' <<<"${LATEST_JSON}" 2>/dev/null || true)"
+fi
+
+if [ -z "${LATEST_TAG}" ] && [[ ${LATEST_JSON} =~ \"tag_name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
+ LATEST_TAG="${BASH_REMATCH[1]}"
+fi
if [ -z "${LATEST_TAG}" ]; then
echo "❌ Could not detect latest release tag"
@@ -97,7 +124,7 @@ echo "📦 Latest tag: ${LATEST_TAG}"
VERSION="${LATEST_TAG#v}"
###############################################
-# 5) Build correct filename (ARCHIVE)
+# 6) Build correct filename (ARCHIVE)
###############################################
FILENAME="proxsave_${VERSION}_${OS}_${ARCH}.tar.gz"
@@ -108,7 +135,7 @@ echo "➡ Archive URL: ${BINARY_URL}"
echo "➡ Checksum URL: ${CHECKSUM_URL}"
###############################################
-# 6) Prepare directories
+# 7) Prepare directories
###############################################
mkdir -p "${BUILD_DIR}"
@@ -116,22 +143,8 @@ TMP_DIR="$(mktemp -d)"
cd "${TMP_DIR}"
###############################################
-# 7) Download helper
+# 8) Download archive and checksum
###############################################
-download() {
- local url="$1"
- local out="$2"
-
- if command -v curl >/dev/null 2>&1; then
- curl -fsSL -o "${out}" "${url}"
- elif command -v wget >/dev/null 2>&1; then
- wget -q -O "${out}" "${url}"
- else
- echo "❌ Neither curl nor wget is installed"
- exit 1
- fi
-}
-
echo "[+] Downloading archive..."
download "${BINARY_URL}" "${FILENAME}"
@@ -139,7 +152,7 @@ echo "[+] Downloading SHA256SUMS..."
download "${CHECKSUM_URL}" "SHA256SUMS"
###############################################
-# 8) Verify checksum
+# 9) Verify checksum
###############################################
echo "[+] Verifying checksum..."
grep " ${FILENAME}\$" SHA256SUMS > CHECK || {
@@ -151,7 +164,7 @@ sha256sum -c CHECK
echo "✔ Checksum OK"
###############################################
-# 9) Extract ONLY the binary
+# 10) Extract ONLY the binary
###############################################
echo "[+] Extracting binary from tar.gz..."
tar -xzf "${FILENAME}" proxsave
@@ -162,14 +175,14 @@ if [ ! -f proxsave ]; then
fi
###############################################
-# 10) Install binary
+# 11) Install binary
###############################################
echo "[+] Installing binary -> ${TARGET_BIN}"
mv proxsave "${TARGET_BIN}"
chmod +x "${TARGET_BIN}"
###############################################
-# 11) Run internal installer (--install or --new-install)
+# 12) Run internal installer (--install or --new-install)
###############################################
cd "${TARGET_DIR}"
@@ -182,7 +195,7 @@ echo "[+] Running: ${TARGET_BIN} ${BINARY_ARGS[*]}"
"${TARGET_BIN}" "${BINARY_ARGS[@]}"
###############################################
-# 12) Cleanup
+# 13) Cleanup
###############################################
rm -rf "${TMP_DIR}"
diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go
index a6fd8f9..1369edb 100644
--- a/internal/backup/archiver.go
+++ b/internal/backup/archiver.go
@@ -55,6 +55,7 @@ type Archiver struct {
requestedCompression types.CompressionType
encryptArchive bool
ageRecipients []age.Recipient
+ excludePatterns []string
deps ArchiverDeps
}
@@ -67,6 +68,7 @@ type ArchiverConfig struct {
DryRun bool
EncryptArchive bool
AgeRecipients []age.Recipient
+ ExcludePatterns []string
}
// CompressionError rappresenta un errore di compressione esterna (xz/zstd)
@@ -149,6 +151,7 @@ func NewArchiver(logger *logging.Logger, config *ArchiverConfig) *Archiver {
requestedCompression: config.Compression,
encryptArchive: config.EncryptArchive,
ageRecipients: append([]age.Recipient(nil), config.AgeRecipients...),
+ excludePatterns: append([]string(nil), config.ExcludePatterns...),
deps: defaultArchiverDeps(),
}
}
@@ -792,6 +795,16 @@ func (a *Archiver) addToTar(ctx context.Context, tarWriter *tar.Writer, sourceDi
return nil
}
+ if len(a.excludePatterns) > 0 {
+ if excluded, pattern := FindExcludeMatch(a.excludePatterns, path, sourceDir, ""); excluded {
+ a.logger.Debug("Excluding from archive: %s (matches pattern %s)", relPath, pattern)
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ }
+
// Use Lstat to get symlink info without following it
linkInfo, err := os.Lstat(path)
if err != nil {
diff --git a/internal/backup/archiver_test.go b/internal/backup/archiver_test.go
index 39a128e..6a29842 100644
--- a/internal/backup/archiver_test.go
+++ b/internal/backup/archiver_test.go
@@ -106,6 +106,65 @@ func TestCreateTarArchive(t *testing.T) {
}
}
+func TestCreateTarArchiveRespectsExcludePatterns(t *testing.T) {
+ logger := logging.New(types.LogLevelError, false)
+ config := &ArchiverConfig{
+ Compression: types.CompressionNone,
+ ExcludePatterns: []string{"/skip.txt", "dir/**"},
+ }
+ archiver := NewArchiver(logger, config)
+
+ tempDir := t.TempDir()
+ sourceDir := filepath.Join(tempDir, "source")
+ if err := os.MkdirAll(filepath.Join(sourceDir, "dir"), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "keep.txt"), []byte("keep"), 0o644); err != nil {
+ t.Fatalf("write keep: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "skip.txt"), []byte("skip"), 0o644); err != nil {
+ t.Fatalf("write skip: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "dir", "inner.txt"), []byte("inner"), 0o644); err != nil {
+ t.Fatalf("write inner: %v", err)
+ }
+
+ outputPath := filepath.Join(tempDir, "test.tar")
+ if err := archiver.CreateArchive(context.Background(), sourceDir, outputPath); err != nil {
+ t.Fatalf("CreateArchive failed: %v", err)
+ }
+
+ f, err := os.Open(outputPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer f.Close()
+
+ found := map[string]bool{}
+ tr := tar.NewReader(f)
+ for {
+ hdr, err := tr.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ t.Fatalf("read tar: %v", err)
+ }
+ name := strings.TrimPrefix(hdr.Name, "./")
+ found[name] = true
+ }
+
+ if !found["keep.txt"] {
+ t.Fatalf("expected keep.txt in archive")
+ }
+ if found["skip.txt"] {
+ t.Fatalf("expected skip.txt to be excluded from archive")
+ }
+ if found["dir/inner.txt"] {
+ t.Fatalf("expected dir/inner.txt to be excluded from archive")
+ }
+}
+
func TestCreateGzipArchive(t *testing.T) {
logger := logging.New(types.LogLevelInfo, false)
config := &ArchiverConfig{
diff --git a/internal/backup/collector.go b/internal/backup/collector.go
index 1f62ed5..ea6a4c3 100644
--- a/internal/backup/collector.go
+++ b/internal/backup/collector.go
@@ -27,6 +27,8 @@ import (
type CollectionStats struct {
FilesProcessed int64
FilesFailed int64
+ FilesNotFound int64
+ FilesSkipped int64
DirsCreated int64
BytesCollected int64
}
@@ -54,6 +56,11 @@ type Collector struct {
// clusteredPVE records whether cluster mode was detected during PVE collection.
clusteredPVE bool
+
+ // Manifest tracking for backup contents
+ pbsManifest map[string]ManifestEntry
+ pveManifest map[string]ManifestEntry
+ systemManifest map[string]ManifestEntry
}
var osSymlink = os.Symlink
@@ -71,6 +78,14 @@ func (c *Collector) incFilesFailed() {
atomic.AddInt64(&c.stats.FilesFailed, 1)
}
+func (c *Collector) incFilesNotFound() {
+ atomic.AddInt64(&c.stats.FilesNotFound, 1)
+}
+
+func (c *Collector) incFilesSkipped() {
+ atomic.AddInt64(&c.stats.FilesSkipped, 1)
+}
+
func (c *Collector) incDirsCreated() {
atomic.AddInt64(&c.stats.DirsCreated, 1)
}
@@ -408,25 +423,70 @@ func (c *Collector) CollectAll(ctx context.Context) error {
// Helper functions
-func (c *Collector) shouldExclude(path string) bool {
- if len(c.config.ExcludePatterns) == 0 {
- return false
+func FindExcludeMatch(patterns []string, path, tempDir, systemRootPrefix string) (bool, string) {
+ if len(patterns) == 0 {
+ return false, ""
}
- candidates := uniqueCandidates(path, c.tempDir)
+ candidates := uniqueCandidates(path, tempDir, systemRootPrefix)
+ if len(candidates) == 0 {
+ return false, ""
+ }
- for _, pattern := range c.config.ExcludePatterns {
+ for _, pattern := range patterns {
for _, candidate := range candidates {
if matchesGlob(pattern, candidate) {
- c.logger.Debug("Excluding %s (matches pattern %s)", path, pattern)
- return true
+ return true, pattern
}
}
}
- return false
+ return false, ""
}
-func uniqueCandidates(path, tempDir string) []string {
+func (c *Collector) shouldExclude(path string) bool {
+ if c == nil || c.config == nil {
+ return false
+ }
+ excluded, pattern := FindExcludeMatch(c.config.ExcludePatterns, path, c.tempDir, c.config.SystemRootPrefix)
+ if excluded {
+ c.logger.Debug("Excluding %s (matches pattern %s)", path, pattern)
+ }
+ return excluded
+}
+
+func (c *Collector) withTemporaryExcludes(extra []string, fn func() error) error {
+ if fn == nil {
+ return nil
+ }
+ if c == nil || c.config == nil || len(extra) == 0 {
+ return fn()
+ }
+
+ seen := make(map[string]struct{}, len(extra))
+ normalized := make([]string, 0, len(extra))
+ for _, pattern := range extra {
+ pattern = strings.TrimSpace(pattern)
+ if pattern == "" {
+ continue
+ }
+ if _, ok := seen[pattern]; ok {
+ continue
+ }
+ seen[pattern] = struct{}{}
+ normalized = append(normalized, pattern)
+ }
+ if len(normalized) == 0 {
+ return fn()
+ }
+
+ original := append([]string(nil), c.config.ExcludePatterns...)
+ c.config.ExcludePatterns = append(c.config.ExcludePatterns, normalized...)
+ defer func() { c.config.ExcludePatterns = original }()
+
+ return fn()
+}
+
+func uniqueCandidates(path, tempDir, systemRootPrefix string) []string {
base := filepath.Base(path)
candidates := []string{path}
if base != "" && base != "." && base != string(filepath.Separator) {
@@ -439,10 +499,23 @@ func uniqueCandidates(path, tempDir string) []string {
}
}
+ if systemRootPrefix != "" && systemRootPrefix != string(filepath.Separator) {
+ prefix := filepath.Clean(systemRootPrefix)
+ clean := filepath.Clean(path)
+ if clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator)) {
+ if relPrefix, err := filepath.Rel(prefix, clean); err == nil {
+ if relPrefix != "." && relPrefix != "" && relPrefix != ".." && !strings.HasPrefix(relPrefix, ".."+string(filepath.Separator)) {
+ candidates = append(candidates, filepath.Join(string(filepath.Separator), relPrefix))
+ }
+ }
+ }
+ }
+
if tempDir != "" {
if relTemp, err := filepath.Rel(tempDir, path); err == nil {
if relTemp != "." && relTemp != "" && relTemp != ".." {
candidates = append(candidates, relTemp)
+ candidates = append(candidates, filepath.Join(string(filepath.Separator), relTemp))
}
}
}
@@ -626,7 +699,8 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str
}
// Check if this file should be excluded
- if c.shouldExclude(src) {
+ if c.shouldExclude(src) || c.shouldExclude(dest) {
+ c.incFilesSkipped()
return nil
}
@@ -727,8 +801,9 @@ func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description stri
c.logger.Debug("Collecting directory %s: %s -> %s", description, src, dest)
- if c.shouldExclude(src) {
+ if c.shouldExclude(src) || c.shouldExclude(dest) {
c.logger.Debug("Skipping directory %s due to exclusion pattern", src)
+ c.incFilesSkipped()
return nil
}
@@ -757,8 +832,15 @@ func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description stri
return err
}
+ // Calculate relative path and destination path for archive matching.
+ relPath, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+ destPath := filepath.Join(dest, relPath)
+
// Check if this path should be excluded
- if c.shouldExclude(path) {
+ if c.shouldExclude(path) || c.shouldExclude(destPath) {
// If it's a directory, skip it entirely
if info.IsDir() {
return filepath.SkipDir
@@ -766,14 +848,6 @@ func (c *Collector) safeCopyDir(ctx context.Context, src, dest, description stri
return nil
}
- // Calculate relative path
- relPath, err := filepath.Rel(src, path)
- if err != nil {
- return err
- }
-
- destPath := filepath.Join(dest, relPath)
-
if info.IsDir() {
if err := c.ensureDir(destPath); err != nil {
return err
@@ -799,6 +873,12 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description
return err
}
+ if output != "" && c.shouldExclude(output) {
+ c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output)
+ c.incFilesSkipped()
+ return nil
+ }
+
c.logger.Debug("Collecting %s via command: %s > %s", description, cmd, output)
cmdParts := strings.Fields(cmd)
@@ -837,17 +917,11 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description
return nil // Non-critical failure
}
- if err := c.ensureDir(filepath.Dir(output)); err != nil {
+ if err := c.writeReportFile(output, out); err != nil {
return err
}
- if err := os.WriteFile(output, out, 0640); err != nil {
- c.incFilesFailed()
- return fmt.Errorf("failed to write output %s: %w", output, err)
- }
- c.incFilesProcessed()
c.logger.Debug("Successfully collected %s via command: %s", description, cmdString)
-
return nil
}
@@ -858,6 +932,12 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d
return err
}
+ if output != "" && c.shouldExclude(output) {
+ c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output)
+ c.incFilesSkipped()
+ return nil
+ }
+
cmdParts := strings.Fields(cmd)
if len(cmdParts) == 0 {
return fmt.Errorf("empty command")
@@ -914,16 +994,9 @@ func (c *Collector) safeCmdOutputWithPBSAuth(ctx context.Context, cmd, output, d
return nil // Non-critical failure
}
- if err := c.ensureDir(filepath.Dir(output)); err != nil {
+ if err := c.writeReportFile(output, out); err != nil {
return err
}
-
- if err := os.WriteFile(output, out, 0640); err != nil {
- c.incFilesFailed()
- return fmt.Errorf("failed to write output %s: %w", output, err)
- }
-
- c.incFilesProcessed()
c.logger.Debug("Successfully collected %s via PBS-authenticated command: %s", description, cmdString)
return nil
@@ -936,6 +1009,12 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm
return err
}
+ if output != "" && c.shouldExclude(output) {
+ c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output)
+ c.incFilesSkipped()
+ return nil
+ }
+
cmdParts := strings.Fields(cmd)
if len(cmdParts) == 0 {
return fmt.Errorf("empty command")
@@ -1012,15 +1091,9 @@ func (c *Collector) safeCmdOutputWithPBSAuthForDatastore(ctx context.Context, cm
return nil // Non-critical failure
}
- if err := c.ensureDir(filepath.Dir(output)); err != nil {
+ if err := c.writeReportFile(output, out); err != nil {
return err
}
- if err := os.WriteFile(output, out, 0640); err != nil {
- c.incFilesFailed()
- return fmt.Errorf("failed to write output %s: %w", output, err)
- }
-
- c.incFilesProcessed()
c.logger.Debug("Successfully collected %s via PBS-authenticated command for datastore %s: %s", description, datastoreName, cmdString)
return nil
@@ -1076,6 +1149,12 @@ func (c *Collector) IsClusteredPVE() bool {
}
func (c *Collector) writeReportFile(path string, data []byte) error {
+ if c.shouldExclude(path) {
+ c.logger.Debug("Skipping report file %s due to exclusion pattern", path)
+ c.incFilesSkipped()
+ return nil
+ }
+
if c.dryRun {
c.logger.Debug("[DRY RUN] Would write report file: %s (%d bytes)", path, len(data))
return nil
@@ -1102,6 +1181,12 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr
return nil, err
}
+ if output != "" && c.shouldExclude(output) {
+ c.logger.Debug("Skipping %s: output %s excluded by pattern", description, output)
+ c.incFilesSkipped()
+ return nil, nil
+ }
+
parts := strings.Fields(cmd)
if len(parts) == 0 {
return nil, fmt.Errorf("empty command")
diff --git a/internal/backup/collector_config_extra_test.go b/internal/backup/collector_config_extra_test.go
index f1a4158..ae15aa4 100644
--- a/internal/backup/collector_config_extra_test.go
+++ b/internal/backup/collector_config_extra_test.go
@@ -89,7 +89,7 @@ func TestCollectorConfigValidateRequiresAbsoluteSystemRootPrefix(t *testing.T) {
}
func TestUniqueCandidatesSkipsEmptyEntries(t *testing.T) {
- got := uniqueCandidates("", "")
+ got := uniqueCandidates("", "", "")
if len(got) != 0 {
t.Fatalf("expected no candidates for empty input, got %#v", got)
}
diff --git a/internal/backup/collector_manifest.go b/internal/backup/collector_manifest.go
new file mode 100644
index 0000000..072d886
--- /dev/null
+++ b/internal/backup/collector_manifest.go
@@ -0,0 +1,74 @@
+package backup
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "time"
+)
+
+// ManifestFileStatus represents the status of a file in the backup manifest
+type ManifestFileStatus string
+
+const (
+ StatusCollected ManifestFileStatus = "collected"
+ StatusNotFound ManifestFileStatus = "not_found"
+ StatusFailed ManifestFileStatus = "failed"
+ StatusSkipped ManifestFileStatus = "skipped"
+ StatusDisabled ManifestFileStatus = "disabled"
+)
+
+// ManifestEntry represents a single file entry in the manifest
+type ManifestEntry struct {
+ Status ManifestFileStatus `json:"status"`
+ Size int64 `json:"size,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// BackupManifest contains metadata about all files in the backup
+type BackupManifest struct {
+ CreatedAt time.Time `json:"created_at"`
+ Hostname string `json:"hostname"`
+ ProxmoxType string `json:"proxmox_type"`
+ PBSConfigs map[string]ManifestEntry `json:"pbs_configs,omitempty"`
+ PVEConfigs map[string]ManifestEntry `json:"pve_configs,omitempty"`
+ SystemFiles map[string]ManifestEntry `json:"system_files,omitempty"`
+ Stats ManifestStats `json:"stats"`
+}
+
+// ManifestStats contains summary statistics for the manifest
+type ManifestStats struct {
+ FilesProcessed int64 `json:"files_processed"`
+ FilesFailed int64 `json:"files_failed"`
+ FilesNotFound int64 `json:"files_not_found"`
+ FilesSkipped int64 `json:"files_skipped"`
+ DirsCreated int64 `json:"dirs_created"`
+ BytesCollected int64 `json:"bytes_collected"`
+}
+
+// WriteManifest writes the backup manifest to the temp directory
+func (c *Collector) WriteManifest(hostname string) error {
+ manifest := BackupManifest{
+ CreatedAt: time.Now().UTC(),
+ Hostname: hostname,
+ ProxmoxType: string(c.proxType),
+ PBSConfigs: c.pbsManifest,
+ PVEConfigs: c.pveManifest,
+ SystemFiles: c.systemManifest,
+ Stats: ManifestStats{
+ FilesProcessed: c.stats.FilesProcessed,
+ FilesFailed: c.stats.FilesFailed,
+ FilesNotFound: c.stats.FilesNotFound,
+ FilesSkipped: c.stats.FilesSkipped,
+ DirsCreated: c.stats.DirsCreated,
+ BytesCollected: c.stats.BytesCollected,
+ },
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return err
+ }
+
+ manifestPath := filepath.Join(c.tempDir, "manifest.json")
+ return c.writeReportFile(manifestPath, data)
+}
diff --git a/internal/backup/collector_paths.go b/internal/backup/collector_paths.go
new file mode 100644
index 0000000..7b39b64
--- /dev/null
+++ b/internal/backup/collector_paths.go
@@ -0,0 +1,23 @@
+package backup
+
+import "path/filepath"
+
+func (c *Collector) proxsaveInfoRoot() string {
+ return filepath.Join(c.tempDir, "var/lib/proxsave-info")
+}
+
+func (c *Collector) proxsaveInfoDir(parts ...string) string {
+ args := make([]string, 0, 1+len(parts))
+ args = append(args, c.proxsaveInfoRoot())
+ args = append(args, parts...)
+ return filepath.Join(args...)
+}
+
+func (c *Collector) proxsaveCommandsDir(component string) string {
+ return c.proxsaveInfoDir("commands", component)
+}
+
+func (c *Collector) proxsaveRuntimeDir(component string) string {
+ return c.proxsaveInfoDir("runtime", component)
+}
+
diff --git a/internal/backup/collector_pbs.go b/internal/backup/collector_pbs.go
index b85b957..c419a7b 100644
--- a/internal/backup/collector_pbs.go
+++ b/internal/backup/collector_pbs.go
@@ -3,26 +3,13 @@ package backup
import (
"context"
"encoding/json"
- "errors"
"fmt"
"os"
"path/filepath"
- "regexp"
"strings"
- "sync"
"time"
-
- "github.com/tis24dev/proxsave/internal/pbs"
)
-type pbsDatastore struct {
- Name string
- Path string
- Comment string
-}
-
-var listNamespacesFunc = pbs.ListNamespaces
-
func (c *Collector) pbsConfigPath() string {
if c.config != nil && c.config.PBSConfigPath != "" {
return c.systemPath(c.config.PBSConfigPath)
@@ -30,6 +17,54 @@ func (c *Collector) pbsConfigPath() string {
return c.systemPath("/etc/proxmox-backup")
}
+// collectPBSConfigFile collects a single PBS configuration file with detailed logging
+func (c *Collector) collectPBSConfigFile(ctx context.Context, root, filename, description string, enabled bool) ManifestEntry {
+ if !enabled {
+ c.logger.Debug("Skipping %s: disabled by configuration", filename)
+ c.logger.Info(" %s: disabled", description)
+ return ManifestEntry{Status: StatusDisabled}
+ }
+
+ srcPath := filepath.Join(root, filename)
+ destPath := filepath.Join(c.tempDir, "etc/proxmox-backup", filename)
+
+ if c.shouldExclude(srcPath) || c.shouldExclude(destPath) {
+ c.logger.Debug("Skipping %s: excluded by pattern", filename)
+ c.logger.Info(" %s: skipped (excluded)", description)
+ c.incFilesSkipped()
+ return ManifestEntry{Status: StatusSkipped}
+ }
+
+ c.logger.Debug("Checking %s: %s", filename, srcPath)
+
+ info, err := os.Stat(srcPath)
+ if os.IsNotExist(err) {
+ c.incFilesNotFound()
+ c.logger.Debug(" File not found: %v", err)
+ c.logger.Info(" %s: not configured", description)
+ return ManifestEntry{Status: StatusNotFound}
+ }
+ if err != nil {
+ c.incFilesFailed()
+ c.logger.Debug(" Stat error: %v", err)
+ c.logger.Warning(" %s: failed - %v", description, err)
+ return ManifestEntry{Status: StatusFailed, Error: err.Error()}
+ }
+
+ // Log file details in debug mode
+ c.logger.Debug(" File exists, size=%d, mode=%s, mtime=%s",
+ info.Size(), info.Mode(), info.ModTime().Format(time.RFC3339))
+ c.logger.Debug(" Copying to %s", destPath)
+
+ if err := c.safeCopyFile(ctx, srcPath, destPath, description); err != nil {
+ c.logger.Warning(" %s: failed - %v", description, err)
+ return ManifestEntry{Status: StatusFailed, Error: err.Error()}
+ }
+
+ c.logger.Info(" %s: collected (%s)", description, FormatBytes(info.Size()))
+ return ManifestEntry{Status: StatusCollected, Size: info.Size()}
+}
+
// CollectPBSConfigs collects Proxmox Backup Server specific configurations
func (c *Collector) CollectPBSConfigs(ctx context.Context) error {
c.logger.Info("Collecting PBS configurations")
@@ -79,6 +114,14 @@ func (c *Collector) CollectPBSConfigs(ctx context.Context) error {
}
c.logger.Debug("PBS command output collection completed")
+ // Collect datastore inventory (mounts, paths, config snapshots)
+ c.logger.Debug("Collecting PBS datastore inventory report")
+ if err := c.collectPBSDatastoreInventory(ctx, datastores); err != nil {
+ c.logger.Warning("Failed to collect PBS datastore inventory report: %v", err)
+ } else {
+ c.logger.Debug("PBS datastore inventory report completed")
+ }
+
// Collect datastore configurations
if c.config.BackupDatastoreConfigs {
c.logger.Debug("Collecting datastore configuration files and namespaces")
@@ -116,116 +159,145 @@ func (c *Collector) CollectPBSConfigs(ctx context.Context) error {
c.logger.Skip("PBS PXAR metadata collection disabled.")
}
+ // Print collection summary
+ c.logger.Info("PBS collection summary:")
+ c.logger.Info(" Files collected: %d", c.stats.FilesProcessed)
+ c.logger.Info(" Files not found: %d", c.stats.FilesNotFound)
+ if c.stats.FilesFailed > 0 {
+ c.logger.Warning(" Files failed: %d", c.stats.FilesFailed)
+ }
+ c.logger.Debug(" Files skipped: %d", c.stats.FilesSkipped)
+ c.logger.Debug(" Bytes collected: %d", c.stats.BytesCollected)
+
c.logger.Info("PBS configuration collection completed")
return nil
}
// collectPBSDirectories collects PBS-specific directories
func (c *Collector) collectPBSDirectories(ctx context.Context, root string) error {
- c.logger.Debug("Collecting PBS directories (%s, configs, schedules)", root)
- // PBS main configuration directory
- if err := c.safeCopyDir(ctx,
- root,
- filepath.Join(c.tempDir, "etc/proxmox-backup"),
- "PBS configuration"); err != nil {
- return err
- }
+ c.logger.Debug("Collecting PBS directories (source=%s, dest=%s)",
+ root, filepath.Join(c.tempDir, "etc/proxmox-backup"))
- // Datastore configuration
- if c.config.BackupDatastoreConfigs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "datastore.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/datastore.cfg"),
- "Datastore configuration"); err != nil {
- c.logger.Debug("No datastore.cfg found")
- }
+ // Even though we keep a full snapshot of /etc/proxmox-backup (or PBS_CONFIG_PATH),
+ // treat per-feature flags as exclusions so users can selectively omit sensitive files
+ // while still capturing unknown/new PBS config files.
+ //
+ // NOTE: These patterns are applied only for the duration of the directory snapshot to
+ // avoid impacting other collectors.
+ var extraExclude []string
+ if !c.config.BackupDatastoreConfigs {
+ extraExclude = append(extraExclude, "datastore.cfg")
}
-
- // User configuration
- if c.config.BackupUserConfigs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "user.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/user.cfg"),
- "User configuration"); err != nil {
- c.logger.Debug("No user.cfg found")
+ if !c.config.BackupUserConfigs {
+ // User-related configs are intentionally excluded together.
+ extraExclude = append(extraExclude, "user.cfg", "acl.cfg", "domains.cfg")
+ }
+ if !c.config.BackupRemoteConfigs {
+ extraExclude = append(extraExclude, "remote.cfg")
+ }
+ if !c.config.BackupSyncJobs {
+ extraExclude = append(extraExclude, "sync.cfg")
+ }
+ if !c.config.BackupVerificationJobs {
+ extraExclude = append(extraExclude, "verification.cfg")
}
-
- // ACL configuration
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "acl.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/acl.cfg"),
- "ACL configuration"); err != nil {
- c.logger.Debug("No acl.cfg found")
+ if !c.config.BackupTapeConfigs {
+ extraExclude = append(extraExclude, "tape.cfg", "tape-job.cfg", "media-pool.cfg", "tape-encryption-keys.json")
+ }
+ if !c.config.BackupNetworkConfigs {
+ extraExclude = append(extraExclude, "network.cfg")
}
+ if !c.config.BackupPruneSchedules {
+ extraExclude = append(extraExclude, "prune.cfg")
}
- // Remote configuration (for sync jobs)
- if c.config.BackupRemoteConfigs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "remote.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/remote.cfg"),
- "Remote configuration"); err != nil {
- c.logger.Debug("No remote.cfg found")
- }
+ // PBS main configuration directory (full backup)
+ if len(extraExclude) > 0 {
+ c.logger.Debug("PBS config exclusions enabled (disabled features): %s", strings.Join(extraExclude, ", "))
}
+ if err := c.withTemporaryExcludes(extraExclude, func() error {
+ return c.safeCopyDir(ctx,
+ root,
+ filepath.Join(c.tempDir, "etc/proxmox-backup"),
+ "PBS configuration")
+ }); err != nil {
+ return err
+ }
+
+ // Initialize manifest for PBS configs
+ c.pbsManifest = make(map[string]ManifestEntry)
+
+ c.logger.Info("Collecting PBS configuration files:")
+
+ // Datastore configuration
+ c.pbsManifest["datastore.cfg"] = c.collectPBSConfigFile(ctx, root, "datastore.cfg",
+ "Datastore configuration", c.config.BackupDatastoreConfigs)
+
+ // S3 endpoint configuration (used by S3 datastores)
+ c.pbsManifest["s3.cfg"] = c.collectPBSConfigFile(ctx, root, "s3.cfg",
+ "S3 endpoints", c.config.BackupDatastoreConfigs)
+
+ // Node configuration (global PBS settings)
+ c.pbsManifest["node.cfg"] = c.collectPBSConfigFile(ctx, root, "node.cfg",
+ "Node configuration", true)
+
+ // ACME configuration (accounts/plugins)
+ c.pbsManifest["acme/accounts.cfg"] = c.collectPBSConfigFile(ctx, root, filepath.Join("acme", "accounts.cfg"),
+ "ACME accounts", true)
+ c.pbsManifest["acme/plugins.cfg"] = c.collectPBSConfigFile(ctx, root, filepath.Join("acme", "plugins.cfg"),
+ "ACME plugins", true)
+
+ // External metric servers
+ c.pbsManifest["metricserver.cfg"] = c.collectPBSConfigFile(ctx, root, "metricserver.cfg",
+ "External metric servers", true)
+
+ // Traffic control
+ c.pbsManifest["traffic-control.cfg"] = c.collectPBSConfigFile(ctx, root, "traffic-control.cfg",
+ "Traffic control rules", true)
+
+ // User configuration
+ c.pbsManifest["user.cfg"] = c.collectPBSConfigFile(ctx, root, "user.cfg",
+ "User configuration", c.config.BackupUserConfigs)
+
+ // ACL configuration (under user configs flag)
+ c.pbsManifest["acl.cfg"] = c.collectPBSConfigFile(ctx, root, "acl.cfg",
+ "ACL configuration", c.config.BackupUserConfigs)
+
+ // Remote configuration (for sync jobs)
+ c.pbsManifest["remote.cfg"] = c.collectPBSConfigFile(ctx, root, "remote.cfg",
+ "Remote configuration", c.config.BackupRemoteConfigs)
// Sync jobs configuration
- if c.config.BackupSyncJobs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "sync.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/sync.cfg"),
- "Sync configuration"); err != nil {
- c.logger.Debug("No sync.cfg found")
- }
- }
+ c.pbsManifest["sync.cfg"] = c.collectPBSConfigFile(ctx, root, "sync.cfg",
+ "Sync jobs", c.config.BackupSyncJobs)
// Verification jobs configuration
- if c.config.BackupVerificationJobs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "verification.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/verification.cfg"),
- "Verification configuration"); err != nil {
- c.logger.Debug("No verification.cfg found")
- }
- }
+ c.pbsManifest["verification.cfg"] = c.collectPBSConfigFile(ctx, root, "verification.cfg",
+ "Verification jobs", c.config.BackupVerificationJobs)
- // Tape backup configuration (if applicable)
- if c.config.BackupTapeConfigs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "tape.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/tape.cfg"),
- "Tape configuration"); err != nil {
- c.logger.Debug("No tape.cfg found")
- }
+ // Tape backup configuration
+ c.pbsManifest["tape.cfg"] = c.collectPBSConfigFile(ctx, root, "tape.cfg",
+ "Tape configuration", c.config.BackupTapeConfigs)
- // Media pool configuration
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "media-pool.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/media-pool.cfg"),
- "Media pool configuration"); err != nil {
- c.logger.Debug("No media-pool.cfg found")
- }
- }
+ // Tape jobs (under tape configs flag)
+ c.pbsManifest["tape-job.cfg"] = c.collectPBSConfigFile(ctx, root, "tape-job.cfg",
+ "Tape jobs", c.config.BackupTapeConfigs)
+
+ // Media pool configuration (under tape configs flag)
+ c.pbsManifest["media-pool.cfg"] = c.collectPBSConfigFile(ctx, root, "media-pool.cfg",
+ "Media pool configuration", c.config.BackupTapeConfigs)
+
+ // Tape encryption keys (under tape configs flag)
+ c.pbsManifest["tape-encryption-keys.json"] = c.collectPBSConfigFile(ctx, root, "tape-encryption-keys.json",
+ "Tape encryption keys", c.config.BackupTapeConfigs)
// Network configuration
- if c.config.BackupNetworkConfigs {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "network.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/network.cfg"),
- "Network configuration"); err != nil {
- c.logger.Debug("No network.cfg found")
- }
- }
+ c.pbsManifest["network.cfg"] = c.collectPBSConfigFile(ctx, root, "network.cfg",
+ "Network configuration", c.config.BackupNetworkConfigs)
// Prune/GC schedules
- if c.config.BackupPruneSchedules {
- if err := c.safeCopyFile(ctx,
- filepath.Join(root, "prune.cfg"),
- filepath.Join(c.tempDir, "etc/proxmox-backup/prune.cfg"),
- "Prune configuration"); err != nil {
- c.logger.Debug("No prune.cfg found")
- }
- }
+ c.pbsManifest["prune.cfg"] = c.collectPBSConfigFile(ctx, root, "prune.cfg",
+ "Prune schedules", c.config.BackupPruneSchedules)
c.logger.Debug("PBS directory collection finished")
return nil
@@ -233,25 +305,18 @@ func (c *Collector) collectPBSDirectories(ctx context.Context, root string) erro
// collectPBSCommands collects output from PBS commands
func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsDatastore) error {
- commandsDir := filepath.Join(c.tempDir, "commands")
+ commandsDir := c.proxsaveCommandsDir("pbs")
if err := c.ensureDir(commandsDir); err != nil {
return fmt.Errorf("failed to create commands directory: %w", err)
}
c.logger.Debug("Collecting PBS command outputs into %s", commandsDir)
- stateDir := filepath.Join(c.tempDir, "var/lib/proxmox-backup")
- if err := c.ensureDir(stateDir); err != nil {
- return fmt.Errorf("failed to create PBS state directory: %w", err)
- }
- c.logger.Debug("PBS state snapshots will be stored in %s", stateDir)
-
// PBS version (CRITICAL)
if err := c.collectCommandMulti(ctx,
"proxmox-backup-manager version",
filepath.Join(commandsDir, "pbs_version.txt"),
"PBS version",
- true,
- filepath.Join(stateDir, "version.txt")); err != nil {
+ true); err != nil {
return fmt.Errorf("failed to get PBS version (critical): %w", err)
}
@@ -267,8 +332,7 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"proxmox-backup-manager datastore list --output-format=json",
filepath.Join(commandsDir, "datastore_list.json"),
"Datastore list",
- false,
- filepath.Join(stateDir, "datastore_list.json")); err != nil {
+ false); err != nil {
return err
}
@@ -283,24 +347,31 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
}
}
+ // ACME (accounts, plugins)
+ c.collectPBSAcmeSnapshots(ctx, commandsDir)
+
+ // Notifications (targets, matchers, endpoints)
+ c.collectPBSNotificationSnapshots(ctx, commandsDir)
+
// User list
if c.config.BackupUserConfigs {
if err := c.collectCommandMulti(ctx,
"proxmox-backup-manager user list --output-format=json",
filepath.Join(commandsDir, "user_list.json"),
"User list",
- false,
- filepath.Join(stateDir, "user_list.json")); err != nil {
+ false); err != nil {
return err
}
+ // Authentication realms (LDAP/AD/OpenID)
+ c.collectPBSRealmSnapshots(ctx, commandsDir)
+
// ACL list
if err := c.collectCommandMulti(ctx,
"proxmox-backup-manager acl list --output-format=json",
filepath.Join(commandsDir, "acl_list.json"),
"ACL list",
- false,
- filepath.Join(stateDir, "acl_list.json")); err != nil {
+ false); err != nil {
return err
}
}
@@ -311,8 +382,7 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"proxmox-backup-manager remote list --output-format=json",
filepath.Join(commandsDir, "remote_list.json"),
"Remote list",
- false,
- filepath.Join(stateDir, "remote_list.json")); err != nil {
+ false); err != nil {
return err
}
}
@@ -323,8 +393,7 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"proxmox-backup-manager sync-job list --output-format=json",
filepath.Join(commandsDir, "sync_jobs.json"),
"Sync jobs",
- false,
- filepath.Join(stateDir, "sync_jobs.json")); err != nil {
+ false); err != nil {
return err
}
}
@@ -335,8 +404,7 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"proxmox-backup-manager verify-job list --output-format=json",
filepath.Join(commandsDir, "verification_jobs.json"),
"Verification jobs",
- false,
- filepath.Join(stateDir, "verify_jobs.json")); err != nil {
+ false); err != nil {
return err
}
}
@@ -347,8 +415,7 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"proxmox-backup-manager prune-job list --output-format=json",
filepath.Join(commandsDir, "prune_jobs.json"),
"Prune jobs",
- false,
- filepath.Join(stateDir, "prune_jobs.json")); err != nil {
+ false); err != nil {
return err
}
}
@@ -408,8 +475,7 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"proxmox-backup-manager cert info",
filepath.Join(commandsDir, "cert_info.txt"),
"Certificate information",
- false,
- filepath.Join(stateDir, "cert_info.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -427,74 +493,170 @@ func (c *Collector) collectPBSCommands(ctx context.Context, datastores []pbsData
"Recent tasks",
false)
+ // S3 endpoints (optional, may be unavailable on older PBS versions)
+ c.collectPBSS3Snapshots(ctx, commandsDir)
+
return nil
}
-// collectDatastoreConfigs collects detailed datastore configurations
-func (c *Collector) collectDatastoreConfigs(ctx context.Context, datastores []pbsDatastore) error {
- if len(datastores) == 0 {
- c.logger.Debug("No datastores found")
- return nil
+func (c *Collector) collectPBSAcmeSnapshots(ctx context.Context, commandsDir string) {
+ accountsPath := filepath.Join(commandsDir, "acme_accounts.json")
+ if err := c.collectCommandMulti(ctx,
+ "proxmox-backup-manager acme account list --output-format=json",
+ accountsPath,
+ "ACME accounts",
+ false,
+ ); err != nil {
+ c.logger.Debug("ACME accounts snapshot skipped: %v", err)
}
- c.logger.Debug("Collecting datastore details for %d datastores", len(datastores))
- datastoreDir := filepath.Join(c.tempDir, "datastores")
- if err := c.ensureDir(datastoreDir); err != nil {
- return fmt.Errorf("failed to create datastores directory: %w", err)
+ pluginsPath := filepath.Join(commandsDir, "acme_plugins.json")
+ if err := c.collectCommandMulti(ctx,
+ "proxmox-backup-manager acme plugin list --output-format=json",
+ pluginsPath,
+ "ACME plugins",
+ false,
+ ); err != nil {
+ c.logger.Debug("ACME plugins snapshot skipped: %v", err)
}
- for _, ds := range datastores {
- // Get datastore configuration details
- c.safeCmdOutput(ctx,
- fmt.Sprintf("proxmox-backup-manager datastore show %s --output-format=json", ds.Name),
- filepath.Join(datastoreDir, fmt.Sprintf("%s_config.json", ds.Name)),
- fmt.Sprintf("Datastore %s configuration", ds.Name),
- false)
+ type acmeAccount struct {
+ Name string `json:"name"`
+ }
+ if raw, err := os.ReadFile(accountsPath); err == nil && len(raw) > 0 {
+ var accounts []acmeAccount
+ if err := json.Unmarshal(raw, &accounts); err == nil {
+ for _, account := range accounts {
+ name := strings.TrimSpace(account.Name)
+ if name == "" {
+ continue
+ }
+ out := filepath.Join(commandsDir, fmt.Sprintf("acme_account_%s_info.json", sanitizeFilename(name)))
+ _ = c.collectCommandMulti(ctx,
+ fmt.Sprintf("proxmox-backup-manager acme account info %s --output-format=json", name),
+ out,
+ fmt.Sprintf("ACME account info (%s)", name),
+ false)
+ }
+ }
+ }
- // Get namespace list using CLI/Filesystem fallback
- if err := c.collectDatastoreNamespaces(ds, datastoreDir); err != nil {
- c.logger.Debug("Failed to collect namespaces for datastore %s: %v", ds.Name, err)
+ type acmePlugin struct {
+ ID string `json:"id"`
+ }
+ if raw, err := os.ReadFile(pluginsPath); err == nil && len(raw) > 0 {
+ var plugins []acmePlugin
+ if err := json.Unmarshal(raw, &plugins); err == nil {
+ for _, plugin := range plugins {
+ id := strings.TrimSpace(plugin.ID)
+ if id == "" {
+ continue
+ }
+ out := filepath.Join(commandsDir, fmt.Sprintf("acme_plugin_%s_config.json", sanitizeFilename(id)))
+ _ = c.collectCommandMulti(ctx,
+ fmt.Sprintf("proxmox-backup-manager acme plugin config %s --output-format=json", id),
+ out,
+ fmt.Sprintf("ACME plugin config (%s)", id),
+ false)
+ }
}
}
+}
- c.logger.Debug("Datastore configuration collection completed")
- return nil
+func (c *Collector) collectPBSNotificationSnapshots(ctx context.Context, commandsDir string) {
+ _ = c.collectCommandMulti(ctx,
+ "proxmox-backup-manager notification target list --output-format=json",
+ filepath.Join(commandsDir, "notification_targets.json"),
+ "Notification targets",
+ false)
+
+ _ = c.collectCommandMulti(ctx,
+ "proxmox-backup-manager notification matcher list --output-format=json",
+ filepath.Join(commandsDir, "notification_matchers.json"),
+ "Notification matchers",
+ false)
+
+ for _, typ := range []string{"smtp", "sendmail", "gotify", "webhook"} {
+ _ = c.collectCommandMulti(ctx,
+ fmt.Sprintf("proxmox-backup-manager notification endpoint %s list --output-format=json", typ),
+ filepath.Join(commandsDir, fmt.Sprintf("notification_endpoints_%s.json", typ)),
+ fmt.Sprintf("Notification endpoints (%s)", typ),
+ false)
+ }
}
-// collectDatastoreNamespaces collects namespace information for a datastore
-// using CLI first, then filesystem fallback.
-func (c *Collector) collectDatastoreNamespaces(ds pbsDatastore, datastoreDir string) error {
- c.logger.Debug("Collecting namespaces for datastore %s (path: %s)", ds.Name, ds.Path)
- namespaces, fromFallback, err := listNamespacesFunc(ds.Name, ds.Path)
- if err != nil {
- return err
+func (c *Collector) collectPBSRealmSnapshots(ctx context.Context, commandsDir string) {
+ for _, realm := range []struct {
+ cmd string
+ out string
+ desc string
+ }{
+ {
+ cmd: "proxmox-backup-manager ldap list --output-format=json",
+ out: "realms_ldap.json",
+ desc: "LDAP realms",
+ },
+ {
+ cmd: "proxmox-backup-manager ad list --output-format=json",
+ out: "realms_ad.json",
+ desc: "Active Directory realms",
+ },
+ {
+ cmd: "proxmox-backup-manager openid list --output-format=json",
+ out: "realms_openid.json",
+ desc: "OpenID realms",
+ },
+ } {
+ _ = c.collectCommandMulti(ctx,
+ realm.cmd,
+ filepath.Join(commandsDir, realm.out),
+ realm.desc,
+ false)
}
+}
- // Write namespaces to JSON file
- outputPath := filepath.Join(datastoreDir, fmt.Sprintf("%s_namespaces.json", ds.Name))
- data, err := json.MarshalIndent(namespaces, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal namespaces: %w", err)
+func (c *Collector) collectPBSS3Snapshots(ctx context.Context, commandsDir string) {
+ endpointsPath := filepath.Join(commandsDir, "s3_endpoints.json")
+ if err := c.collectCommandMulti(ctx,
+ "proxmox-backup-manager s3 endpoint list --output-format=json",
+ endpointsPath,
+ "S3 endpoints",
+ false,
+ ); err != nil {
+ c.logger.Debug("S3 endpoints snapshot skipped: %v", err)
}
- if err := os.WriteFile(outputPath, data, 0640); err != nil {
- c.incFilesFailed()
- return fmt.Errorf("failed to write namespaces file: %w", err)
+ type s3Endpoint struct {
+ ID string `json:"id"`
+ }
+ raw, err := os.ReadFile(endpointsPath)
+ if err != nil || len(raw) == 0 {
+ return
+ }
+ var endpoints []s3Endpoint
+ if err := json.Unmarshal(raw, &endpoints); err != nil {
+ return
}
- c.incFilesProcessed()
- if fromFallback {
- c.logger.Debug("Successfully collected %d namespaces for datastore %s via filesystem fallback", len(namespaces), ds.Name)
- } else {
- c.logger.Debug("Successfully collected %d namespaces for datastore %s via CLI", len(namespaces), ds.Name)
+ for _, endpoint := range endpoints {
+ id := strings.TrimSpace(endpoint.ID)
+ if id == "" {
+ continue
+ }
+ // Best-effort: may require network and may not exist on older versions.
+ out := filepath.Join(commandsDir, fmt.Sprintf("s3_endpoint_%s_buckets.json", sanitizeFilename(id)))
+ _ = c.collectCommandMulti(ctx,
+ fmt.Sprintf("proxmox-backup-manager s3 endpoint list-buckets %s --output-format=json", id),
+ out,
+ fmt.Sprintf("S3 endpoint buckets (%s)", id),
+ false)
}
- return nil
}
// collectUserConfigs collects user and ACL configurations
func (c *Collector) collectUserConfigs(ctx context.Context) error {
c.logger.Debug("Collecting PBS user and ACL information")
- usersDir := filepath.Join(c.tempDir, "users")
+ usersDir := c.proxsaveInfoDir("pbs", "access-control")
if err := c.ensureDir(usersDir); err != nil {
return fmt.Errorf("failed to create users directory: %w", err)
}
@@ -507,7 +669,7 @@ func (c *Collector) collectUserConfigs(ctx context.Context) error {
func (c *Collector) collectUserTokens(ctx context.Context, usersDir string) {
c.logger.Debug("Collecting PBS API tokens for configured users")
- userListPath := filepath.Join(c.tempDir, "commands", "user_list.json")
+ userListPath := filepath.Join(c.proxsaveCommandsDir("pbs"), "user_list.json")
data, err := os.ReadFile(userListPath)
if err != nil {
c.logger.Debug("User list not available for token export: %v", err)
@@ -552,370 +714,17 @@ func (c *Collector) collectUserTokens(ctx context.Context, usersDir string) {
return
}
- if err := os.WriteFile(filepath.Join(usersDir, "tokens.json"), buffer, 0640); err != nil {
+ target := filepath.Join(usersDir, "tokens.json")
+ if c.shouldExclude(target) {
+ c.incFilesSkipped()
+ return
+ }
+ if err := c.writeReportFile(target, buffer); err != nil {
c.logger.Debug("Failed to write aggregated tokens.json: %v", err)
}
c.logger.Debug("Aggregated PBS token export completed (%d users)", len(aggregated))
}
-func (c *Collector) collectPBSPxarMetadata(ctx context.Context, datastores []pbsDatastore) error {
- if err := ctx.Err(); err != nil {
- return err
- }
-
- if len(datastores) == 0 {
- return nil
- }
- c.logger.Debug("Collecting PXAR metadata for %d datastores", len(datastores))
- dsWorkers := c.config.PxarDatastoreConcurrency
- if dsWorkers <= 0 {
- dsWorkers = 1
- }
- intraWorkers := c.config.PxarIntraConcurrency
- if intraWorkers <= 0 {
- intraWorkers = 1
- }
- mode := "sequential"
- if dsWorkers > 1 {
- mode = fmt.Sprintf("parallel (%d workers)", dsWorkers)
- }
- c.logger.Debug("PXAR metadata concurrency: datastores=%s, per-datastore workers=%d", mode, intraWorkers)
-
- metaRoot := filepath.Join(c.tempDir, "var/lib/proxmox-backup/pxar_metadata")
- if err := c.ensureDir(metaRoot); err != nil {
- return fmt.Errorf("failed to create PXAR metadata directory: %w", err)
- }
-
- selectedRoot := filepath.Join(c.tempDir, "var/lib/proxmox-backup/selected_pxar")
- if err := c.ensureDir(selectedRoot); err != nil {
- return fmt.Errorf("failed to create selected_pxar directory: %w", err)
- }
-
- smallRoot := filepath.Join(c.tempDir, "var/lib/proxmox-backup/small_pxar")
- if err := c.ensureDir(smallRoot); err != nil {
- return fmt.Errorf("failed to create small_pxar directory: %w", err)
- }
-
- workerLimit := dsWorkers
-
- ctx, cancel := context.WithCancel(ctx)
- defer cancel()
-
- var (
- wg sync.WaitGroup
- sem = make(chan struct{}, workerLimit)
- errMu sync.Mutex
- firstErr error
- )
-
- for _, ds := range datastores {
- ds := ds
- if ds.Path == "" {
- continue
- }
-
- wg.Add(1)
- go func() {
- defer wg.Done()
- select {
- case sem <- struct{}{}:
- case <-ctx.Done():
- return
- }
- defer func() { <-sem }()
-
- if err := c.processPxarDatastore(ctx, ds, metaRoot, selectedRoot, smallRoot); err != nil {
- if errors.Is(err, context.Canceled) {
- return
- }
- errMu.Lock()
- if firstErr == nil {
- firstErr = err
- cancel()
- }
- errMu.Unlock()
- }
- }()
- }
-
- wg.Wait()
-
- if firstErr != nil {
- return firstErr
- }
- if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) {
- return err
- }
-
- c.logger.Debug("PXAR metadata collection completed")
- return nil
-}
-
-func (c *Collector) processPxarDatastore(ctx context.Context, ds pbsDatastore, metaRoot, selectedRoot, smallRoot string) error {
- if err := ctx.Err(); err != nil {
- return err
- }
- if ds.Path == "" {
- return nil
- }
-
- stat, err := os.Stat(ds.Path)
- if err != nil || !stat.IsDir() {
- c.logger.Debug("Skipping PXAR metadata for datastore %s (path not accessible: %s)", ds.Name, ds.Path)
- return nil
- }
-
- start := time.Now()
- c.logger.Debug("PXAR: scanning datastore %s at %s", ds.Name, ds.Path)
-
- dsDir := filepath.Join(metaRoot, ds.Name)
- if err := c.ensureDir(dsDir); err != nil {
- return fmt.Errorf("failed to create PXAR metadata directory for %s: %w", ds.Name, err)
- }
-
- for _, base := range []string{
- filepath.Join(selectedRoot, ds.Name, "vm"),
- filepath.Join(selectedRoot, ds.Name, "ct"),
- filepath.Join(smallRoot, ds.Name, "vm"),
- filepath.Join(smallRoot, ds.Name, "ct"),
- } {
- if err := c.ensureDir(base); err != nil {
- c.logger.Debug("Failed to prepare PXAR directory %s: %v", base, err)
- }
- }
-
- meta := struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Comment string `json:"comment,omitempty"`
- ScannedAt time.Time `json:"scanned_at"`
- SampleDirectories []string `json:"sample_directories,omitempty"`
- SamplePxarFiles []FileSummary `json:"sample_pxar_files,omitempty"`
- }{
- Name: ds.Name,
- Path: ds.Path,
- Comment: ds.Comment,
- ScannedAt: time.Now(),
- }
-
- if dirs, err := c.sampleDirectories(ctx, ds.Path, 2, 30); err == nil && len(dirs) > 0 {
- meta.SampleDirectories = dirs
- c.logger.Debug("PXAR: datastore %s -> selected %d sample directories", ds.Name, len(dirs))
- } else if err != nil {
- c.logger.Debug("PXAR: datastore %s -> sampleDirectories error: %v", ds.Name, err)
- }
-
- includePatterns := c.config.PxarFileIncludePatterns
- if len(includePatterns) == 0 {
- includePatterns = []string{"*.pxar", "*.pxar.*", "catalog.pxar", "catalog.pxar.*"}
- }
- excludePatterns := c.config.PxarFileExcludePatterns
- if files, err := c.sampleFiles(ctx, ds.Path, includePatterns, excludePatterns, 8, 200); err == nil && len(files) > 0 {
- meta.SamplePxarFiles = files
- c.logger.Debug("PXAR: datastore %s -> selected %d sample pxar files", ds.Name, len(files))
- } else if err != nil {
- c.logger.Debug("PXAR: datastore %s -> sampleFiles error: %v", ds.Name, err)
- }
-
- data, err := json.MarshalIndent(meta, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal PXAR metadata for %s: %w", ds.Name, err)
- }
-
- if err := c.writeReportFile(filepath.Join(dsDir, "metadata.json"), data); err != nil {
- return err
- }
-
- if err := c.writePxarSubdirReport(filepath.Join(dsDir, fmt.Sprintf("%s_subdirs.txt", ds.Name)), ds); err != nil {
- return err
- }
-
- if err := c.writePxarListReport(filepath.Join(dsDir, fmt.Sprintf("%s_vm_pxar_list.txt", ds.Name)), ds, "vm"); err != nil {
- return err
- }
-
- if err := c.writePxarListReport(filepath.Join(dsDir, fmt.Sprintf("%s_ct_pxar_list.txt", ds.Name)), ds, "ct"); err != nil {
- return err
- }
-
- c.logger.Debug("PXAR: datastore %s completed in %s", ds.Name, time.Since(start).Truncate(time.Millisecond))
- return nil
-}
-
-func (c *Collector) writePxarSubdirReport(target string, ds pbsDatastore) error {
- c.logger.Debug("Writing PXAR subdirectory report for datastore %s", ds.Name)
- var builder strings.Builder
- builder.WriteString(fmt.Sprintf("# Datastore subdirectories in %s generated on %s\n", ds.Path, time.Now().Format(time.RFC1123)))
- builder.WriteString(fmt.Sprintf("# Datastore: %s\n", ds.Name))
-
- entries, err := os.ReadDir(ds.Path)
- if err != nil {
- builder.WriteString(fmt.Sprintf("# Unable to read datastore path: %v\n", err))
- return c.writeReportFile(target, []byte(builder.String()))
- }
-
- hasSubdirs := false
- for _, entry := range entries {
- if entry.IsDir() {
- builder.WriteString(entry.Name())
- builder.WriteByte('\n')
- hasSubdirs = true
- }
- }
-
- if !hasSubdirs {
- builder.WriteString("# No subdirectories found\n")
- }
-
- if err := c.writeReportFile(target, []byte(builder.String())); err != nil {
- return err
- }
- c.logger.Debug("PXAR subdirectory report written: %s", target)
- return nil
-}
-
-func (c *Collector) writePxarListReport(target string, ds pbsDatastore, subDir string) error {
- c.logger.Debug("Writing PXAR file list for datastore %s subdir %s", ds.Name, subDir)
- basePath := filepath.Join(ds.Path, subDir)
-
- var builder strings.Builder
- builder.WriteString(fmt.Sprintf("# List of .pxar files in %s generated on %s\n", basePath, time.Now().Format(time.RFC1123)))
- builder.WriteString(fmt.Sprintf("# Datastore: %s, Subdirectory: %s\n", ds.Name, subDir))
- builder.WriteString("# Format: permissions size date name\n")
-
- entries, err := os.ReadDir(basePath)
- if err != nil {
- builder.WriteString(fmt.Sprintf("# Unable to read directory: %v\n", err))
- if writeErr := c.writeReportFile(target, []byte(builder.String())); writeErr != nil {
- return writeErr
- }
- c.logger.Info("PXAR: datastore %s/%s -> path %s not accessible (%v)", ds.Name, subDir, basePath, err)
- return nil
- }
-
- type infoEntry struct {
- mode os.FileMode
- size int64
- time time.Time
- name string
- }
-
- var files []infoEntry
- for _, entry := range entries {
- if entry.IsDir() {
- continue
- }
- if !strings.HasSuffix(entry.Name(), ".pxar") {
- continue
- }
- info, err := entry.Info()
- if err != nil {
- continue
- }
- files = append(files, infoEntry{
- mode: info.Mode(),
- size: info.Size(),
- time: info.ModTime(),
- name: entry.Name(),
- })
- }
-
- count := len(files)
- if count == 0 {
- builder.WriteString("# No .pxar files found\n")
- } else {
- for _, file := range files {
- builder.WriteString(fmt.Sprintf("%s %d %s %s\n",
- file.mode.String(),
- file.size,
- file.time.Format("2006-01-02 15:04:05"),
- file.name))
- }
- }
-
- if err := c.writeReportFile(target, []byte(builder.String())); err != nil {
- return err
- }
- c.logger.Debug("PXAR file list report written: %s", target)
- if count == 0 {
- c.logger.Info("PXAR: datastore %s/%s -> 0 .pxar files", ds.Name, subDir)
- } else {
- c.logger.Info("PXAR: datastore %s/%s -> %d .pxar file(s)", ds.Name, subDir, count)
- }
- return nil
-}
-
-// getDatastoreList retrieves the list of configured datastores
-func (c *Collector) getDatastoreList(ctx context.Context) ([]pbsDatastore, error) {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- c.logger.Debug("Enumerating PBS datastores via proxmox-backup-manager")
-
- if _, err := c.depLookPath("proxmox-backup-manager"); err != nil {
- return nil, nil
- }
-
- output, err := c.depRunCommand(ctx, "proxmox-backup-manager", "datastore", "list", "--output-format=json")
- if err != nil {
- return nil, fmt.Errorf("proxmox-backup-manager datastore list failed: %w", err)
- }
-
- type datastoreEntry struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Comment string `json:"comment"`
- }
-
- var entries []datastoreEntry
- if err := json.Unmarshal(output, &entries); err != nil {
- return nil, fmt.Errorf("failed to parse datastore list JSON: %w", err)
- }
-
- datastores := make([]pbsDatastore, 0, len(entries))
- for _, entry := range entries {
- name := strings.TrimSpace(entry.Name)
- if name != "" {
- datastores = append(datastores, pbsDatastore{
- Name: name,
- Path: strings.TrimSpace(entry.Path),
- Comment: strings.TrimSpace(entry.Comment),
- })
- }
- }
-
- if len(c.config.PBSDatastorePaths) > 0 {
- existing := make(map[string]struct{}, len(datastores))
- for _, ds := range datastores {
- if ds.Path != "" {
- existing[ds.Path] = struct{}{}
- }
- }
- validName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
- for idx, override := range c.config.PBSDatastorePaths {
- override = strings.TrimSpace(override)
- if override == "" {
- continue
- }
- if _, ok := existing[override]; ok {
- continue
- }
- name := filepath.Base(filepath.Clean(override))
- if name == "" || name == "." || name == string(os.PathSeparator) || !validName.MatchString(name) {
- name = fmt.Sprintf("datastore_%d", idx+1)
- }
- datastores = append(datastores, pbsDatastore{
- Name: name,
- Path: override,
- Comment: "configured via PBS_DATASTORE_PATH",
- })
- }
- }
-
- c.logger.Debug("Detected %d configured datastores", len(datastores))
- return datastores, nil
-}
-
// hasTapeSupport checks if PBS has tape backup support configured
func (c *Collector) hasTapeSupport(ctx context.Context) (bool, error) {
if err := ctx.Err(); err != nil {
diff --git a/internal/backup/collector_pbs_commands_coverage_test.go b/internal/backup/collector_pbs_commands_coverage_test.go
index c01f2cd..9cac09a 100644
--- a/internal/backup/collector_pbs_commands_coverage_test.go
+++ b/internal/backup/collector_pbs_commands_coverage_test.go
@@ -41,15 +41,25 @@ func TestCollectPBSCommandsWritesExpectedOutputs(t *testing.T) {
t.Fatalf("collectPBSCommands error: %v", err)
}
- commandsDir := filepath.Join(collector.tempDir, "commands")
- stateDir := filepath.Join(collector.tempDir, "var/lib/proxmox-backup")
+ commandsDir := filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "pbs")
for _, rel := range []string{
"pbs_version.txt",
"node_config.json",
"datastore_list.json",
"datastore_store1_status.json",
+ "acme_accounts.json",
+ "acme_plugins.json",
+ "notification_targets.json",
+ "notification_matchers.json",
+ "notification_endpoints_smtp.json",
+ "notification_endpoints_sendmail.json",
+ "notification_endpoints_gotify.json",
+ "notification_endpoints_webhook.json",
"user_list.json",
+ "realms_ldap.json",
+ "realms_ad.json",
+ "realms_openid.json",
"acl_list.json",
"remote_list.json",
"sync_jobs.json",
@@ -64,19 +74,13 @@ func TestCollectPBSCommandsWritesExpectedOutputs(t *testing.T) {
"cert_info.txt",
"traffic_control.json",
"recent_tasks.json",
+ "s3_endpoints.json",
} {
if _, err := os.Stat(filepath.Join(commandsDir, rel)); err != nil {
t.Fatalf("expected %s to exist: %v", rel, err)
}
}
- if _, err := os.Stat(filepath.Join(stateDir, "version.txt")); err != nil {
- t.Fatalf("expected version mirror to exist: %v", err)
- }
- if _, err := os.Stat(filepath.Join(stateDir, "datastore_list.json")); err != nil {
- t.Fatalf("expected datastore_list mirror to exist: %v", err)
- }
-
version, err := os.ReadFile(filepath.Join(commandsDir, "pbs_version.txt"))
if err != nil {
t.Fatalf("read pbs_version.txt: %v", err)
@@ -140,7 +144,7 @@ func TestCollectPBSPxarMetadataProcessesMultipleDatastores(t *testing.T) {
t.Fatalf("collectPBSPxarMetadata error: %v", err)
}
- metaDir := filepath.Join(tmp, "var/lib/proxmox-backup/pxar_metadata")
+ metaDir := filepath.Join(tmp, "var/lib/proxsave-info", "pbs", "pxar", "metadata")
for _, tc := range []struct {
ds pbsDatastore
dsPath string
@@ -220,7 +224,7 @@ func TestCollectDatastoreConfigsCreatesConfigAndNamespaceFiles(t *testing.T) {
t.Fatalf("collectDatastoreConfigs error: %v", err)
}
- datastoreDir := filepath.Join(tmp, "datastores")
+ datastoreDir := filepath.Join(tmp, "var/lib/proxsave-info", "pbs", "datastores")
if _, err := os.Stat(filepath.Join(datastoreDir, "store_config.json")); err != nil {
t.Fatalf("expected config file: %v", err)
}
@@ -233,8 +237,8 @@ func TestCollectUserTokensSkipsInvalidUserListJSON(t *testing.T) {
tmp := t.TempDir()
collector := NewCollector(newTestLogger(), GetDefaultCollectorConfig(), tmp, types.ProxmoxBS, false)
- commandsDir := filepath.Join(tmp, "commands")
- usersDir := filepath.Join(tmp, "users")
+ commandsDir := filepath.Join(tmp, "var/lib/proxsave-info", "commands", "pbs")
+ usersDir := filepath.Join(tmp, "var/lib/proxsave-info", "pbs", "access-control")
if err := os.MkdirAll(commandsDir, 0o755); err != nil {
t.Fatalf("mkdir commands: %v", err)
}
@@ -273,7 +277,7 @@ func TestCollectPBSCommandsSkipsTapeDetailsWhenHasTapeSupportErrors(t *testing.T
t.Fatalf("collectPBSCommands error: %v", err)
}
- if _, err := os.Stat(filepath.Join(collector.tempDir, "commands", "tape_drives.json")); err == nil {
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "pbs", "tape_drives.json")); err == nil {
t.Fatalf("tape_drives.json should not be created when tape support check fails")
}
}
@@ -346,15 +350,15 @@ func TestCollectPBSConfigsEndToEndWithStubs(t *testing.T) {
t.Fatalf("expected etc/proxmox-backup to be copied: %v", err)
}
- if _, err := os.Stat(filepath.Join(collector.tempDir, "datastores", "store_config.json")); err != nil {
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "var/lib/proxsave-info", "pbs", "datastores", "store_config.json")); err != nil {
t.Fatalf("expected datastore config to be collected: %v", err)
}
- if _, err := os.Stat(filepath.Join(collector.tempDir, "users", "tokens.json")); err != nil {
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "var/lib/proxsave-info", "pbs", "access-control", "tokens.json")); err != nil {
t.Fatalf("expected tokens.json to be aggregated: %v", err)
}
- pxarMeta := filepath.Join(collector.tempDir, "var/lib/proxmox-backup/pxar_metadata", "store", "metadata.json")
+ pxarMeta := filepath.Join(collector.tempDir, "var/lib/proxsave-info", "pbs", "pxar", "metadata", "store", "metadata.json")
if _, err := os.Stat(pxarMeta); err != nil {
t.Fatalf("expected PXAR metadata to be collected: %v", err)
}
@@ -400,7 +404,7 @@ func TestCollectPBSPxarMetadataStopsOnFirstDatastoreError(t *testing.T) {
collector := NewCollector(newTestLogger(), cfg, tmp, types.ProxmoxBS, false)
- metaRoot := filepath.Join(tmp, "var/lib/proxmox-backup/pxar_metadata")
+ metaRoot := filepath.Join(tmp, "var/lib/proxsave-info", "pbs", "pxar", "metadata")
if err := os.MkdirAll(metaRoot, 0o755); err != nil {
t.Fatalf("mkdir metaRoot: %v", err)
}
diff --git a/internal/backup/collector_pbs_datastore.go b/internal/backup/collector_pbs_datastore.go
new file mode 100644
index 0000000..1b3d02c
--- /dev/null
+++ b/internal/backup/collector_pbs_datastore.go
@@ -0,0 +1,448 @@
+package backup
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/pbs"
+)
+
+type pbsDatastore struct {
+ Name string
+ Path string
+ Comment string
+}
+
+var listNamespacesFunc = pbs.ListNamespaces
+
+// collectDatastoreConfigs collects detailed datastore configurations
+func (c *Collector) collectDatastoreConfigs(ctx context.Context, datastores []pbsDatastore) error {
+ if len(datastores) == 0 {
+ c.logger.Debug("No datastores found")
+ return nil
+ }
+ c.logger.Debug("Collecting datastore details for %d datastores", len(datastores))
+
+ datastoreDir := c.proxsaveInfoDir("pbs", "datastores")
+ if err := c.ensureDir(datastoreDir); err != nil {
+ return fmt.Errorf("failed to create datastores directory: %w", err)
+ }
+
+ for _, ds := range datastores {
+ // Get datastore configuration details
+ c.safeCmdOutput(ctx,
+ fmt.Sprintf("proxmox-backup-manager datastore show %s --output-format=json", ds.Name),
+ filepath.Join(datastoreDir, fmt.Sprintf("%s_config.json", ds.Name)),
+ fmt.Sprintf("Datastore %s configuration", ds.Name),
+ false)
+
+ // Get namespace list using CLI/Filesystem fallback
+ if err := c.collectDatastoreNamespaces(ds, datastoreDir); err != nil {
+ c.logger.Debug("Failed to collect namespaces for datastore %s: %v", ds.Name, err)
+ }
+ }
+
+ c.logger.Debug("Datastore configuration collection completed")
+ return nil
+}
+
+// collectDatastoreNamespaces collects namespace information for a datastore
+// using CLI first, then filesystem fallback.
+func (c *Collector) collectDatastoreNamespaces(ds pbsDatastore, datastoreDir string) error {
+ c.logger.Debug("Collecting namespaces for datastore %s (path: %s)", ds.Name, ds.Path)
+ // Write location is deterministic; if excluded, skip the whole operation.
+ outputPath := filepath.Join(datastoreDir, fmt.Sprintf("%s_namespaces.json", ds.Name))
+ if c.shouldExclude(outputPath) {
+ c.incFilesSkipped()
+ return nil
+ }
+
+ namespaces, fromFallback, err := listNamespacesFunc(ds.Name, ds.Path)
+ if err != nil {
+ return err
+ }
+
+ // Write namespaces to JSON file
+ data, err := json.MarshalIndent(namespaces, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal namespaces: %w", err)
+ }
+
+ if err := c.writeReportFile(outputPath, data); err != nil {
+ return fmt.Errorf("failed to write namespaces file: %w", err)
+ }
+
+ if fromFallback {
+ c.logger.Debug("Successfully collected %d namespaces for datastore %s via filesystem fallback", len(namespaces), ds.Name)
+ } else {
+ c.logger.Debug("Successfully collected %d namespaces for datastore %s via CLI", len(namespaces), ds.Name)
+ }
+ return nil
+}
+
+func (c *Collector) collectPBSPxarMetadata(ctx context.Context, datastores []pbsDatastore) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ if len(datastores) == 0 {
+ return nil
+ }
+ c.logger.Debug("Collecting PXAR metadata for %d datastores", len(datastores))
+ dsWorkers := c.config.PxarDatastoreConcurrency
+ if dsWorkers <= 0 {
+ dsWorkers = 1
+ }
+ intraWorkers := c.config.PxarIntraConcurrency
+ if intraWorkers <= 0 {
+ intraWorkers = 1
+ }
+ mode := "sequential"
+ if dsWorkers > 1 {
+ mode = fmt.Sprintf("parallel (%d workers)", dsWorkers)
+ }
+ c.logger.Debug("PXAR metadata concurrency: datastores=%s, per-datastore workers=%d", mode, intraWorkers)
+
+ pxarRoot := c.proxsaveInfoDir("pbs", "pxar")
+ metaRoot := filepath.Join(pxarRoot, "metadata")
+ if err := c.ensureDir(metaRoot); err != nil {
+ return fmt.Errorf("failed to create PXAR metadata directory: %w", err)
+ }
+
+ selectedRoot := filepath.Join(pxarRoot, "selected")
+ if err := c.ensureDir(selectedRoot); err != nil {
+ return fmt.Errorf("failed to create selected_pxar directory: %w", err)
+ }
+
+ smallRoot := filepath.Join(pxarRoot, "small")
+ if err := c.ensureDir(smallRoot); err != nil {
+ return fmt.Errorf("failed to create small_pxar directory: %w", err)
+ }
+
+ workerLimit := dsWorkers
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ var (
+ wg sync.WaitGroup
+ sem = make(chan struct{}, workerLimit)
+ errMu sync.Mutex
+ firstErr error
+ )
+
+ for _, ds := range datastores {
+ ds := ds
+ if ds.Path == "" {
+ continue
+ }
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ select {
+ case sem <- struct{}{}:
+ case <-ctx.Done():
+ return
+ }
+ defer func() { <-sem }()
+
+ if err := c.processPxarDatastore(ctx, ds, metaRoot, selectedRoot, smallRoot); err != nil {
+ if errors.Is(err, context.Canceled) {
+ return
+ }
+ errMu.Lock()
+ if firstErr == nil {
+ firstErr = err
+ cancel()
+ }
+ errMu.Unlock()
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ if firstErr != nil {
+ return firstErr
+ }
+ if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) {
+ return err
+ }
+
+ c.logger.Debug("PXAR metadata collection completed")
+ return nil
+}
+
+func (c *Collector) processPxarDatastore(ctx context.Context, ds pbsDatastore, metaRoot, selectedRoot, smallRoot string) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if ds.Path == "" {
+ return nil
+ }
+
+ stat, err := os.Stat(ds.Path)
+ if err != nil || !stat.IsDir() {
+ c.logger.Debug("Skipping PXAR metadata for datastore %s (path not accessible: %s)", ds.Name, ds.Path)
+ return nil
+ }
+
+ start := time.Now()
+ c.logger.Debug("PXAR: scanning datastore %s at %s", ds.Name, ds.Path)
+
+ dsDir := filepath.Join(metaRoot, ds.Name)
+ if err := c.ensureDir(dsDir); err != nil {
+ return fmt.Errorf("failed to create PXAR metadata directory for %s: %w", ds.Name, err)
+ }
+
+ for _, base := range []string{
+ filepath.Join(selectedRoot, ds.Name, "vm"),
+ filepath.Join(selectedRoot, ds.Name, "ct"),
+ filepath.Join(smallRoot, ds.Name, "vm"),
+ filepath.Join(smallRoot, ds.Name, "ct"),
+ } {
+ if err := c.ensureDir(base); err != nil {
+ c.logger.Debug("Failed to prepare PXAR directory %s: %v", base, err)
+ }
+ }
+
+ meta := struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Comment string `json:"comment,omitempty"`
+ ScannedAt time.Time `json:"scanned_at"`
+ SampleDirectories []string `json:"sample_directories,omitempty"`
+ SamplePxarFiles []FileSummary `json:"sample_pxar_files,omitempty"`
+ }{
+ Name: ds.Name,
+ Path: ds.Path,
+ Comment: ds.Comment,
+ ScannedAt: time.Now(),
+ }
+
+ if dirs, err := c.sampleDirectories(ctx, ds.Path, 2, 30); err == nil && len(dirs) > 0 {
+ meta.SampleDirectories = dirs
+ c.logger.Debug("PXAR: datastore %s -> selected %d sample directories", ds.Name, len(dirs))
+ } else if err != nil {
+ c.logger.Debug("PXAR: datastore %s -> sampleDirectories error: %v", ds.Name, err)
+ }
+
+ includePatterns := c.config.PxarFileIncludePatterns
+ if len(includePatterns) == 0 {
+ includePatterns = []string{"*.pxar", "*.pxar.*", "catalog.pxar", "catalog.pxar.*"}
+ }
+ excludePatterns := c.config.PxarFileExcludePatterns
+ if files, err := c.sampleFiles(ctx, ds.Path, includePatterns, excludePatterns, 8, 200); err == nil && len(files) > 0 {
+ meta.SamplePxarFiles = files
+ c.logger.Debug("PXAR: datastore %s -> selected %d sample pxar files", ds.Name, len(files))
+ } else if err != nil {
+ c.logger.Debug("PXAR: datastore %s -> sampleFiles error: %v", ds.Name, err)
+ }
+
+ data, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal PXAR metadata for %s: %w", ds.Name, err)
+ }
+
+ if err := c.writeReportFile(filepath.Join(dsDir, "metadata.json"), data); err != nil {
+ return err
+ }
+
+ if err := c.writePxarSubdirReport(filepath.Join(dsDir, fmt.Sprintf("%s_subdirs.txt", ds.Name)), ds); err != nil {
+ return err
+ }
+
+ if err := c.writePxarListReport(filepath.Join(dsDir, fmt.Sprintf("%s_vm_pxar_list.txt", ds.Name)), ds, "vm"); err != nil {
+ return err
+ }
+
+ if err := c.writePxarListReport(filepath.Join(dsDir, fmt.Sprintf("%s_ct_pxar_list.txt", ds.Name)), ds, "ct"); err != nil {
+ return err
+ }
+
+ c.logger.Debug("PXAR: datastore %s completed in %s", ds.Name, time.Since(start).Truncate(time.Millisecond))
+ return nil
+}
+
+func (c *Collector) writePxarSubdirReport(target string, ds pbsDatastore) error {
+ c.logger.Debug("Writing PXAR subdirectory report for datastore %s", ds.Name)
+ var builder strings.Builder
+ builder.WriteString(fmt.Sprintf("# Datastore subdirectories in %s generated on %s\n", ds.Path, time.Now().Format(time.RFC1123)))
+ builder.WriteString(fmt.Sprintf("# Datastore: %s\n", ds.Name))
+
+ entries, err := os.ReadDir(ds.Path)
+ if err != nil {
+ builder.WriteString(fmt.Sprintf("# Unable to read datastore path: %v\n", err))
+ return c.writeReportFile(target, []byte(builder.String()))
+ }
+
+ hasSubdirs := false
+ for _, entry := range entries {
+ if entry.IsDir() {
+ builder.WriteString(entry.Name())
+ builder.WriteByte('\n')
+ hasSubdirs = true
+ }
+ }
+
+ if !hasSubdirs {
+ builder.WriteString("# No subdirectories found\n")
+ }
+
+ if err := c.writeReportFile(target, []byte(builder.String())); err != nil {
+ return err
+ }
+ c.logger.Debug("PXAR subdirectory report written: %s", target)
+ return nil
+}
+
+func (c *Collector) writePxarListReport(target string, ds pbsDatastore, subDir string) error {
+ c.logger.Debug("Writing PXAR file list for datastore %s subdir %s", ds.Name, subDir)
+ basePath := filepath.Join(ds.Path, subDir)
+
+ var builder strings.Builder
+ builder.WriteString(fmt.Sprintf("# List of .pxar files in %s generated on %s\n", basePath, time.Now().Format(time.RFC1123)))
+ builder.WriteString(fmt.Sprintf("# Datastore: %s, Subdirectory: %s\n", ds.Name, subDir))
+ builder.WriteString("# Format: permissions size date name\n")
+
+ entries, err := os.ReadDir(basePath)
+ if err != nil {
+ builder.WriteString(fmt.Sprintf("# Unable to read directory: %v\n", err))
+ if writeErr := c.writeReportFile(target, []byte(builder.String())); writeErr != nil {
+ return writeErr
+ }
+ c.logger.Info("PXAR: datastore %s/%s -> path %s not accessible (%v)", ds.Name, subDir, basePath, err)
+ return nil
+ }
+
+ type infoEntry struct {
+ mode os.FileMode
+ size int64
+ time time.Time
+ name string
+ }
+
+ var files []infoEntry
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ if !strings.HasSuffix(entry.Name(), ".pxar") {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+ files = append(files, infoEntry{
+ mode: info.Mode(),
+ size: info.Size(),
+ time: info.ModTime(),
+ name: entry.Name(),
+ })
+ }
+
+ count := len(files)
+ if count == 0 {
+ builder.WriteString("# No .pxar files found\n")
+ } else {
+ for _, file := range files {
+ builder.WriteString(fmt.Sprintf("%s %d %s %s\n",
+ file.mode.String(),
+ file.size,
+ file.time.Format("2006-01-02 15:04:05"),
+ file.name))
+ }
+ }
+
+ if err := c.writeReportFile(target, []byte(builder.String())); err != nil {
+ return err
+ }
+ c.logger.Debug("PXAR file list report written: %s", target)
+ if count == 0 {
+ c.logger.Info("PXAR: datastore %s/%s -> 0 .pxar files", ds.Name, subDir)
+ } else {
+ c.logger.Info("PXAR: datastore %s/%s -> %d .pxar file(s)", ds.Name, subDir, count)
+ }
+ return nil
+}
+
+// getDatastoreList retrieves the list of configured datastores
+func (c *Collector) getDatastoreList(ctx context.Context) ([]pbsDatastore, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ c.logger.Debug("Enumerating PBS datastores via proxmox-backup-manager")
+
+ if _, err := c.depLookPath("proxmox-backup-manager"); err != nil {
+ return nil, nil
+ }
+
+ output, err := c.depRunCommand(ctx, "proxmox-backup-manager", "datastore", "list", "--output-format=json")
+ if err != nil {
+ return nil, fmt.Errorf("proxmox-backup-manager datastore list failed: %w", err)
+ }
+
+ type datastoreEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Comment string `json:"comment"`
+ }
+
+ var entries []datastoreEntry
+ if err := json.Unmarshal(output, &entries); err != nil {
+ return nil, fmt.Errorf("failed to parse datastore list JSON: %w", err)
+ }
+
+ datastores := make([]pbsDatastore, 0, len(entries))
+ for _, entry := range entries {
+ name := strings.TrimSpace(entry.Name)
+ if name != "" {
+ datastores = append(datastores, pbsDatastore{
+ Name: name,
+ Path: strings.TrimSpace(entry.Path),
+ Comment: strings.TrimSpace(entry.Comment),
+ })
+ }
+ }
+
+ if len(c.config.PBSDatastorePaths) > 0 {
+ existing := make(map[string]struct{}, len(datastores))
+ for _, ds := range datastores {
+ if ds.Path != "" {
+ existing[ds.Path] = struct{}{}
+ }
+ }
+ validName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+ for idx, override := range c.config.PBSDatastorePaths {
+ override = strings.TrimSpace(override)
+ if override == "" {
+ continue
+ }
+ if _, ok := existing[override]; ok {
+ continue
+ }
+ name := filepath.Base(filepath.Clean(override))
+ if name == "" || name == "." || name == string(os.PathSeparator) || !validName.MatchString(name) {
+ name = fmt.Sprintf("datastore_%d", idx+1)
+ }
+ datastores = append(datastores, pbsDatastore{
+ Name: name,
+ Path: override,
+ Comment: "configured via PBS_DATASTORE_PATH",
+ })
+ }
+ }
+
+ c.logger.Debug("Detected %d configured datastores", len(datastores))
+ return datastores, nil
+}
diff --git a/internal/backup/collector_pbs_datastore_inventory.go b/internal/backup/collector_pbs_datastore_inventory.go
new file mode 100644
index 0000000..c549206
--- /dev/null
+++ b/internal/backup/collector_pbs_datastore_inventory.go
@@ -0,0 +1,869 @@
+package backup
+
+import (
+ "bufio"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+type inventoryFileSnapshot struct {
+ LogicalPath string `json:"logical_path"`
+ SourcePath string `json:"source_path,omitempty"`
+ Exists bool `json:"exists"`
+ Skipped bool `json:"skipped,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ SizeBytes int64 `json:"size_bytes,omitempty"`
+ SHA256 string `json:"sha256,omitempty"`
+ Content string `json:"content,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type inventoryDirSnapshot struct {
+ LogicalPath string `json:"logical_path"`
+ SourcePath string `json:"source_path,omitempty"`
+ Exists bool `json:"exists"`
+ Skipped bool `json:"skipped,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Error string `json:"error,omitempty"`
+ Files []inventoryDirEntry `json:"files,omitempty"`
+}
+
+type inventoryDirEntry struct {
+ RelativePath string `json:"relative_path"`
+ SizeBytes int64 `json:"size_bytes,omitempty"`
+ SHA256 string `json:"sha256,omitempty"`
+ IsSymlink bool `json:"is_symlink,omitempty"`
+ SymlinkTarget string `json:"symlink_target,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type inventoryCommandSnapshot struct {
+ Command string `json:"command"`
+ Output string `json:"output,omitempty"`
+ Error string `json:"error,omitempty"`
+ Skipped bool `json:"skipped,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type pbsDatastorePathMarkers struct {
+ HasChunks bool `json:"has_chunks,omitempty"`
+ HasLock bool `json:"has_lock,omitempty"`
+ HasGCStatus bool `json:"has_gc_status,omitempty"`
+ HasVMDir bool `json:"has_vm_dir,omitempty"`
+ HasCTDir bool `json:"has_ct_dir,omitempty"`
+}
+
+type pbsDatastoreInventoryEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Sources []string `json:"sources,omitempty"`
+ StatPath string `json:"stat_path,omitempty"`
+ PathOK bool `json:"path_ok,omitempty"`
+ PathIsDir bool `json:"path_is_dir,omitempty"`
+ Markers pbsDatastorePathMarkers `json:"markers,omitempty"`
+
+ Findmnt inventoryCommandSnapshot `json:"findmnt,omitempty"`
+ DF inventoryCommandSnapshot `json:"df,omitempty"`
+}
+
+type pbsDatastoreInventoryReport struct {
+ GeneratedAt string `json:"generated_at"`
+ Hostname string `json:"hostname,omitempty"`
+ SystemRootPrefix string `json:"system_root_prefix,omitempty"`
+ PBSConfigPath string `json:"pbs_config_path,omitempty"`
+ HostCommands bool `json:"host_commands,omitempty"`
+ DatastoreCfgParse bool `json:"datastore_cfg_parse,omitempty"`
+
+ Files map[string]inventoryFileSnapshot `json:"files,omitempty"`
+ Dirs map[string]inventoryDirSnapshot `json:"dirs,omitempty"`
+ Commands map[string]inventoryCommandSnapshot `json:"commands,omitempty"`
+ Datastores []pbsDatastoreInventoryEntry `json:"datastores,omitempty"`
+}
+
+func (c *Collector) collectPBSDatastoreInventory(ctx context.Context, cliDatastores []pbsDatastore) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ commandsDir := c.proxsaveCommandsDir("pbs")
+ if err := c.ensureDir(commandsDir); err != nil {
+ return fmt.Errorf("ensure commands dir: %w", err)
+ }
+
+ outputPath := filepath.Join(commandsDir, "pbs_datastore_inventory.json")
+ if c.shouldExclude(outputPath) {
+ c.incFilesSkipped()
+ return nil
+ }
+
+ ensureSystemPath()
+
+ // Copy storage stack configuration that may be needed to re-mount datastore paths (best effort).
+ for _, dir := range []struct {
+ src string
+ dest string
+ desc string
+ }{
+ {src: "/etc/iscsi", dest: filepath.Join(c.tempDir, "etc/iscsi"), desc: "iSCSI configuration"},
+ {src: "/var/lib/iscsi", dest: filepath.Join(c.tempDir, "var/lib/iscsi"), desc: "iSCSI runtime state"},
+ {src: "/etc/multipath", dest: filepath.Join(c.tempDir, "etc/multipath"), desc: "multipath configuration"},
+ {src: "/etc/mdadm", dest: filepath.Join(c.tempDir, "etc/mdadm"), desc: "mdadm configuration"},
+ {src: "/etc/lvm/backup", dest: filepath.Join(c.tempDir, "etc/lvm/backup"), desc: "LVM metadata backups"},
+ {src: "/etc/lvm/archive", dest: filepath.Join(c.tempDir, "etc/lvm/archive"), desc: "LVM metadata archives"},
+ {src: "/etc/zfs", dest: filepath.Join(c.tempDir, "etc/zfs"), desc: "ZFS configuration/cache"},
+ } {
+ if err := c.safeCopyDir(ctx, c.systemPath(dir.src), dir.dest, dir.desc); err != nil {
+ c.logger.Warning("Failed to collect %s (%s): %v", dir.desc, dir.src, err)
+ }
+ }
+ if err := c.safeCopyFile(ctx, c.systemPath("/etc/multipath.conf"), filepath.Join(c.tempDir, "etc/multipath.conf"), "multipath.conf"); err != nil {
+ c.logger.Warning("Failed to collect /etc/multipath.conf: %v", err)
+ }
+
+ report := pbsDatastoreInventoryReport{
+ GeneratedAt: time.Now().Format(time.RFC3339),
+ SystemRootPrefix: strings.TrimSpace(c.config.SystemRootPrefix),
+ PBSConfigPath: c.pbsConfigPath(),
+ HostCommands: c.shouldRunHostCommands(),
+ Files: make(map[string]inventoryFileSnapshot),
+ Dirs: make(map[string]inventoryDirSnapshot),
+ Commands: make(map[string]inventoryCommandSnapshot),
+ }
+ if host, err := os.Hostname(); err == nil {
+ report.Hostname = host
+ }
+
+ report.Files["pbs_datastore_cfg"] = c.captureInventoryFile(filepath.Join(c.pbsConfigPath(), "datastore.cfg"), "pbsConfig/datastore.cfg")
+ report.Files["fstab"] = c.captureInventoryFile(c.systemPath("/etc/fstab"), "/etc/fstab")
+ report.Files["crypttab"] = c.captureInventoryFile(c.systemPath("/etc/crypttab"), "/etc/crypttab")
+ report.Files["mdstat"] = c.captureInventoryFile(c.systemPath("/proc/mdstat"), "/proc/mdstat")
+ report.Files["os_release"] = c.captureInventoryFile(c.systemPath("/etc/os-release"), "/etc/os-release")
+ report.Files["proc_mounts"] = c.captureInventoryFile(c.systemPath("/proc/mounts"), "/proc/mounts")
+ report.Files["lvm_conf"] = c.captureInventoryFile(c.systemPath("/etc/lvm/lvm.conf"), "/etc/lvm/lvm.conf")
+ report.Files["multipath_conf"] = c.captureInventoryFile(c.systemPath("/etc/multipath.conf"), "/etc/multipath.conf")
+ report.Files["multipath_bindings"] = c.captureInventoryFile(c.systemPath("/etc/multipath/bindings"), "/etc/multipath/bindings")
+ report.Files["multipath_wwids"] = c.captureInventoryFile(c.systemPath("/etc/multipath/wwids"), "/etc/multipath/wwids")
+ report.Files["mdadm_conf"] = c.captureInventoryFile(c.systemPath("/etc/mdadm/mdadm.conf"), "/etc/mdadm/mdadm.conf")
+ report.Files["iscsi_initiatorname"] = c.captureInventoryFile(c.systemPath("/etc/iscsi/initiatorname.iscsi"), "/etc/iscsi/initiatorname.iscsi")
+ report.Files["iscsi_iscsid_conf"] = c.captureInventoryFile(c.systemPath("/etc/iscsi/iscsid.conf"), "/etc/iscsi/iscsid.conf")
+ report.Files["autofs_master"] = c.captureInventoryFile(c.systemPath("/etc/auto.master"), "/etc/auto.master")
+ report.Files["autofs_conf"] = c.captureInventoryFile(c.systemPath("/etc/autofs.conf"), "/etc/autofs.conf")
+ report.Files["zfs_zpool_cache"] = c.captureInventoryFile(c.systemPath("/etc/zfs/zpool.cache"), "/etc/zfs/zpool.cache")
+
+ report.Dirs["iscsi_etc"] = c.captureInventoryDir(ctx, c.systemPath("/etc/iscsi"), "/etc/iscsi")
+ report.Dirs["iscsi_var_lib"] = c.captureInventoryDir(ctx, c.systemPath("/var/lib/iscsi"), "/var/lib/iscsi")
+ report.Dirs["multipath_etc"] = c.captureInventoryDir(ctx, c.systemPath("/etc/multipath"), "/etc/multipath")
+ report.Dirs["mdadm_etc"] = c.captureInventoryDir(ctx, c.systemPath("/etc/mdadm"), "/etc/mdadm")
+ report.Dirs["lvm_backup"] = c.captureInventoryDir(ctx, c.systemPath("/etc/lvm/backup"), "/etc/lvm/backup")
+ report.Dirs["lvm_archive"] = c.captureInventoryDir(ctx, c.systemPath("/etc/lvm/archive"), "/etc/lvm/archive")
+ report.Dirs["zfs_etc"] = c.captureInventoryDir(ctx, c.systemPath("/etc/zfs"), "/etc/zfs")
+ report.Dirs["autofs_master_d"] = c.captureInventoryDir(ctx, c.systemPath("/etc/auto.master.d"), "/etc/auto.master.d")
+
+ // Capture systemd mount units (common for remote storage mounts outside /etc/fstab).
+ report.Dirs["systemd_mount_units"] = c.captureInventoryDirFiltered(
+ ctx,
+ c.systemPath("/etc/systemd/system"),
+ "/etc/systemd/system",
+ func(rel string, info os.FileInfo) bool {
+ name := strings.ToLower(filepath.Base(rel))
+ return strings.HasSuffix(name, ".mount") || strings.HasSuffix(name, ".automount")
+ },
+ )
+ if err := c.safeCopySystemdMountUnitFiles(ctx); err != nil {
+ c.logger.Warning("Failed to collect systemd mount units: %v", err)
+ }
+
+ // Capture common autofs map files and copy them into the backup tree (best effort).
+ if err := c.safeCopyAutofsMapFiles(ctx); err != nil {
+ c.logger.Warning("Failed to collect autofs map files: %v", err)
+ }
+
+ // Best-effort: capture and copy referenced key/credential files from crypttab/fstab.
+ for _, ref := range uniqueSortedStrings(append(
+ extractCrypttabKeyFiles(report.Files["crypttab"].Content),
+ extractFstabReferencedFiles(report.Files["fstab"].Content)...,
+ )) {
+ ref := ref
+ key := referencedFileKey(ref)
+ snap := c.captureInventoryFile(c.systemPath(ref), ref)
+ if !snap.Skipped && snap.Reason == "" {
+ snap.Reason = "referenced by fstab/crypttab"
+ }
+ report.Files[key] = snap
+
+ dest := filepath.Join(c.tempDir, strings.TrimPrefix(ref, "/"))
+ if err := c.safeCopyFile(ctx, c.systemPath(ref), dest, "Referenced file"); err != nil {
+ c.logger.Warning("Failed to collect referenced file %s: %v", ref, err)
+ }
+ }
+
+ configDatastores := parsePBSDatastoreCfg(report.Files["pbs_datastore_cfg"].Content)
+ if len(configDatastores) > 0 {
+ report.DatastoreCfgParse = true
+ }
+
+ merged := mergePBSDatastoreDefinitions(cliDatastores, configDatastores)
+ report.Datastores = make([]pbsDatastoreInventoryEntry, 0, len(merged))
+
+ for _, def := range merged {
+ entry := pbsDatastoreInventoryEntry{
+ Name: def.Name,
+ Path: def.Path,
+ Comment: def.Comment,
+ Sources: append([]string(nil), def.Sources...),
+ }
+
+ statPath := def.Path
+ if filepath.IsAbs(statPath) {
+ statPath = c.systemPath(statPath)
+ }
+ entry.StatPath = statPath
+
+ if statPath != "" {
+ if info, err := os.Stat(statPath); err == nil {
+ entry.PathOK = true
+ entry.PathIsDir = info.IsDir()
+ entry.Markers = c.inspectPBSDatastorePathMarkers(statPath)
+ }
+ }
+
+ if report.HostCommands && def.Path != "" && filepath.IsAbs(def.Path) {
+ entry.Findmnt = c.captureInventoryCommand(ctx, fmt.Sprintf("findmnt -J -T %s", def.Path), "findmnt", "-J", "-T", def.Path)
+ entry.DF = c.captureInventoryCommand(ctx, fmt.Sprintf("df -T %s", def.Path), "df", "-T", def.Path)
+ }
+
+ report.Datastores = append(report.Datastores, entry)
+ }
+
+ if report.HostCommands {
+ report.Commands["uname"] = c.captureInventoryCommand(ctx, "uname -a", "uname", "-a")
+ report.Commands["blkid"] = c.captureInventoryCommand(ctx, "blkid", "blkid")
+ report.Commands["lsblk_json"] = c.captureInventoryCommand(ctx, "lsblk -J -O", "lsblk", "-J", "-O")
+ report.Commands["findmnt_json"] = c.captureInventoryCommand(ctx, "findmnt -J", "findmnt", "-J")
+ report.Commands["nfsstat_mounts"] = c.captureInventoryCommand(ctx, "nfsstat -m", "nfsstat", "-m")
+
+ report.Commands["dmsetup_tree"] = c.captureInventoryCommand(ctx, "dmsetup ls --tree", "dmsetup", "ls", "--tree")
+ report.Commands["pvs_json"] = c.captureInventoryCommand(ctx, "pvs --reportformat json --units b", "pvs", "--reportformat", "json", "--units", "b")
+ report.Commands["vgs_json"] = c.captureInventoryCommand(ctx, "vgs --reportformat json --units b", "vgs", "--reportformat", "json", "--units", "b")
+ report.Commands["lvs_json"] = c.captureInventoryCommand(ctx, "lvs --reportformat json --units b -a", "lvs", "--reportformat", "json", "--units", "b", "-a")
+
+ report.Commands["proc_mdstat"] = c.captureInventoryCommand(ctx, "cat /proc/mdstat", "cat", "/proc/mdstat")
+ report.Commands["mdadm_scan"] = c.captureInventoryCommand(ctx, "mdadm --detail --scan", "mdadm", "--detail", "--scan")
+
+ report.Commands["multipath_ll"] = c.captureInventoryCommand(ctx, "multipath -ll", "multipath", "-ll")
+
+ report.Commands["iscsi_sessions"] = c.captureInventoryCommand(ctx, "iscsiadm -m session", "iscsiadm", "-m", "session")
+ report.Commands["iscsi_nodes"] = c.captureInventoryCommand(ctx, "iscsiadm -m node", "iscsiadm", "-m", "node")
+ report.Commands["iscsi_ifaces"] = c.captureInventoryCommand(ctx, "iscsiadm -m iface", "iscsiadm", "-m", "iface")
+
+ report.Commands["zpool_status"] = c.captureInventoryCommand(ctx, "zpool status -P", "zpool", "status", "-P")
+ report.Commands["zpool_list"] = c.captureInventoryCommand(ctx, "zpool list", "zpool", "list")
+ report.Commands["zfs_list"] = c.captureInventoryCommand(ctx, "zfs list", "zfs", "list")
+ } else {
+ report.Commands["host_commands_skipped"] = inventoryCommandSnapshot{
+ Command: "host_commands",
+ Skipped: true,
+ Reason: "system_root_prefix is not host root; skipping host-only commands",
+ }
+ }
+
+ // Include already collected PBS command outputs if available (best-effort).
+ report.Commands["pbs_version_file"] = c.captureInventoryCommandFromFile(filepath.Join(commandsDir, "pbs_version.txt"), "var/lib/proxsave-info/commands/pbs/pbs_version.txt")
+ report.Commands["datastore_list_file"] = c.captureInventoryCommandFromFile(filepath.Join(commandsDir, "datastore_list.json"), "var/lib/proxsave-info/commands/pbs/datastore_list.json")
+
+ data, err := json.MarshalIndent(report, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal datastore inventory report: %w", err)
+ }
+
+ if err := c.writeReportFile(outputPath, data); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (c *Collector) captureInventoryFile(sourcePath, logicalPath string) inventoryFileSnapshot {
+ snap := inventoryFileSnapshot{
+ LogicalPath: logicalPath,
+ SourcePath: sourcePath,
+ }
+
+ if c.shouldExclude(sourcePath) {
+ snap.Skipped = true
+ snap.Reason = "excluded by pattern"
+ return snap
+ }
+
+ data, err := os.ReadFile(sourcePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return snap
+ }
+ snap.Error = err.Error()
+ return snap
+ }
+
+ snap.Exists = true
+ snap.SizeBytes = int64(len(data))
+ snap.SHA256 = sha256Hex(data)
+ snap.Content = string(data)
+ return snap
+}
+
+func (c *Collector) captureInventoryDir(ctx context.Context, sourcePath, logicalPath string) inventoryDirSnapshot {
+ snap := inventoryDirSnapshot{
+ LogicalPath: logicalPath,
+ SourcePath: sourcePath,
+ }
+
+ if c.shouldExclude(sourcePath) {
+ snap.Skipped = true
+ snap.Reason = "excluded by pattern"
+ return snap
+ }
+
+ info, err := os.Stat(sourcePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return snap
+ }
+ snap.Error = err.Error()
+ return snap
+ }
+
+ if !info.IsDir() {
+ snap.Exists = true
+ snap.Error = "not a directory"
+ return snap
+ }
+
+ snap.Exists = true
+
+ var files []inventoryDirEntry
+ walkErr := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
+ if errCtx := ctx.Err(); errCtx != nil {
+ return errCtx
+ }
+ if err != nil {
+ return err
+ }
+ if info == nil || info.IsDir() {
+ return nil
+ }
+
+ rel, err := filepath.Rel(sourcePath, path)
+ if err != nil {
+ return err
+ }
+
+ entry := inventoryDirEntry{
+ RelativePath: rel,
+ SizeBytes: info.Size(),
+ }
+
+ if info.Mode()&os.ModeSymlink != 0 {
+ entry.IsSymlink = true
+ if target, err := os.Readlink(path); err == nil {
+ entry.SymlinkTarget = target
+ } else {
+ entry.Error = err.Error()
+ }
+ files = append(files, entry)
+ return nil
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ entry.Error = err.Error()
+ } else {
+ entry.SHA256 = sha256Hex(data)
+ }
+
+ files = append(files, entry)
+ return nil
+ })
+ if walkErr != nil {
+ snap.Error = walkErr.Error()
+ }
+
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].RelativePath < files[j].RelativePath
+ })
+ snap.Files = files
+ return snap
+}
+
+func (c *Collector) captureInventoryCommandFromFile(path, logical string) inventoryCommandSnapshot {
+ out := inventoryCommandSnapshot{
+ Command: logical,
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ out.Skipped = true
+ out.Reason = "file not present"
+ return out
+ }
+ out.Error = err.Error()
+ return out
+ }
+ out.Output = string(data)
+ return out
+}
+
+func (c *Collector) captureInventoryCommand(ctx context.Context, pretty string, name string, args ...string) inventoryCommandSnapshot {
+ result := inventoryCommandSnapshot{
+ Command: pretty,
+ }
+
+ if err := ctx.Err(); err != nil {
+ result.Error = err.Error()
+ return result
+ }
+
+ if _, err := c.depLookPath(name); err != nil {
+ result.Skipped = true
+ result.Reason = "command not found"
+ return result
+ }
+
+ output, err := c.depRunCommand(ctx, name, args...)
+ if err != nil {
+ result.Error = err.Error()
+ }
+ if len(output) > 0 {
+ result.Output = string(output)
+ }
+ return result
+}
+
+type pbsDatastoreDefinition struct {
+ Name string
+ Path string
+ Comment string
+ Sources []string
+}
+
+func mergePBSDatastoreDefinitions(cli, config []pbsDatastore) []pbsDatastoreDefinition {
+ merged := make(map[string]*pbsDatastoreDefinition)
+
+ add := func(ds pbsDatastore, source string) {
+ name := strings.TrimSpace(ds.Name)
+ if name == "" {
+ return
+ }
+
+ entry := merged[name]
+ if entry == nil {
+ entry = &pbsDatastoreDefinition{Name: name}
+ merged[name] = entry
+ }
+
+ entry.Sources = append(entry.Sources, source)
+
+ if entry.Path == "" && strings.TrimSpace(ds.Path) != "" {
+ entry.Path = strings.TrimSpace(ds.Path)
+ }
+ if entry.Comment == "" && strings.TrimSpace(ds.Comment) != "" {
+ entry.Comment = strings.TrimSpace(ds.Comment)
+ }
+ }
+
+ for _, ds := range config {
+ add(ds, "datastore.cfg")
+ }
+ for _, ds := range cli {
+ add(ds, "cli")
+ }
+
+ out := make([]pbsDatastoreDefinition, 0, len(merged))
+ for _, v := range merged {
+ if v == nil {
+ continue
+ }
+ v.Sources = uniqueSortedStrings(v.Sources)
+ out = append(out, *v)
+ }
+
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].Name < out[j].Name
+ })
+ return out
+}
+
+func parsePBSDatastoreCfg(contents string) []pbsDatastore {
+ contents = strings.TrimSpace(contents)
+ if contents == "" {
+ return nil
+ }
+
+ var (
+ out []pbsDatastore
+ current *pbsDatastore
+ )
+
+ flush := func() {
+ if current == nil {
+ return
+ }
+ if strings.TrimSpace(current.Name) == "" {
+ current = nil
+ return
+ }
+ out = append(out, *current)
+ current = nil
+ }
+
+ scanner := bufio.NewScanner(strings.NewReader(contents))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ if strings.HasPrefix(line, "datastore:") {
+ flush()
+ name := strings.TrimSpace(strings.TrimPrefix(line, "datastore:"))
+ if name == "" {
+ continue
+ }
+ current = &pbsDatastore{Name: name}
+ continue
+ }
+
+ if current == nil {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) == 0 {
+ continue
+ }
+ key := fields[0]
+ rest := strings.TrimSpace(line[len(key):])
+
+ switch key {
+ case "path":
+ current.Path = strings.TrimSpace(rest)
+ case "comment":
+ current.Comment = strings.TrimSpace(rest)
+ }
+ }
+ flush()
+
+ return out
+}
+
+func (c *Collector) inspectPBSDatastorePathMarkers(path string) pbsDatastorePathMarkers {
+ markers := pbsDatastorePathMarkers{}
+ if path == "" {
+ return markers
+ }
+
+ statAny := func(rel string) bool {
+ _, err := os.Stat(filepath.Join(path, rel))
+ return err == nil
+ }
+
+ markers.HasChunks = statAny(".chunks")
+ markers.HasLock = statAny(".lock")
+ markers.HasGCStatus = statAny(".gc-status")
+ markers.HasVMDir = statAny("vm")
+ markers.HasCTDir = statAny("ct")
+
+ return markers
+}
+
+func sha256Hex(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func uniqueSortedStrings(in []string) []string {
+ if len(in) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(in))
+ out := make([]string, 0, len(in))
+ for _, raw := range in {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ if _, ok := seen[raw]; ok {
+ continue
+ }
+ seen[raw] = struct{}{}
+ out = append(out, raw)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func referencedFileKey(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return "ref_empty"
+ }
+ sum := sha256.Sum256([]byte(path))
+ return fmt.Sprintf("ref_%s_%s", sanitizeFilename(path), hex.EncodeToString(sum[:4]))
+}
+
+func extractCrypttabKeyFiles(content string) []string {
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return nil
+ }
+
+ var out []string
+ scanner := bufio.NewScanner(strings.NewReader(content))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+ keyFile := strings.TrimSpace(fields[2])
+ if keyFile == "" || keyFile == "none" || keyFile == "-" {
+ continue
+ }
+ if strings.HasPrefix(keyFile, "/") {
+ out = append(out, keyFile)
+ }
+ }
+ return uniqueSortedStrings(out)
+}
+
+func extractFstabReferencedFiles(content string) []string {
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return nil
+ }
+
+ keys := map[string]struct{}{
+ "credentials": {},
+ "cred": {},
+ "passwd": {},
+ "passfile": {},
+ "keyfile": {},
+ "identityfile": {},
+ }
+
+ var out []string
+ scanner := bufio.NewScanner(strings.NewReader(content))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 4 {
+ continue
+ }
+
+ opts := fields[3]
+ for _, opt := range strings.Split(opts, ",") {
+ opt = strings.TrimSpace(opt)
+ if opt == "" || !strings.Contains(opt, "=") {
+ continue
+ }
+ parts := strings.SplitN(opt, "=", 2)
+ key := strings.ToLower(strings.TrimSpace(parts[0]))
+ val := strings.TrimSpace(parts[1])
+ if key == "" || val == "" {
+ continue
+ }
+ if _, ok := keys[key]; !ok {
+ continue
+ }
+ if strings.HasPrefix(val, "/") {
+ out = append(out, val)
+ }
+ }
+ }
+ return uniqueSortedStrings(out)
+}
+
+func (c *Collector) safeCopySystemdMountUnitFiles(ctx context.Context) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ base := c.systemPath("/etc/systemd/system")
+ info, err := os.Stat(base)
+ if err != nil {
+ return nil
+ }
+ if !info.IsDir() {
+ return nil
+ }
+
+ destBase := filepath.Join(c.tempDir, "etc/systemd/system")
+ if c.shouldExclude(base) || c.shouldExclude(destBase) {
+ c.incFilesSkipped()
+ return nil
+ }
+
+ if c.dryRun {
+ return nil
+ }
+ if err := c.ensureDir(destBase); err != nil {
+ return err
+ }
+
+ return filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
+ if errCtx := ctx.Err(); errCtx != nil {
+ return errCtx
+ }
+ if err != nil {
+ return err
+ }
+ if info == nil || info.IsDir() {
+ return nil
+ }
+ name := strings.ToLower(info.Name())
+ if !strings.HasSuffix(name, ".mount") && !strings.HasSuffix(name, ".automount") {
+ return nil
+ }
+ rel, err := filepath.Rel(base, path)
+ if err != nil {
+ return err
+ }
+ dest := filepath.Join(destBase, rel)
+ if c.shouldExclude(path) || c.shouldExclude(dest) {
+ return nil
+ }
+ return c.safeCopyFile(ctx, path, dest, "systemd mount unit")
+ })
+}
+
+func (c *Collector) safeCopyAutofsMapFiles(ctx context.Context) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ for _, path := range []string{
+ "/etc/auto.master",
+ "/etc/autofs.conf",
+ } {
+ src := c.systemPath(path)
+ dest := filepath.Join(c.tempDir, strings.TrimPrefix(path, "/"))
+ if err := c.safeCopyFile(ctx, src, dest, "autofs config"); err != nil {
+ // Non-critical; safeCopyFile already counts failures when appropriate.
+ continue
+ }
+ }
+
+ // /etc/auto.* maps (e.g. /etc/auto.nfs, /etc/auto.cifs)
+ glob := c.systemPath("/etc/auto.*")
+ matches, _ := filepath.Glob(glob)
+ for _, src := range matches {
+ base := filepath.Base(src)
+ if base == "auto.master" {
+ continue
+ }
+ rel := filepath.Join("etc", base)
+ dest := filepath.Join(c.tempDir, rel)
+ _ = c.safeCopyFile(ctx, src, dest, "autofs map")
+ }
+
+ // /etc/auto.master.d (drop-in directory)
+ _ = c.safeCopyDir(ctx, c.systemPath("/etc/auto.master.d"), filepath.Join(c.tempDir, "etc/auto.master.d"), "autofs drop-in configs")
+ return nil
+}
+
+func (c *Collector) captureInventoryDirFiltered(ctx context.Context, sourcePath, logicalPath string, include func(rel string, info os.FileInfo) bool) inventoryDirSnapshot {
+ snap := inventoryDirSnapshot{
+ LogicalPath: logicalPath,
+ SourcePath: sourcePath,
+ }
+
+ if c.shouldExclude(sourcePath) {
+ snap.Skipped = true
+ snap.Reason = "excluded by pattern"
+ return snap
+ }
+
+ info, err := os.Stat(sourcePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return snap
+ }
+ snap.Error = err.Error()
+ return snap
+ }
+ if !info.IsDir() {
+ snap.Exists = true
+ snap.Error = "not a directory"
+ return snap
+ }
+ snap.Exists = true
+
+ var files []inventoryDirEntry
+ walkErr := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
+ if errCtx := ctx.Err(); errCtx != nil {
+ return errCtx
+ }
+ if err != nil {
+ return err
+ }
+ if info == nil || info.IsDir() {
+ return nil
+ }
+
+ rel, err := filepath.Rel(sourcePath, path)
+ if err != nil {
+ return err
+ }
+ if include != nil && !include(rel, info) {
+ return nil
+ }
+
+ entry := inventoryDirEntry{
+ RelativePath: rel,
+ SizeBytes: info.Size(),
+ }
+
+ if info.Mode()&os.ModeSymlink != 0 {
+ entry.IsSymlink = true
+ if target, err := os.Readlink(path); err == nil {
+ entry.SymlinkTarget = target
+ } else {
+ entry.Error = err.Error()
+ }
+ files = append(files, entry)
+ return nil
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ entry.Error = err.Error()
+ } else {
+ entry.SHA256 = sha256Hex(data)
+ }
+
+ files = append(files, entry)
+ return nil
+ })
+ if walkErr != nil {
+ snap.Error = walkErr.Error()
+ }
+
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].RelativePath < files[j].RelativePath
+ })
+ snap.Files = files
+ return snap
+}
diff --git a/internal/backup/collector_pbs_datastore_inventory_test.go b/internal/backup/collector_pbs_datastore_inventory_test.go
new file mode 100644
index 0000000..ca11128
--- /dev/null
+++ b/internal/backup/collector_pbs_datastore_inventory_test.go
@@ -0,0 +1,297 @@
+package backup
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/tis24dev/proxsave/internal/types"
+)
+
+func TestCollectPBSDatastoreInventoryOfflineFromDatastoreCfg(t *testing.T) {
+ root := t.TempDir()
+
+ cfg := GetDefaultCollectorConfig()
+ cfg.SystemRootPrefix = root
+
+ pbsCfgDir := filepath.Join(root, "etc", "proxmox-backup")
+ if err := os.MkdirAll(pbsCfgDir, 0o755); err != nil {
+ t.Fatalf("mkdir pbs cfg dir: %v", err)
+ }
+
+ datastoreCfg := `datastore: Data1
+ path /mnt/datastore/Data1
+ comment local
+
+datastore: Synology-Archive
+ path /mnt/Synology_NFS/PBS_Backup
+`
+ if err := os.WriteFile(filepath.Join(pbsCfgDir, "datastore.cfg"), []byte(datastoreCfg), 0o640); err != nil {
+ t.Fatalf("write datastore.cfg: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil {
+ t.Fatalf("mkdir etc: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "fstab"), []byte("UUID=1 / ext4 defaults 0 1\n//server/share /mnt/cifs cifs credentials=/etc/cifs-creds 0 0\nsshfs#example:/ /mnt/ssh fuse.sshfs defaults,_netdev,IdentityFile=/root/.ssh/id_rsa 0 0\n"), 0o644); err != nil {
+ t.Fatalf("write fstab: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "crypttab"), []byte("crypt1 UUID=deadbeef /etc/keys/crypt1.key luks\n"), 0o600); err != nil {
+ t.Fatalf("write crypttab: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "etc", "keys"), 0o755); err != nil {
+ t.Fatalf("mkdir /etc/keys: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "keys", "crypt1.key"), []byte("keydata\n"), 0o600); err != nil {
+ t.Fatalf("write crypt keyfile: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "cifs-creds"), []byte("username=alice\npassword=secret\n"), 0o600); err != nil {
+ t.Fatalf("write cifs creds: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "root", ".ssh"), 0o700); err != nil {
+ t.Fatalf("mkdir /root/.ssh: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "root", ".ssh", "id_rsa"), []byte("PRIVATEKEY\n"), 0o600); err != nil {
+ t.Fatalf("write ssh identity file: %v", err)
+ }
+
+ // iSCSI + multipath config data (secrets included in the backing files).
+ nodesFile := filepath.Join(root, "etc", "iscsi", "nodes", "iqn.2026-01.test:target1", "127.0.0.1,3260,1", "default")
+ if err := os.MkdirAll(filepath.Dir(nodesFile), 0o755); err != nil {
+ t.Fatalf("mkdir iscsi nodes: %v", err)
+ }
+ if err := os.WriteFile(nodesFile, []byte("node.session.auth.password = secret\n"), 0o600); err != nil {
+ t.Fatalf("write iscsi node file: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "etc", "multipath"), 0o755); err != nil {
+ t.Fatalf("mkdir multipath: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "multipath", "bindings"), []byte("mpatha 3600...\n"), 0o600); err != nil {
+ t.Fatalf("write bindings: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "multipath", "wwids"), []byte("3600...\n"), 0o600); err != nil {
+ t.Fatalf("write wwids: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "var", "lib", "iscsi"), 0o755); err != nil {
+ t.Fatalf("mkdir var/lib/iscsi: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "var", "lib", "iscsi", "example.txt"), []byte("state\n"), 0o600); err != nil {
+ t.Fatalf("write var/lib/iscsi example: %v", err)
+ }
+
+ // systemd mount units + autofs maps (additional mount sources)
+ unitPath := filepath.Join(root, "etc", "systemd", "system", "mnt-synology_nfs-pbs_backup.mount")
+ if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil {
+ t.Fatalf("mkdir systemd dir: %v", err)
+ }
+ if err := os.WriteFile(unitPath, []byte("[Mount]\nWhat=server:/export\nWhere=/mnt/Synology_NFS/PBS_Backup\nType=nfs\n"), 0o644); err != nil {
+ t.Fatalf("write mount unit: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "auto.master"), []byte("/- /etc/auto.pbs\n"), 0o644); err != nil {
+ t.Fatalf("write auto.master: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "auto.pbs"), []byte("/mnt/autofs -fstype=nfs4 server:/export\n"), 0o644); err != nil {
+ t.Fatalf("write auto.pbs: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(root, "etc", "lvm", "backup"), 0o755); err != nil {
+ t.Fatalf("mkdir lvm backup: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "lvm", "backup", "vg0"), []byte("contents\n"), 0o600); err != nil {
+ t.Fatalf("write lvm backup: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "etc", "zfs"), 0o755); err != nil {
+ t.Fatalf("mkdir zfs: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "zfs", "zpool.cache"), []byte("cache\n"), 0o600); err != nil {
+ t.Fatalf("write zpool cache: %v", err)
+ }
+
+ for _, dsPath := range []string{
+ filepath.Join(root, "mnt", "datastore", "Data1"),
+ filepath.Join(root, "mnt", "Synology_NFS", "PBS_Backup"),
+ } {
+ if err := os.MkdirAll(filepath.Join(dsPath, ".chunks"), 0o750); err != nil {
+ t.Fatalf("mkdir chunks: %v", err)
+ }
+ if err := os.MkdirAll(filepath.Join(dsPath, "vm"), 0o750); err != nil {
+ t.Fatalf("mkdir vm: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dsPath, ".lock"), []byte(""), 0o640); err != nil {
+ t.Fatalf("write lock: %v", err)
+ }
+ }
+
+ collector := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false)
+ if err := collector.collectPBSDatastoreInventory(context.Background(), nil); err != nil {
+ t.Fatalf("collectPBSDatastoreInventory error: %v", err)
+ }
+
+ reportPath := filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "pbs", "pbs_datastore_inventory.json")
+ raw, err := os.ReadFile(reportPath)
+ if err != nil {
+ t.Fatalf("read report: %v", err)
+ }
+
+ var report pbsDatastoreInventoryReport
+ if err := json.Unmarshal(raw, &report); err != nil {
+ t.Fatalf("unmarshal report: %v", err)
+ }
+
+ if report.HostCommands {
+ t.Fatalf("expected host_commands=false in offline mode")
+ }
+
+ if snap, ok := report.Files["pbs_datastore_cfg"]; !ok || !snap.Exists || snap.Content == "" {
+ t.Fatalf("expected datastore cfg snapshot, got: %+v", snap)
+ }
+ if snap, ok := report.Files["crypttab"]; !ok || !snap.Exists || snap.Content == "" {
+ t.Fatalf("expected crypttab snapshot, got: %+v", snap)
+ }
+ if snap, ok := report.Files["multipath_bindings"]; !ok || !snap.Exists || snap.Content == "" {
+ t.Fatalf("expected multipath bindings snapshot, got: %+v", snap)
+ }
+ if dir, ok := report.Dirs["iscsi_etc"]; !ok || !dir.Exists || len(dir.Files) == 0 {
+ t.Fatalf("expected iscsi dir snapshot, got: %+v", dir)
+ }
+ if dir, ok := report.Dirs["systemd_mount_units"]; !ok || !dir.Exists || len(dir.Files) == 0 {
+ t.Fatalf("expected systemd mount units snapshot, got: %+v", dir)
+ }
+ if snap, ok := report.Files["autofs_master"]; !ok || !snap.Exists || snap.Content == "" {
+ t.Fatalf("expected autofs master snapshot, got: %+v", snap)
+ }
+ if snap, ok := report.Files["zfs_zpool_cache"]; !ok || !snap.Exists || snap.Content == "" {
+ t.Fatalf("expected zpool cache snapshot, got: %+v", snap)
+ }
+ if dir, ok := report.Dirs["lvm_backup"]; !ok || !dir.Exists || len(dir.Files) == 0 {
+ t.Fatalf("expected lvm backup snapshot, got: %+v", dir)
+ }
+
+ // Ensure iSCSI config was copied into the backup tree.
+ copiedNodesFile := filepath.Join(collector.tempDir, "etc", "iscsi", "nodes", "iqn.2026-01.test:target1", "127.0.0.1,3260,1", "default")
+ if _, err := os.Stat(copiedNodesFile); err != nil {
+ t.Fatalf("expected copied iSCSI node file, got %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "etc", "keys", "crypt1.key")); err != nil {
+ t.Fatalf("expected copied crypttab keyfile, got %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "etc", "cifs-creds")); err != nil {
+ t.Fatalf("expected copied fstab credentials file, got %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "root", ".ssh", "id_rsa")); err != nil {
+ t.Fatalf("expected copied ssh identity file, got %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "etc", "systemd", "system", "mnt-synology_nfs-pbs_backup.mount")); err != nil {
+ t.Fatalf("expected copied systemd mount unit file, got %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "etc", "auto.pbs")); err != nil {
+ t.Fatalf("expected copied autofs map file, got %v", err)
+ }
+
+ if len(report.Datastores) != 2 {
+ t.Fatalf("expected 2 datastores, got %d", len(report.Datastores))
+ }
+ foundChunks := 0
+ for _, ds := range report.Datastores {
+ if ds.Name == "" || ds.Path == "" {
+ t.Fatalf("unexpected datastore entry: %+v", ds)
+ }
+ if !ds.PathOK || !ds.PathIsDir {
+ t.Fatalf("expected datastore path to be ok and dir: %+v", ds)
+ }
+ if ds.Markers.HasChunks {
+ foundChunks++
+ }
+ }
+ if foundChunks != 2 {
+ t.Fatalf("expected HasChunks=true for both datastores, got %d", foundChunks)
+ }
+}
+
+func TestCollectPBSDatastoreInventoryCapturesHostCommands(t *testing.T) {
+ pbsRoot := t.TempDir()
+ if err := os.MkdirAll(pbsRoot, 0o755); err != nil {
+ t.Fatalf("mkdir pbsRoot: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(pbsRoot, "datastore.cfg"), []byte("datastore: Data1\npath /mnt/datastore/Data1\n"), 0o640); err != nil {
+ t.Fatalf("write datastore.cfg: %v", err)
+ }
+
+ cfg := GetDefaultCollectorConfig()
+ cfg.PBSConfigPath = pbsRoot
+ cfg.SystemRootPrefix = ""
+ cfg.ExcludePatterns = append(cfg.ExcludePatterns,
+ "**/etc/fstab",
+ "**/etc/crypttab",
+ "**/etc/systemd/**",
+ "**/etc/auto.*",
+ "**/etc/auto.master.d/**",
+ "**/etc/autofs.conf",
+ "**/etc/mdadm/**",
+ "**/etc/lvm/**",
+ "**/etc/zfs/**",
+ "**/etc/iscsi/**",
+ "**/var/lib/iscsi/**",
+ "**/etc/multipath/**",
+ "**/etc/multipath.conf",
+ )
+
+ deps := CollectorDeps{
+ LookPath: func(string) (string, error) { return "/bin/true", nil },
+ RunCommand: func(_ context.Context, name string, args ...string) ([]byte, error) {
+ switch name {
+ case "uname":
+ return []byte("Linux test\n"), nil
+ case "lsblk":
+ return []byte(`{"blockdevices":[]}`), nil
+ case "findmnt":
+ if len(args) >= 2 && args[0] == "-J" && args[1] == "-T" {
+ return []byte(`{"filesystems":[{"target":"/mnt/datastore/Data1","source":"server:/export","fstype":"nfs4"}]}`), nil
+ }
+ return []byte(`{"filesystems":[]}`), nil
+ case "nfsstat":
+ return []byte("nfsstat -m output\n"), nil
+ case "zpool":
+ return []byte("zpool output\n"), nil
+ case "zfs":
+ return []byte("zfs output\n"), nil
+ case "df":
+ return []byte("df output\n"), nil
+ default:
+ return []byte("ok\n"), nil
+ }
+ },
+ }
+
+ collector := NewCollectorWithDeps(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false, deps)
+ cli := []pbsDatastore{{Name: "Data1", Path: "/mnt/datastore/Data1"}}
+ if err := collector.collectPBSDatastoreInventory(context.Background(), cli); err != nil {
+ t.Fatalf("collectPBSDatastoreInventory error: %v", err)
+ }
+
+ reportPath := filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "pbs", "pbs_datastore_inventory.json")
+ raw, err := os.ReadFile(reportPath)
+ if err != nil {
+ t.Fatalf("read report: %v", err)
+ }
+
+ var report pbsDatastoreInventoryReport
+ if err := json.Unmarshal(raw, &report); err != nil {
+ t.Fatalf("unmarshal report: %v", err)
+ }
+
+ if !report.HostCommands {
+ t.Fatalf("expected host_commands=true")
+ }
+ if got := report.Commands["lsblk_json"].Output; got != `{"blockdevices":[]}` {
+ t.Fatalf("unexpected lsblk output: %q", got)
+ }
+ if len(report.Datastores) != 1 {
+ t.Fatalf("expected 1 datastore, got %d", len(report.Datastores))
+ }
+ if got := report.Datastores[0].Findmnt.Output; got == "" {
+ t.Fatalf("expected findmnt output to be captured")
+ }
+}
diff --git a/internal/backup/collector_pbs_extra_test.go b/internal/backup/collector_pbs_extra_test.go
index 93e5f69..2144e42 100644
--- a/internal/backup/collector_pbs_extra_test.go
+++ b/internal/backup/collector_pbs_extra_test.go
@@ -128,7 +128,7 @@ func TestCollectUserTokensAggregates(t *testing.T) {
},
})
- commandsDir := filepath.Join(tmp, "commands")
+ commandsDir := filepath.Join(tmp, "var/lib/proxsave-info", "commands", "pbs")
if err := os.MkdirAll(commandsDir, 0o755); err != nil {
t.Fatalf("mkdir commands: %v", err)
}
@@ -142,7 +142,7 @@ func TestCollectUserTokensAggregates(t *testing.T) {
t.Fatalf("collectUserConfigs error: %v", err)
}
- aggPath := filepath.Join(tmp, "users", "tokens.json")
+ aggPath := filepath.Join(tmp, "var/lib/proxsave-info", "pbs", "access-control", "tokens.json")
if _, err := os.Stat(aggPath); err != nil {
t.Fatalf("expected aggregated tokens.json, got %v", err)
}
@@ -181,8 +181,116 @@ func TestCollectPBSConfigsWithCustomRoot(t *testing.T) {
t.Fatalf("CollectPBSConfigs failed with custom root: %v", err)
}
- commandsDir := filepath.Join(collector.tempDir, "commands")
+ commandsDir := filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "pbs")
if _, err := os.Stat(commandsDir); err != nil {
t.Fatalf("expected commands directory, got err: %v", err)
}
}
+
+func TestCollectPBSConfigsExcludesDisabledPBSConfigFiles(t *testing.T) {
+ root := t.TempDir()
+ mustWrite := func(name, contents string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0o644); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+ }
+
+ mustWrite("dummy.cfg", "ok")
+ mustWrite("datastore.cfg", "datastore")
+ mustWrite("user.cfg", "user")
+ mustWrite("acl.cfg", "acl")
+ mustWrite("domains.cfg", "domains")
+ mustWrite("remote.cfg", "remote")
+ mustWrite("sync.cfg", "sync")
+ mustWrite("verification.cfg", "verify")
+ mustWrite("tape.cfg", "tape")
+ mustWrite("media-pool.cfg", "media")
+ mustWrite("network.cfg", "net")
+ mustWrite("prune.cfg", "prune")
+
+ cfg := GetDefaultCollectorConfig()
+ cfg.PBSConfigPath = root
+ cfg.BackupDatastoreConfigs = false
+ cfg.BackupUserConfigs = false
+ cfg.BackupRemoteConfigs = false
+ cfg.BackupSyncJobs = false
+ cfg.BackupVerificationJobs = false
+ cfg.BackupTapeConfigs = false
+ cfg.BackupPruneSchedules = false
+ cfg.BackupNetworkConfigs = false
+ cfg.BackupPxarFiles = false
+
+ collector := NewCollectorWithDeps(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false, CollectorDeps{
+ LookPath: func(cmd string) (string, error) {
+ return "/usr/bin/" + cmd, nil
+ },
+ RunCommand: func(_ context.Context, name string, args ...string) ([]byte, error) {
+ if name == "proxmox-backup-manager" && len(args) >= 3 && args[0] == "datastore" && args[1] == "list" {
+ return []byte(`[{"name":"store1","path":"/fake"}]`), nil
+ }
+ return []byte("ok"), nil
+ },
+ })
+
+ if err := collector.CollectPBSConfigs(context.Background()); err != nil {
+ t.Fatalf("CollectPBSConfigs failed: %v", err)
+ }
+
+ destDir := filepath.Join(collector.tempDir, "etc", "proxmox-backup")
+
+ if _, err := os.Stat(filepath.Join(destDir, "dummy.cfg")); err != nil {
+ t.Fatalf("expected dummy.cfg collected, got %v", err)
+ }
+
+ for _, excluded := range []string{
+ "datastore.cfg",
+ "user.cfg",
+ "acl.cfg",
+ "domains.cfg",
+ "remote.cfg",
+ "sync.cfg",
+ "verification.cfg",
+ "tape.cfg",
+ "media-pool.cfg",
+ "network.cfg",
+ "prune.cfg",
+ } {
+ _, err := os.Stat(filepath.Join(destDir, excluded))
+ if err == nil {
+ t.Fatalf("expected %s excluded from PBS config snapshot", excluded)
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("stat %s: %v", excluded, err)
+ }
+ }
+
+ // Ensure related command output is also excluded when the feature flag is disabled.
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "pbs", "remote_list.json")); err == nil {
+ t.Fatalf("expected remote_list.json excluded when BACKUP_REMOTE_CONFIGS=false")
+ }
+}
+
+func TestCollectPBSConfigFileReturnsSkippedWhenExcluded(t *testing.T) {
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "remote.cfg"), []byte("remote"), 0o644); err != nil {
+ t.Fatalf("write remote.cfg: %v", err)
+ }
+
+ cfg := GetDefaultCollectorConfig()
+ cfg.PBSConfigPath = root
+ cfg.ExcludePatterns = []string{"remote.cfg"}
+
+ collector := NewCollectorWithDeps(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false, CollectorDeps{})
+
+ entry := collector.collectPBSConfigFile(context.Background(), root, "remote.cfg", "Remote configuration", true)
+ if entry.Status != StatusSkipped {
+ t.Fatalf("expected StatusSkipped, got %s", entry.Status)
+ }
+
+ target := filepath.Join(collector.tempDir, "etc", "proxmox-backup", "remote.cfg")
+ _, err := os.Stat(target)
+ if err == nil || !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("expected %s not to exist, stat err=%v", target, err)
+ }
+}
diff --git a/internal/backup/collector_pbs_test.go b/internal/backup/collector_pbs_test.go
index 572523c..61180ee 100644
--- a/internal/backup/collector_pbs_test.go
+++ b/internal/backup/collector_pbs_test.go
@@ -327,7 +327,7 @@ func TestCollectDatastoreConfigsDryRun(t *testing.T) {
t.Fatalf("collectDatastoreConfigs failed: %v", err)
}
- nsFile := filepath.Join(collector.tempDir, "datastores", "store1_namespaces.json")
+ nsFile := filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "pbs", "datastores", "store1_namespaces.json")
if _, err := os.Stat(nsFile); err != nil {
t.Fatalf("expected namespaces file, got %v", err)
}
@@ -352,7 +352,7 @@ func TestCollectUserConfigsWithTokens(t *testing.T) {
return []byte(`[{"tokenid":"mytoken"}]`), nil
},
})
- commandsDir := filepath.Join(collector.tempDir, "commands")
+ commandsDir := filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "commands", "pbs")
if err := os.MkdirAll(commandsDir, 0o755); err != nil {
t.Fatalf("failed to create commands dir: %v", err)
}
@@ -365,7 +365,7 @@ func TestCollectUserConfigsWithTokens(t *testing.T) {
t.Fatalf("collectUserConfigs failed: %v", err)
}
- tokensPath := filepath.Join(collector.tempDir, "users", "tokens.json")
+ tokensPath := filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "pbs", "access-control", "tokens.json")
data, err := os.ReadFile(tokensPath)
if err != nil {
t.Fatalf("tokens.json not created: %v", err)
@@ -389,7 +389,7 @@ func TestCollectUserConfigsMissingUserList(t *testing.T) {
t.Fatalf("collectUserConfigs failed: %v", err)
}
- tokensPath := filepath.Join(collector.tempDir, "users", "tokens.json")
+ tokensPath := filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "pbs", "access-control", "tokens.json")
if _, err := os.Stat(tokensPath); !os.IsNotExist(err) {
t.Fatalf("expected no tokens.json, got err=%v", err)
}
diff --git a/internal/backup/collector_pve.go b/internal/backup/collector_pve.go
index 2a56e8b..30b448a 100644
--- a/internal/backup/collector_pve.go
+++ b/internal/backup/collector_pve.go
@@ -147,19 +147,164 @@ func (c *Collector) CollectPVEConfigs(ctx context.Context) error {
c.logger.Warning("Failed to create PVE info aliases: %v", err)
}
+ c.populatePVEManifest()
+
c.logger.Info("PVE configuration collection completed")
return nil
}
+func (c *Collector) populatePVEManifest() {
+ if c == nil || c.config == nil {
+ return
+ }
+ if c.pveManifest == nil {
+ c.pveManifest = make(map[string]ManifestEntry)
+ }
+
+ record := func(src string, enabled bool) {
+ if src == "" {
+ return
+ }
+ dest := c.targetPathFor(src)
+ key := pveManifestKey(c.tempDir, dest)
+ c.pveManifest[key] = c.describePathForManifest(src, dest, enabled)
+ }
+
+ pveConfigPath := c.effectivePVEConfigPath()
+ if pveConfigPath == "" {
+ return
+ }
+
+ // VM/CT configuration directories.
+ record(filepath.Join(pveConfigPath, "qemu-server"), c.config.BackupVMConfigs)
+ record(filepath.Join(pveConfigPath, "lxc"), c.config.BackupVMConfigs)
+
+ // Firewall configuration.
+ record(filepath.Join(pveConfigPath, "firewall"), c.config.BackupPVEFirewall)
+ if c.config.BackupPVEFirewall {
+ nodesDir := filepath.Join(pveConfigPath, "nodes")
+ if entries, err := os.ReadDir(nodesDir); err == nil {
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ node := strings.TrimSpace(entry.Name())
+ if node == "" {
+ continue
+ }
+ record(filepath.Join(nodesDir, node, "host.fw"), true)
+ }
+ }
+ }
+
+ // ACL configuration.
+ record(filepath.Join(pveConfigPath, "user.cfg"), c.config.BackupPVEACL)
+ record(filepath.Join(pveConfigPath, "acl.cfg"), c.config.BackupPVEACL)
+ record(filepath.Join(pveConfigPath, "domains.cfg"), c.config.BackupPVEACL)
+
+ // Scheduled jobs.
+ record(filepath.Join(pveConfigPath, "jobs.cfg"), c.config.BackupPVEJobs)
+ record(filepath.Join(pveConfigPath, "vzdump.cron"), c.config.BackupPVEJobs)
+
+ // Cluster configuration.
+ record(c.effectiveCorosyncConfigPath(), c.config.BackupClusterConfig)
+ record(filepath.Join(c.effectivePVEClusterPath(), "config.db"), c.config.BackupClusterConfig)
+ record("/etc/corosync/authkey", c.config.BackupClusterConfig)
+
+ // VZDump configuration.
+ vzdumpPath := c.config.VzdumpConfigPath
+ if vzdumpPath == "" {
+ vzdumpPath = "/etc/vzdump.conf"
+ } else if !filepath.IsAbs(vzdumpPath) {
+ vzdumpPath = filepath.Join(pveConfigPath, vzdumpPath)
+ }
+ record(vzdumpPath, c.config.BackupVZDumpConfig)
+}
+
+func pveManifestKey(tempDir, dest string) string {
+ if tempDir == "" || dest == "" {
+ return filepath.ToSlash(dest)
+ }
+ rel, err := filepath.Rel(tempDir, dest)
+ if err != nil {
+ return filepath.ToSlash(dest)
+ }
+ rel = strings.TrimSpace(rel)
+ if rel == "" || rel == "." {
+ return filepath.ToSlash(dest)
+ }
+ return filepath.ToSlash(rel)
+}
+
+func (c *Collector) describePathForManifest(src, dest string, enabled bool) ManifestEntry {
+ if !enabled {
+ return ManifestEntry{Status: StatusDisabled}
+ }
+ if c.shouldExclude(src) || c.shouldExclude(dest) {
+ return ManifestEntry{Status: StatusSkipped}
+ }
+
+ info, err := os.Lstat(src)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ManifestEntry{Status: StatusNotFound}
+ }
+ return ManifestEntry{Status: StatusFailed, Error: err.Error()}
+ }
+
+ if c.dryRun {
+ if info.Mode().IsRegular() {
+ return ManifestEntry{Status: StatusCollected, Size: info.Size()}
+ }
+ return ManifestEntry{Status: StatusCollected}
+ }
+
+ if _, err := os.Lstat(dest); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ManifestEntry{Status: StatusFailed, Error: "not present in temp directory after collection"}
+ }
+ return ManifestEntry{Status: StatusFailed, Error: err.Error()}
+ }
+
+ if info.Mode().IsRegular() {
+ return ManifestEntry{Status: StatusCollected, Size: info.Size()}
+ }
+ return ManifestEntry{Status: StatusCollected}
+}
+
// collectPVEDirectories collects PVE-specific directories
func (c *Collector) collectPVEDirectories(ctx context.Context, clustered bool) error {
c.logger.Debug("Snapshotting PVE directories (clustered=%v)", clustered)
pveConfigPath := c.effectivePVEConfigPath()
- if err := c.safeCopyDir(ctx,
- pveConfigPath,
- c.targetPathFor(pveConfigPath),
- "PVE configuration"); err != nil {
+ var extraExclude []string
+ if !c.config.BackupVMConfigs {
+ extraExclude = append(extraExclude, "qemu-server", "lxc")
+ }
+ if !c.config.BackupPVEFirewall {
+ // Rules can exist both under /etc/pve/firewall and under /etc/pve/nodes/*.
+ extraExclude = append(extraExclude, "firewall", "host.fw")
+ }
+ if !c.config.BackupPVEACL {
+ extraExclude = append(extraExclude, "user.cfg", "acl.cfg", "domains.cfg")
+ }
+ if !c.config.BackupPVEJobs {
+ extraExclude = append(extraExclude, "jobs.cfg", "vzdump.cron")
+ }
+ if !c.config.BackupClusterConfig {
+ // Keep /etc/pve snapshot but omit cluster-specific config files when disabled.
+ extraExclude = append(extraExclude, "corosync.conf")
+ }
+
+ if len(extraExclude) > 0 {
+ c.logger.Debug("PVE config exclusions enabled (disabled features): %s", strings.Join(extraExclude, ", "))
+ }
+ if err := c.withTemporaryExcludes(extraExclude, func() error {
+ return c.safeCopyDir(ctx,
+ pveConfigPath,
+ c.targetPathFor(pveConfigPath),
+ "PVE configuration")
+ }); err != nil {
return err
}
@@ -203,16 +348,20 @@ func (c *Collector) collectPVEDirectories(ctx context.Context, clustered bool) e
}
}
- // Always attempt to capture config.db even on standalone nodes
- configDB := filepath.Join(clusterPath, "config.db")
- if info, err := os.Stat(configDB); err == nil && !info.IsDir() {
- target := c.targetPathFor(configDB)
- c.logger.Debug("Copying PVE cluster database %s to %s", configDB, target)
- if err := c.safeCopyFile(ctx, configDB, target, "PVE cluster database"); err != nil {
- c.logger.Warning("Failed to copy PVE cluster database %s: %v", configDB, err)
+ if c.config.BackupClusterConfig {
+ // Always attempt to capture config.db even on standalone nodes when cluster config is enabled.
+ configDB := filepath.Join(clusterPath, "config.db")
+ if info, err := os.Stat(configDB); err == nil && !info.IsDir() {
+ target := c.targetPathFor(configDB)
+ c.logger.Debug("Copying PVE cluster database %s to %s", configDB, target)
+ if err := c.safeCopyFile(ctx, configDB, target, "PVE cluster database"); err != nil {
+ c.logger.Warning("Failed to copy PVE cluster database %s: %v", configDB, err)
+ }
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ c.logger.Warning("Failed to stat PVE cluster database %s: %v", configDB, err)
}
- } else if err != nil && !errors.Is(err, os.ErrNotExist) {
- c.logger.Warning("Failed to stat PVE cluster database %s: %v", configDB, err)
+ } else {
+ c.logger.Debug("Skipping PVE cluster database capture: BACKUP_CLUSTER_CONFIG=false")
}
// Firewall configuration
@@ -268,7 +417,7 @@ func (c *Collector) collectPVEDirectories(ctx context.Context, clustered bool) e
// collectPVECommands collects output from PVE commands and returns runtime info
func (c *Collector) collectPVECommands(ctx context.Context, clustered bool) (*pveRuntimeInfo, error) {
- commandsDir := filepath.Join(c.tempDir, "commands")
+ commandsDir := c.proxsaveCommandsDir("pve")
if err := c.ensureDir(commandsDir); err != nil {
return nil, fmt.Errorf("failed to create commands directory: %w", err)
}
@@ -343,6 +492,13 @@ func (c *Collector) collectPVECommands(ctx context.Context, clustered bool) (*pv
filepath.Join(commandsDir, "pve_roles.json"),
"PVE roles",
false)
+
+ // Resource pools (datacenter-wide objects; may be useful for SAFE restore apply).
+ c.safeCmdOutput(ctx,
+ "pveum pool list --output-format=json",
+ filepath.Join(commandsDir, "pools.json"),
+ "PVE resource pools",
+ false)
}
// Cluster commands (if clustered)
@@ -365,6 +521,23 @@ func (c *Collector) collectPVECommands(ctx context.Context, clustered bool) (*pv
filepath.Join(commandsDir, "ha_status.json"),
"HA status",
false)
+
+ // Resource mappings (datacenter-wide objects; used by VM configs via mapping=).
+ c.safeCmdOutput(ctx,
+ "pvesh get /cluster/mapping/pci --output-format=json",
+ filepath.Join(commandsDir, "mapping_pci.json"),
+ "PCI resource mappings",
+ false)
+ c.safeCmdOutput(ctx,
+ "pvesh get /cluster/mapping/usb --output-format=json",
+ filepath.Join(commandsDir, "mapping_usb.json"),
+ "USB resource mappings",
+ false)
+ c.safeCmdOutput(ctx,
+ "pvesh get /cluster/mapping/dir --output-format=json",
+ filepath.Join(commandsDir, "mapping_dir.json"),
+ "Directory resource mappings",
+ false)
} else if clustered && !c.config.BackupClusterConfig {
c.logger.Debug("Skipping cluster runtime commands: BACKUP_CLUSTER_CONFIG=false (clustered=%v)", clustered)
}
@@ -494,7 +667,7 @@ func (c *Collector) collectVMConfigs(ctx context.Context) error {
}
// Collect VMs/CTs list
- commandsDir := filepath.Join(c.tempDir, "commands")
+ commandsDir := c.proxsaveCommandsDir("pve")
hostname, _ := os.Hostname()
nodeName := shortHostname(hostname)
if nodeName == "" {
@@ -1203,23 +1376,23 @@ func (c *Collector) createPVEInfoAliases(ctx context.Context) error {
target string
}{
{
- source: filepath.Join(c.tempDir, "commands", "nodes_status.json"),
+ source: filepath.Join(c.proxsaveCommandsDir("pve"), "nodes_status.json"),
target: filepath.Join(baseInfoDir, "nodes_status.json"),
},
{
- source: filepath.Join(c.tempDir, "commands", "storage_status.json"),
+ source: filepath.Join(c.proxsaveCommandsDir("pve"), "storage_status.json"),
target: filepath.Join(baseInfoDir, "storage_status.json"),
},
{
- source: filepath.Join(c.tempDir, "commands", "pve_users.json"),
+ source: filepath.Join(c.proxsaveCommandsDir("pve"), "pve_users.json"),
target: filepath.Join(baseInfoDir, "user_list.json"),
},
{
- source: filepath.Join(c.tempDir, "commands", "pve_groups.json"),
+ source: filepath.Join(c.proxsaveCommandsDir("pve"), "pve_groups.json"),
target: filepath.Join(baseInfoDir, "group_list.json"),
},
{
- source: filepath.Join(c.tempDir, "commands", "pve_roles.json"),
+ source: filepath.Join(c.proxsaveCommandsDir("pve"), "pve_roles.json"),
target: filepath.Join(baseInfoDir, "role_list.json"),
},
}
diff --git a/internal/backup/collector_pve_test.go b/internal/backup/collector_pve_test.go
index 4d3ea8f..4c0e309 100644
--- a/internal/backup/collector_pve_test.go
+++ b/internal/backup/collector_pve_test.go
@@ -2,6 +2,7 @@ package backup
import (
"context"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -362,6 +363,39 @@ func TestCollectPVEConfigsIntegration(t *testing.T) {
}
}
+func TestCollectPVEConfigsPopulatesManifestSkippedForExcludedACL(t *testing.T) {
+ collector := newPVECollectorWithDeps(t, CollectorDeps{
+ RunCommand: func(context.Context, string, ...string) ([]byte, error) {
+ return []byte("{}"), nil
+ },
+ LookPath: func(cmd string) (string, error) {
+ return "/usr/bin/" + cmd, nil
+ },
+ })
+
+ pveConfigPath := collector.config.PVEConfigPath
+ if err := os.WriteFile(filepath.Join(pveConfigPath, "user.cfg"), []byte("user"), 0o644); err != nil {
+ t.Fatalf("write user.cfg: %v", err)
+ }
+ collector.config.BackupPVEACL = true
+ collector.config.ExcludePatterns = []string{"user.cfg"}
+
+ if err := collector.CollectPVEConfigs(context.Background()); err != nil {
+ t.Fatalf("CollectPVEConfigs failed: %v", err)
+ }
+
+ src := filepath.Join(collector.effectivePVEConfigPath(), "user.cfg")
+ dest := collector.targetPathFor(src)
+ key := pveManifestKey(collector.tempDir, dest)
+ entry, ok := collector.pveManifest[key]
+ if !ok {
+ t.Fatalf("expected manifest entry for %s (key=%s)", src, key)
+ }
+ if entry.Status != StatusSkipped {
+ t.Fatalf("expected %s status, got %s", StatusSkipped, entry.Status)
+ }
+}
+
// Test collectVMConfigs function
func TestCollectVMConfigs(t *testing.T) {
collector := newPVECollector(t)
@@ -697,6 +731,86 @@ func TestCollectPVEReplication(t *testing.T) {
}
}
+func TestCollectPVEDirectoriesExcludesDisabledPVEConfigFiles(t *testing.T) {
+ collector := newPVECollector(t)
+ pveRoot := collector.config.PVEConfigPath
+
+ mustMkdir := func(path string) {
+ t.Helper()
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", path, err)
+ }
+ }
+ mustWrite := func(path, contents string) {
+ t.Helper()
+ dir := filepath.Dir(path)
+ if dir != "" && dir != "." {
+ mustMkdir(dir)
+ }
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+ }
+
+ // Create representative PVE config files and directories that are normally covered by a full /etc/pve snapshot.
+ mustWrite(filepath.Join(pveRoot, "dummy.cfg"), "ok")
+ mustWrite(filepath.Join(pveRoot, "corosync.conf"), "corosync")
+ mustWrite(filepath.Join(pveRoot, "user.cfg"), "user")
+ mustWrite(filepath.Join(pveRoot, "acl.cfg"), "acl")
+ mustWrite(filepath.Join(pveRoot, "domains.cfg"), "domains")
+ mustWrite(filepath.Join(pveRoot, "jobs.cfg"), "jobs")
+ mustWrite(filepath.Join(pveRoot, "vzdump.cron"), "cron")
+ mustWrite(filepath.Join(pveRoot, "qemu-server", "100.conf"), "vm")
+ mustWrite(filepath.Join(pveRoot, "lxc", "101.conf"), "ct")
+ mustWrite(filepath.Join(pveRoot, "firewall", "cluster.fw"), "fw")
+ mustWrite(filepath.Join(pveRoot, "nodes", "node1", "host.fw"), "hostfw")
+
+ clusterPath := filepath.Join(t.TempDir(), "pve-cluster")
+ mustWrite(filepath.Join(clusterPath, "config.db"), "db")
+ collector.config.PVEClusterPath = clusterPath
+
+ collector.config.BackupVMConfigs = false
+ collector.config.BackupPVEFirewall = false
+ collector.config.BackupPVEACL = false
+ collector.config.BackupPVEJobs = false
+ collector.config.BackupClusterConfig = false
+
+ if err := collector.collectPVEDirectories(context.Background(), false); err != nil {
+ t.Fatalf("collectPVEDirectories error: %v", err)
+ }
+
+ destPVE := collector.targetPathFor(pveRoot)
+ if _, err := os.Stat(filepath.Join(destPVE, "dummy.cfg")); err != nil {
+ t.Fatalf("expected dummy.cfg collected, got %v", err)
+ }
+
+ for _, excluded := range []string{
+ "corosync.conf",
+ "user.cfg",
+ "acl.cfg",
+ "domains.cfg",
+ "jobs.cfg",
+ "vzdump.cron",
+ filepath.Join("qemu-server", "100.conf"),
+ filepath.Join("lxc", "101.conf"),
+ filepath.Join("firewall", "cluster.fw"),
+ filepath.Join("nodes", "node1", "host.fw"),
+ } {
+ _, err := os.Stat(filepath.Join(destPVE, excluded))
+ if err == nil {
+ t.Fatalf("expected %s excluded from PVE config snapshot", excluded)
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("stat %s: %v", excluded, err)
+ }
+ }
+
+ destDB := collector.targetPathFor(filepath.Join(clusterPath, "config.db"))
+ if _, err := os.Stat(destDB); err == nil {
+ t.Fatalf("expected config.db excluded when BACKUP_CLUSTER_CONFIG=false")
+ }
+}
+
// Test collectPVEStorageMetadata function
func TestCollectPVEStorageMetadata(t *testing.T) {
collector := newPVECollector(t)
diff --git a/internal/backup/collector_system.go b/internal/backup/collector_system.go
index 09f5b20..4693b4b 100644
--- a/internal/backup/collector_system.go
+++ b/internal/backup/collector_system.go
@@ -513,20 +513,20 @@ func (c *Collector) collectSystemDirectories(ctx context.Context) error {
// DHCP leases (best effort)
if err := c.safeCopyDir(ctx,
c.systemPath("/var/lib/dhcp"),
- filepath.Join(c.tempDir, "var/lib/dhcp"),
- "DHCP leases"); err != nil {
+ c.proxsaveRuntimeDir("var/lib/dhcp"),
+ "DHCP leases (runtime snapshot)"); err != nil {
c.logger.Debug("No /var/lib/dhcp found")
}
if err := c.safeCopyDir(ctx,
c.systemPath("/var/lib/NetworkManager"),
- filepath.Join(c.tempDir, "var/lib/NetworkManager"),
- "NetworkManager leases"); err != nil {
+ c.proxsaveRuntimeDir("var/lib/NetworkManager"),
+ "NetworkManager leases (runtime snapshot)"); err != nil {
c.logger.Debug("No /var/lib/NetworkManager leases found")
}
if err := c.safeCopyDir(ctx,
c.systemPath("/run/systemd/netif/leases"),
- filepath.Join(c.tempDir, "run/systemd/netif/leases"),
- "systemd-networkd leases"); err != nil {
+ c.proxsaveRuntimeDir("run/systemd/netif/leases"),
+ "systemd-networkd leases (runtime snapshot)"); err != nil {
c.logger.Debug("No /run/systemd/netif/leases found")
}
@@ -536,26 +536,19 @@ func (c *Collector) collectSystemDirectories(ctx context.Context) error {
// collectSystemCommands collects output from system commands
func (c *Collector) collectSystemCommands(ctx context.Context) error {
- commandsDir := filepath.Join(c.tempDir, "commands")
+ commandsDir := c.proxsaveCommandsDir("system")
if err := c.ensureDir(commandsDir); err != nil {
return fmt.Errorf("failed to create commands directory: %w", err)
}
c.logger.Debug("Collecting system command outputs into %s", commandsDir)
- infoDir := filepath.Join(c.tempDir, "var/lib/proxsave-info")
- if err := c.ensureDir(infoDir); err != nil {
- return fmt.Errorf("failed to create system info directory: %w", err)
- }
- c.logger.Debug("System info snapshots will be stored in %s", infoDir)
-
// OS release information (CRITICAL)
osReleasePath := c.systemPath("/etc/os-release")
if err := c.collectCommandMulti(ctx,
fmt.Sprintf("cat %s", osReleasePath),
filepath.Join(commandsDir, "os_release.txt"),
"OS release",
- true,
- filepath.Join(infoDir, "os-release.txt")); err != nil {
+ true); err != nil {
return fmt.Errorf("failed to get OS release (critical): %w", err)
}
@@ -564,8 +557,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"uname -a",
filepath.Join(commandsDir, "uname.txt"),
"Kernel version",
- true,
- filepath.Join(infoDir, "uname.txt")); err != nil {
+ true); err != nil {
return fmt.Errorf("failed to get kernel version (critical): %w", err)
}
@@ -581,69 +573,59 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"ip addr show",
filepath.Join(commandsDir, "ip_addr.txt"),
"IP addresses",
- false,
- filepath.Join(infoDir, "ip_addr.txt")); err != nil {
+ false); err != nil {
return err
}
c.collectCommandOptional(ctx,
"ip -j addr show",
filepath.Join(commandsDir, "ip_addr.json"),
- "IP addresses (json)",
- filepath.Join(infoDir, "ip_addr.json"))
+ "IP addresses (json)")
// Policy routing rules
if err := c.collectCommandMulti(ctx,
"ip rule show",
filepath.Join(commandsDir, "ip_rule.txt"),
"IP rules",
- false,
- filepath.Join(infoDir, "ip_rule.txt")); err != nil {
+ false); err != nil {
return err
}
c.collectCommandOptional(ctx,
"ip -j rule show",
filepath.Join(commandsDir, "ip_rule.json"),
- "IP rules (json)",
- filepath.Join(infoDir, "ip_rule.json"))
+ "IP rules (json)")
// IP routes
if err := c.collectCommandMulti(ctx,
"ip route show",
filepath.Join(commandsDir, "ip_route.txt"),
"IP routes",
- false,
- filepath.Join(infoDir, "ip_route.txt")); err != nil {
+ false); err != nil {
return err
}
c.collectCommandOptional(ctx,
"ip -j route show",
filepath.Join(commandsDir, "ip_route.json"),
- "IP routes (json)",
- filepath.Join(infoDir, "ip_route.json"))
+ "IP routes (json)")
// All routing tables (IPv4/IPv6)
c.collectCommandOptional(ctx,
"ip -4 route show table all",
filepath.Join(commandsDir, "ip_route_all_v4.txt"),
- "IP routes (all tables v4)",
- filepath.Join(infoDir, "ip_route_all_v4.txt"))
+ "IP routes (all tables v4)")
c.collectCommandOptional(ctx,
"ip -6 route show table all",
filepath.Join(commandsDir, "ip_route_all_v6.txt"),
- "IP routes (all tables v6)",
- filepath.Join(infoDir, "ip_route_all_v6.txt"))
+ "IP routes (all tables v6)")
// IP link statistics
c.collectCommandOptional(ctx,
"ip -s link",
filepath.Join(commandsDir, "ip_link.txt"),
- "IP link statistics",
- filepath.Join(infoDir, "ip_link.txt"))
+ "IP link statistics")
c.collectCommandOptional(ctx,
"ip -j link",
filepath.Join(commandsDir, "ip_link.json"),
- "IP links (json)",
- filepath.Join(infoDir, "ip_link.json"))
+ "IP links (json)")
// Neighbors (ARP/NDP)
c.safeCmdOutput(ctx,
@@ -675,7 +657,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
filepath.Join(commandsDir, "bridge_mdb.txt"),
"Bridge MDB")
- if err := c.collectNetworkInventory(ctx, commandsDir, infoDir); err != nil {
+ if err := c.collectNetworkInventory(ctx, commandsDir, ""); err != nil {
c.logger.Debug("Network inventory collection failed: %v", err)
}
@@ -708,8 +690,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"df -h",
filepath.Join(commandsDir, "df.txt"),
"Disk usage",
- false,
- filepath.Join(infoDir, "disk_space.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -725,18 +706,28 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"lsblk -f",
filepath.Join(commandsDir, "lsblk.txt"),
"Block devices",
- false,
- filepath.Join(infoDir, "lsblk.txt")); err != nil {
+ false); err != nil {
return err
}
+ // Block devices (JSON) - used for stable device mapping during restore (fstab remap).
+ c.collectCommandOptional(ctx,
+ "lsblk -J -O",
+ filepath.Join(commandsDir, "lsblk_json.json"),
+ "Block devices (JSON)")
+
+ // Block device identifiers (UUID/PARTUUID/LABEL) - used for stable device mapping during restore.
+ c.collectCommandOptional(ctx,
+ "blkid",
+ filepath.Join(commandsDir, "blkid.txt"),
+ "Block device identifiers (blkid)")
+
// Memory information
if err := c.collectCommandMulti(ctx,
"free -h",
filepath.Join(commandsDir, "free.txt"),
"Memory usage",
- false,
- filepath.Join(infoDir, "memory.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -745,8 +736,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"lscpu",
filepath.Join(commandsDir, "lscpu.txt"),
"CPU information",
- false,
- filepath.Join(infoDir, "lscpu.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -755,8 +745,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"lspci -v",
filepath.Join(commandsDir, "lspci.txt"),
"PCI devices",
- false,
- filepath.Join(infoDir, "lspci.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -773,8 +762,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"systemctl list-units --type=service --all",
filepath.Join(commandsDir, "systemctl_services.txt"),
"Systemd services",
- false,
- filepath.Join(infoDir, "services.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -785,17 +773,16 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
// Installed packages
if c.config.BackupInstalledPackages {
- packagesDir := filepath.Join(infoDir, "packages")
+ packagesDir := filepath.Join(commandsDir, "packages")
if err := c.ensureDir(packagesDir); err != nil {
return fmt.Errorf("failed to create packages directory: %w", err)
}
if err := c.collectCommandMulti(ctx,
"dpkg -l",
- filepath.Join(commandsDir, "dpkg_list.txt"),
+ filepath.Join(packagesDir, "dpkg_list.txt"),
"Installed packages",
- false,
- filepath.Join(packagesDir, "dpkg_list.txt")); err != nil {
+ false); err != nil {
return err
}
}
@@ -815,8 +802,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"iptables-save",
filepath.Join(commandsDir, "iptables.txt"),
"iptables rules",
- false,
- filepath.Join(infoDir, "iptables.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -830,8 +816,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
"ip6tables-save",
filepath.Join(commandsDir, "ip6tables.txt"),
"ip6tables rules",
- false,
- filepath.Join(infoDir, "ip6tables.txt")); err != nil {
+ false); err != nil {
return err
}
@@ -899,7 +884,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
if !usesZFS {
c.logger.Warning("Skipping ZFS collection: not detected. Set BACKUP_ZFS_CONFIG=false to disable.")
} else {
- zfsDir := filepath.Join(infoDir, "zfs")
+ zfsDir := filepath.Join(commandsDir, "zfs")
if err := c.ensureDir(zfsDir); err != nil {
return fmt.Errorf("failed to create zfs info directory: %w", err)
}
@@ -907,29 +892,25 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
if _, err := c.depLookPath("zpool"); err == nil {
c.collectCommandOptional(ctx,
"zpool status",
- filepath.Join(commandsDir, "zpool_status.txt"),
- "ZFS pool status",
- filepath.Join(zfsDir, "zpool_status.txt"))
+ filepath.Join(zfsDir, "zpool_status.txt"),
+ "ZFS pool status")
c.collectCommandOptional(ctx,
"zpool list",
- filepath.Join(commandsDir, "zpool_list.txt"),
- "ZFS pool list",
- filepath.Join(zfsDir, "zpool_list.txt"))
+ filepath.Join(zfsDir, "zpool_list.txt"),
+ "ZFS pool list")
}
if _, err := c.depLookPath("zfs"); err == nil {
c.collectCommandOptional(ctx,
"zfs list",
- filepath.Join(commandsDir, "zfs_list.txt"),
- "ZFS filesystem list",
- filepath.Join(zfsDir, "zfs_list.txt"))
+ filepath.Join(zfsDir, "zfs_list.txt"),
+ "ZFS filesystem list")
c.collectCommandOptional(ctx,
"zfs get all",
- filepath.Join(commandsDir, "zfs_get_all.txt"),
- "ZFS properties",
filepath.Join(zfsDir, "zfs_get_all.txt"),
+ "ZFS properties",
)
}
}
@@ -957,7 +938,7 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
}
c.logger.Debug("System command output collection finished")
- if err := c.buildNetworkReport(ctx, commandsDir, infoDir); err != nil {
+ if err := c.buildNetworkReport(ctx, commandsDir); err != nil {
c.logger.Debug("Network report generation failed: %v", err)
}
return nil
@@ -965,13 +946,12 @@ func (c *Collector) collectSystemCommands(ctx context.Context) error {
// buildNetworkReport composes a single human-readable network report by aggregating
// key command outputs and configuration files.
-func (c *Collector) buildNetworkReport(ctx context.Context, commandsDir, infoDir string) error {
+func (c *Collector) buildNetworkReport(ctx context.Context, commandsDir string) error {
if err := ctx.Err(); err != nil {
return err
}
reportPath := filepath.Join(commandsDir, "network_report.txt")
- mirrorPath := filepath.Join(infoDir, "network_report.txt")
var b strings.Builder
now := time.Now().Format(time.RFC3339)
@@ -1075,11 +1055,6 @@ func (c *Collector) buildNetworkReport(ctx context.Context, commandsDir, infoDir
if err := c.writeReportFile(reportPath, reportData); err != nil {
return err
}
- if mirrorPath != "" {
- if err := c.writeReportFile(mirrorPath, reportData); err != nil {
- return err
- }
- }
return nil
}
@@ -1118,7 +1093,7 @@ func ensureSystemPath() {
// collectKernelInfo collects kernel-specific information
func (c *Collector) collectKernelInfo(ctx context.Context) error {
- commandsDir := filepath.Join(c.tempDir, "commands")
+ commandsDir := c.proxsaveCommandsDir("system")
c.logger.Debug("Collecting kernel information into %s", commandsDir)
// Kernel command line
@@ -1141,7 +1116,7 @@ func (c *Collector) collectKernelInfo(ctx context.Context) error {
// collectHardwareInfo collects hardware information
func (c *Collector) collectHardwareInfo(ctx context.Context) error {
- commandsDir := filepath.Join(c.tempDir, "commands")
+ commandsDir := c.proxsaveCommandsDir("system")
c.logger.Debug("Collecting hardware inventory into %s", commandsDir)
// DMI decode (requires root)
@@ -1178,6 +1153,7 @@ func (c *Collector) collectCriticalFiles(ctx context.Context) error {
c.logger.Debug("Collecting critical files (passwd/shadow/fstab/etc.)")
criticalFiles := []string{
"/etc/fstab",
+ "/etc/crypttab",
"/etc/passwd",
"/etc/group",
"/etc/shadow",
@@ -1347,7 +1323,7 @@ func (c *Collector) collectScriptRepository(ctx context.Context) error {
return nil
}
- target := filepath.Join(c.tempDir, "script-repository", filepath.Base(base))
+ target := c.proxsaveInfoDir("script-repository", filepath.Base(base))
c.logger.Debug("Collecting script repository from %s", base)
if err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
@@ -1440,8 +1416,13 @@ func (c *Collector) collectRootHome(ctx context.Context) error {
}
// Only copy security-critical directories; custom paths must be configured explicitly.
- if err := c.safeCopyDir(ctx, c.systemPath("/root/.ssh"), filepath.Join(target, ".ssh"), "root SSH directory"); err != nil && !errors.Is(err, os.ErrNotExist) {
- c.logger.Debug("Failed to copy root SSH directory: %v", err)
+ // Respect BACKUP_SSH_KEYS to allow backing up /root without including SSH keys.
+ if c.config.BackupSSHKeys {
+ if err := c.safeCopyDir(ctx, c.systemPath("/root/.ssh"), filepath.Join(target, ".ssh"), "root SSH directory"); err != nil && !errors.Is(err, os.ErrNotExist) {
+ c.logger.Debug("Failed to copy root SSH directory: %v", err)
+ }
+ } else {
+ c.logger.Debug("Skipping /root/.ssh in root home: BACKUP_SSH_KEYS=false")
}
// Copy full root .config directory (for CLI tools, editors, and other configs)
@@ -1481,7 +1462,7 @@ func (c *Collector) collectUserHomes(ctx context.Context) error {
continue
}
src := filepath.Join(c.systemPath("/home"), name)
- dest := filepath.Join(c.tempDir, "users", name)
+ dest := filepath.Join(c.tempDir, "home", name)
info, err := entry.Info()
if err != nil {
@@ -1489,7 +1470,13 @@ func (c *Collector) collectUserHomes(ctx context.Context) error {
}
if info.IsDir() {
- if err := c.safeCopyDir(ctx, src, dest, fmt.Sprintf("home directory for %s", name)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ extraExclude := []string(nil)
+ if !c.config.BackupSSHKeys {
+ extraExclude = append(extraExclude, ".ssh")
+ }
+ if err := c.withTemporaryExcludes(extraExclude, func() error {
+ return c.safeCopyDir(ctx, src, dest, fmt.Sprintf("home directory for %s", name))
+ }); err != nil && !errors.Is(err, os.ErrNotExist) {
c.logger.Debug("Failed to copy home for %s: %v", name, err)
}
continue
diff --git a/internal/backup/collector_system_test.go b/internal/backup/collector_system_test.go
index f420c8e..78df5f3 100644
--- a/internal/backup/collector_system_test.go
+++ b/internal/backup/collector_system_test.go
@@ -106,6 +106,32 @@ func TestCollectCustomPathsHonorsContext(t *testing.T) {
}
}
+func TestCollectCriticalFilesIncludesCrypttab(t *testing.T) {
+ collector := newTestCollector(t)
+ root := t.TempDir()
+ collector.config.SystemRootPrefix = root
+
+ if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil {
+ t.Fatalf("mkdir etc: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "etc", "crypttab"), []byte("crypt1 UUID=deadbeef none luks\n"), 0o600); err != nil {
+ t.Fatalf("write crypttab: %v", err)
+ }
+
+ if err := collector.collectCriticalFiles(context.Background()); err != nil {
+ t.Fatalf("collectCriticalFiles error: %v", err)
+ }
+
+ dest := filepath.Join(collector.tempDir, "etc", "crypttab")
+ data, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatalf("expected crypttab copied, got %v", err)
+ }
+ if string(data) != "crypt1 UUID=deadbeef none luks\n" {
+ t.Fatalf("crypttab content mismatch: %q", string(data))
+ }
+}
+
func TestCollectSSHKeysCopiesEtcSSH(t *testing.T) {
collector := newTestCollector(t)
@@ -135,6 +161,64 @@ func TestCollectSSHKeysCopiesEtcSSH(t *testing.T) {
}
}
+func TestCollectRootHomeSkipsSSHKeysWhenDisabled(t *testing.T) {
+ collector := newTestCollector(t)
+
+ root := t.TempDir()
+ collector.config.SystemRootPrefix = root
+ collector.config.BackupSSHKeys = false
+
+ sshDir := filepath.Join(root, "root", ".ssh")
+ if err := os.MkdirAll(sshDir, 0o755); err != nil {
+ t.Fatalf("mkdir /root/.ssh: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("key"), 0o600); err != nil {
+ t.Fatalf("write id_rsa: %v", err)
+ }
+
+ if err := collector.collectRootHome(context.Background()); err != nil {
+ t.Fatalf("collectRootHome failed: %v", err)
+ }
+
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "root", ".ssh")); err == nil {
+ t.Fatalf("expected /root/.ssh excluded when BACKUP_SSH_KEYS=false")
+ } else if !os.IsNotExist(err) {
+ t.Fatalf("stat /root/.ssh: %v", err)
+ }
+}
+
+func TestCollectUserHomesSkipsSSHKeysWhenDisabled(t *testing.T) {
+ collector := newTestCollector(t)
+
+ root := t.TempDir()
+ collector.config.SystemRootPrefix = root
+ collector.config.BackupSSHKeys = false
+
+ userHome := filepath.Join(root, "home", "alice")
+ if err := os.MkdirAll(filepath.Join(userHome, ".ssh"), 0o755); err != nil {
+ t.Fatalf("mkdir alice .ssh: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(userHome, ".ssh", "id_rsa"), []byte("key"), 0o600); err != nil {
+ t.Fatalf("write alice id_rsa: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(userHome, "note.txt"), []byte("note"), 0o644); err != nil {
+ t.Fatalf("write note.txt: %v", err)
+ }
+
+ if err := collector.collectUserHomes(context.Background()); err != nil {
+ t.Fatalf("collectUserHomes failed: %v", err)
+ }
+
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "home", "alice", "note.txt")); err != nil {
+ t.Fatalf("expected note.txt copied: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(collector.tempDir, "home", "alice", ".ssh")); err == nil {
+ t.Fatalf("expected alice .ssh excluded when BACKUP_SSH_KEYS=false")
+ } else if !os.IsNotExist(err) {
+ t.Fatalf("stat alice .ssh: %v", err)
+ }
+}
+
func TestWriteReportFileCreatesDirectories(t *testing.T) {
collector := newTestCollector(t)
report := filepath.Join(collector.tempDir, "reports", "test", "report.txt")
@@ -283,9 +367,9 @@ func TestCollectSystemDirectoriesCopiesAltNetConfigsAndLeases(t *testing.T) {
filepath.Join(collector.tempDir, "etc", "netplan", "01-netcfg.yaml"),
filepath.Join(collector.tempDir, "etc", "systemd", "network", "10-eth0.network"),
filepath.Join(collector.tempDir, "etc", "NetworkManager", "system-connections", "conn.nmconnection"),
- filepath.Join(collector.tempDir, "var", "lib", "dhcp", "lease.test"),
- filepath.Join(collector.tempDir, "var", "lib", "NetworkManager", "lease.test"),
- filepath.Join(collector.tempDir, "run", "systemd", "netif", "leases", "lease.test"),
+ filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "runtime", "var", "lib", "dhcp", "lease.test"),
+ filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "runtime", "var", "lib", "NetworkManager", "lease.test"),
+ filepath.Join(collector.tempDir, "var", "lib", "proxsave-info", "runtime", "run", "systemd", "netif", "leases", "lease.test"),
}
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
@@ -314,12 +398,9 @@ func TestBuildNetworkReportAggregatesOutputs(t *testing.T) {
t.Fatalf("failed to write resolv.conf: %v", err)
}
- commandsDir := filepath.Join(collector.tempDir, "commands")
- infoDir := filepath.Join(collector.tempDir, "var/lib/proxsave-info")
- for _, dir := range []string{commandsDir, infoDir} {
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatalf("failed to create dir %s: %v", dir, err)
- }
+ commandsDir := filepath.Join(collector.tempDir, "var/lib/proxsave-info", "commands", "system")
+ if err := os.MkdirAll(commandsDir, 0o755); err != nil {
+ t.Fatalf("failed to create dir %s: %v", commandsDir, err)
}
writeCmd := func(name, content string) {
@@ -341,7 +422,7 @@ func TestBuildNetworkReportAggregatesOutputs(t *testing.T) {
t.Fatalf("failed to write bonding status: %v", err)
}
- if err := collector.buildNetworkReport(context.Background(), commandsDir, infoDir); err != nil {
+ if err := collector.buildNetworkReport(context.Background(), commandsDir); err != nil {
t.Fatalf("buildNetworkReport failed: %v", err)
}
@@ -357,10 +438,7 @@ func TestBuildNetworkReportAggregatesOutputs(t *testing.T) {
}
}
- mirror := filepath.Join(infoDir, "network_report.txt")
- if _, err := os.Stat(mirror); err != nil {
- t.Fatalf("expected mirrored report at %s: %v", mirror, err)
- }
+ // Report is written only to the primary directory (no secondary mirror).
}
func newTestCollector(t *testing.T) *Collector {
diff --git a/internal/backup/collector_test.go b/internal/backup/collector_test.go
index 603dd9b..372393d 100644
--- a/internal/backup/collector_test.go
+++ b/internal/backup/collector_test.go
@@ -410,7 +410,7 @@ func TestCollectSystemInfo(t *testing.T) {
}
// Verify commands directory was created (new implementation)
- commandsDir := filepath.Join(tempDir, "commands")
+ commandsDir := filepath.Join(tempDir, "var/lib/proxsave-info", "commands", "system")
if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
t.Error("commands directory was not created")
}
diff --git a/internal/cli/args.go b/internal/cli/args.go
index 2b58e69..33222bb 100644
--- a/internal/cli/args.go
+++ b/internal/cli/args.go
@@ -39,6 +39,7 @@ type Args struct {
UpgradeConfigDry bool
EnvMigration bool
EnvMigrationDry bool
+ CleanupGuards bool
LegacyEnvPath string
}
@@ -99,6 +100,8 @@ func Parse() *Args {
"Run the installer and migrate a legacy Bash backup.env to the Go template")
flag.BoolVar(&args.EnvMigrationDry, "env-migration-dry-run", false,
"Preview the installer + legacy env migration without writing files")
+ flag.BoolVar(&args.CleanupGuards, "cleanup-guards", false,
+ "Cleanup ProxSave guard bind mounts and directories (/var/lib/proxsave/guards). Use with --dry-run to preview")
flag.StringVar(&args.LegacyEnvPath, "old-env", "",
"Path to the legacy Bash backup.env used during --env-migration")
diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go
index 09fe82b..882f6b5 100644
--- a/internal/cli/args_test.go
+++ b/internal/cli/args_test.go
@@ -88,7 +88,7 @@ func TestParseDefaults(t *testing.T) {
}
if args.DryRun || args.ShowVersion || args.ShowHelp || args.ForceNewKey || args.Decrypt ||
args.Restore || args.Install || args.NewInstall || args.EnvMigration || args.EnvMigrationDry || args.UpgradeConfig ||
- args.UpgradeConfigDry {
+ args.UpgradeConfigDry || args.CleanupGuards {
t.Fatal("all boolean flags should default to false")
}
}
@@ -108,6 +108,7 @@ func TestParseCustomFlags(t *testing.T) {
"--new-install",
"--env-migration",
"--env-migration-dry-run",
+ "--cleanup-guards",
"--upgrade-config",
"--upgrade-config-dry-run",
"--old-env", "/legacy.env",
@@ -125,7 +126,7 @@ func TestParseCustomFlags(t *testing.T) {
if !args.DryRun || !args.Support || !args.ShowVersion || !args.ShowHelp ||
!args.ForceNewKey || !args.Decrypt || !args.Restore || !args.Install || !args.NewInstall ||
!args.EnvMigration || !args.EnvMigrationDry || !args.UpgradeConfig ||
- !args.UpgradeConfigDry {
+ !args.UpgradeConfigDry || !args.CleanupGuards {
t.Fatal("expected boolean flags to be set")
}
if args.LegacyEnvPath != "/legacy.env" {
diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env
index 17cef09..1981b03 100644
--- a/internal/config/templates/backup.env
+++ b/internal/config/templates/backup.env
@@ -160,7 +160,7 @@ CLOUD_WRITE_HEALTHCHECK=false # false = auto (list + fallback to write te
# ----------------------------------------------------------------------
# CONNECTION timeout: remote accessibility check (short)
# OPERATION timeout: full upload/download operations (long)
-# NOTE: The connection timeout is also used by restore/decrypt workflows when scanning cloud backups.
+# NOTE: Restore/decrypt cloud scan uses the connection timeout per rclone command (lsf/cat); the timer resets per step.
RCLONE_TIMEOUT_CONNECTION=30 # seconds
RCLONE_TIMEOUT_OPERATION=300 # seconds
RCLONE_BANDWIDTH_LIMIT="10M" # e.g. "10M" for 10 MB/s, empty = unlimited
diff --git a/internal/input/input.go b/internal/input/input.go
index 500e43e..15c87b1 100644
--- a/internal/input/input.go
+++ b/internal/input/input.go
@@ -42,7 +42,7 @@ func MapInputError(err error) error {
}
// ReadLineWithContext reads a single line and supports cancellation. On ctx cancellation
-// or stdin closure it returns ErrInputAborted.
+// or stdin closure it returns ErrInputAborted. On ctx deadline it returns context.DeadlineExceeded.
func ReadLineWithContext(ctx context.Context, reader *bufio.Reader) (string, error) {
if ctx == nil {
ctx = context.Background()
@@ -58,6 +58,9 @@ func ReadLineWithContext(ctx context.Context, reader *bufio.Reader) (string, err
}()
select {
case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ return "", context.DeadlineExceeded
+ }
return "", ErrInputAborted
case res := <-ch:
return res.line, res.err
@@ -65,7 +68,8 @@ func ReadLineWithContext(ctx context.Context, reader *bufio.Reader) (string, err
}
// ReadPasswordWithContext reads a password (no echo) and supports cancellation. On ctx
-// cancellation or stdin closure it returns ErrInputAborted.
+// cancellation or stdin closure it returns ErrInputAborted. On ctx deadline it returns
+// context.DeadlineExceeded.
func ReadPasswordWithContext(ctx context.Context, readPassword func(int) ([]byte, error), fd int) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
@@ -84,6 +88,9 @@ func ReadPasswordWithContext(ctx context.Context, readPassword func(int) ([]byte
}()
select {
case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ return nil, context.DeadlineExceeded
+ }
return nil, ErrInputAborted
case res := <-ch:
return res.b, res.err
diff --git a/internal/input/input_test.go b/internal/input/input_test.go
index cad4217..4113024 100644
--- a/internal/input/input_test.go
+++ b/internal/input/input_test.go
@@ -105,6 +105,34 @@ func TestReadLineWithContext_CancelledReturnsAborted(t *testing.T) {
_ = pw.Close()
}
+func TestReadLineWithContext_DeadlineReturnsDeadlineExceeded(t *testing.T) {
+ pr, pw := io.Pipe()
+ defer pr.Close()
+ defer pw.Close()
+
+ reader := bufio.NewReader(pr)
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ done := make(chan struct{})
+ var err error
+ go func() {
+ defer close(done)
+ _, err = ReadLineWithContext(ctx, reader)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(500 * time.Millisecond):
+ t.Fatalf("ReadLineWithContext did not return after deadline")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("err=%v; want %v", err, context.DeadlineExceeded)
+ }
+
+ _ = pw.Close()
+}
+
func TestReadPasswordWithContext_NilReadPasswordErrors(t *testing.T) {
_, err := ReadPasswordWithContext(context.Background(), nil, 0)
if err == nil {
@@ -160,3 +188,23 @@ func TestReadPasswordWithContext_CancelledReturnsAborted(t *testing.T) {
t.Fatalf("err=%v; want %v", err, ErrInputAborted)
}
}
+
+func TestReadPasswordWithContext_DeadlineReturnsDeadlineExceeded(t *testing.T) {
+ unblock := make(chan struct{})
+ readPassword := func(fd int) ([]byte, error) {
+ <-unblock
+ return []byte("secret"), nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ got, err := ReadPasswordWithContext(ctx, readPassword, 0)
+ close(unblock)
+ if got != nil {
+ t.Fatalf("expected nil bytes on deadline")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("err=%v; want %v", err, context.DeadlineExceeded)
+ }
+}
diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock
index 2a493a8..4c24bc1 100644
--- a/internal/orchestrator/.backup.lock
+++ b/internal/orchestrator/.backup.lock
@@ -1,3 +1,3 @@
-pid=2367035
+pid=568974
host=pve
-time=2026-01-21T19:42:55+01:00
+time=2026-01-31T07:18:45+01:00
diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go
index 76a22ba..8b20d73 100644
--- a/internal/orchestrator/additional_helpers_test.go
+++ b/internal/orchestrator/additional_helpers_test.go
@@ -746,6 +746,32 @@ func TestWriteBackupMetadata(t *testing.T) {
}
}
+func TestWriteBackupMetadataSkipsWhenExcluded(t *testing.T) {
+ logger := logging.New(types.LogLevelError, false)
+ o := &Orchestrator{
+ logger: logger,
+ excludePatterns: []string{"var/lib/proxsave-info/**"},
+ }
+ tempDir := t.TempDir()
+
+ stats := &BackupStats{
+ Version: "1.0.0",
+ ProxmoxType: types.ProxmoxVE,
+ Timestamp: time.Now(),
+ Hostname: "host1",
+ }
+
+ if err := o.writeBackupMetadata(tempDir, stats); err != nil {
+ t.Fatalf("writeBackupMetadata error: %v", err)
+ }
+
+ metaPath := filepath.Join(tempDir, "var/lib/proxsave-info/backup_metadata.txt")
+ _, err := os.Stat(metaPath)
+ if err == nil || !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("expected metadata file to be excluded, stat err=%v", err)
+ }
+}
+
func TestNewTempDirRegistryRejectsEmptyPath(t *testing.T) {
_, err := NewTempDirRegistry(logging.New(types.LogLevelError, false), "")
if err == nil {
@@ -1086,6 +1112,7 @@ func makeRegistryEntriesStale(t *testing.T, registryPath string) {
func TestCopyFileUsesProvidedFS(t *testing.T) {
fs := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fs.Root) })
src := "src/config.txt"
dest := "dest/clone.txt"
if err := fs.AddFile(src, []byte("payload")); err != nil {
diff --git a/internal/orchestrator/backup_config.go b/internal/orchestrator/backup_config.go
index adfed39..5b04797 100644
--- a/internal/orchestrator/backup_config.go
+++ b/internal/orchestrator/backup_config.go
@@ -19,6 +19,7 @@ func BuildArchiverConfig(
dryRun bool,
encryptArchive bool,
ageRecipients []age.Recipient,
+ excludePatterns []string,
) *backup.ArchiverConfig {
return &backup.ArchiverConfig{
Compression: compressionType,
@@ -28,6 +29,7 @@ func BuildArchiverConfig(
DryRun: dryRun,
EncryptArchive: encryptArchive,
AgeRecipients: ageRecipients,
+ ExcludePatterns: append([]string(nil), excludePatterns...),
}
}
diff --git a/internal/orchestrator/backup_config_test.go b/internal/orchestrator/backup_config_test.go
index daef17d..4f5d533 100644
--- a/internal/orchestrator/backup_config_test.go
+++ b/internal/orchestrator/backup_config_test.go
@@ -17,7 +17,8 @@ func TestBuildArchiverConfig(t *testing.T) {
t.Fatalf("NewScryptRecipient: %v", err)
}
recipients := []age.Recipient{recipient}
- cfg := BuildArchiverConfig(types.CompressionZstd, 3, 4, "fast", true, true, recipients)
+ exclude := []string{"commands/**", "/etc/ssh/**"}
+ cfg := BuildArchiverConfig(types.CompressionZstd, 3, 4, "fast", true, true, recipients, exclude)
expected := &backup.ArchiverConfig{
Compression: types.CompressionZstd,
@@ -27,6 +28,7 @@ func TestBuildArchiverConfig(t *testing.T) {
DryRun: true,
EncryptArchive: true,
AgeRecipients: recipients,
+ ExcludePatterns: exclude,
}
if !reflect.DeepEqual(cfg, expected) {
diff --git a/internal/orchestrator/backup_safety.go b/internal/orchestrator/backup_safety.go
index 26ca252..5402897 100644
--- a/internal/orchestrator/backup_safety.go
+++ b/internal/orchestrator/backup_safety.go
@@ -111,6 +111,49 @@ func createSafetyBackup(logger *logging.Logger, selectedCategories []Category, d
for _, catPath := range pathsToBackup {
fsPath := strings.TrimPrefix(catPath, "./")
+ if strings.ContainsAny(fsPath, "*?[") {
+ pattern := filepath.Join(destRoot, fsPath)
+ matches, err := globFS(safetyFS, pattern)
+ if err != nil {
+ logger.Warning("Cannot expand glob %s: %v", pattern, err)
+ continue
+ }
+ for _, match := range matches {
+ info, err := safetyFS.Stat(match)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ logger.Warning("Cannot stat %s: %v", match, err)
+ continue
+ }
+
+ relPath, err := filepath.Rel(destRoot, match)
+ if err != nil {
+ logger.Warning("Cannot compute relative path for %s: %v", match, err)
+ continue
+ }
+ relPath = filepath.Clean(relPath)
+ if relPath == "." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) || relPath == ".." {
+ logger.Warning("Skipping glob match %s: relative path escapes root (%s)", match, relPath)
+ continue
+ }
+
+ if info.IsDir() {
+ err = backupDirectory(tarWriter, match, relPath, result, logger)
+ if err != nil {
+ logger.Warning("Failed to backup directory %s: %v", match, err)
+ }
+ } else {
+ err = backupFile(tarWriter, match, relPath, result, logger)
+ if err != nil {
+ logger.Warning("Failed to backup file %s: %v", match, err)
+ }
+ }
+ }
+ continue
+ }
+
fullPath := filepath.Join(destRoot, fsPath)
info, err := safetyFS.Stat(fullPath)
@@ -176,6 +219,45 @@ func CreateNetworkRollbackBackup(logger *logging.Logger, selectedCategories []Ca
})
}
+func CreateFirewallRollbackBackup(logger *logging.Logger, selectedCategories []Category, destRoot string) (*SafetyBackupResult, error) {
+ firewallCat := GetCategoryByID("pve_firewall", selectedCategories)
+ if firewallCat == nil {
+ return nil, nil
+ }
+ return createSafetyBackup(logger, []Category{*firewallCat}, destRoot, safetyBackupSpec{
+ ArchivePrefix: "firewall_rollback_backup",
+ LocationFileName: "firewall_rollback_backup_location.txt",
+ HumanDescription: "Firewall rollback backup",
+ WriteLocationFile: true,
+ })
+}
+
+func CreateHARollbackBackup(logger *logging.Logger, selectedCategories []Category, destRoot string) (*SafetyBackupResult, error) {
+ haCat := GetCategoryByID("pve_ha", selectedCategories)
+ if haCat == nil {
+ return nil, nil
+ }
+ return createSafetyBackup(logger, []Category{*haCat}, destRoot, safetyBackupSpec{
+ ArchivePrefix: "ha_rollback_backup",
+ LocationFileName: "ha_rollback_backup_location.txt",
+ HumanDescription: "HA rollback backup",
+ WriteLocationFile: true,
+ })
+}
+
+func CreatePVEAccessControlRollbackBackup(logger *logging.Logger, selectedCategories []Category, destRoot string) (*SafetyBackupResult, error) {
+ acCat := GetCategoryByID("pve_access_control", selectedCategories)
+ if acCat == nil {
+ return nil, nil
+ }
+ return createSafetyBackup(logger, []Category{*acCat}, destRoot, safetyBackupSpec{
+ ArchivePrefix: "pve_access_control_rollback_backup",
+ LocationFileName: "pve_access_control_rollback_backup_location.txt",
+ HumanDescription: "PVE access control rollback backup",
+ WriteLocationFile: true,
+ })
+}
+
// backupFile adds a single file to the tar archive
func backupFile(tw *tar.Writer, sourcePath, archivePath string, result *SafetyBackupResult, logger *logging.Logger) error {
file, err := safetyFS.Open(sourcePath)
@@ -465,6 +547,88 @@ func CleanupOldSafetyBackups(logger *logging.Logger, olderThan time.Duration) er
return nil
}
+func globFS(fs FS, pattern string) ([]string, error) {
+ pattern = strings.TrimSpace(pattern)
+ if pattern == "" {
+ return nil, nil
+ }
+
+ clean := filepath.Clean(pattern)
+ sep := string(os.PathSeparator)
+ abs := filepath.IsAbs(clean)
+
+ parts := strings.Split(clean, sep)
+ if abs && len(parts) > 0 && parts[0] == "" {
+ parts = parts[1:]
+ }
+
+ paths := []string{""}
+ if abs {
+ paths = []string{sep}
+ }
+
+ for i, part := range parts {
+ part = strings.TrimSpace(part)
+ if part == "" || part == "." {
+ continue
+ }
+ isLast := i == len(parts)-1
+
+ var next []string
+ for _, base := range paths {
+ dir := base
+ if dir == "" {
+ dir = "."
+ }
+
+ if strings.ContainsAny(part, "*?[") {
+ entries, err := fs.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return nil, err
+ }
+ for _, entry := range entries {
+ if entry == nil {
+ continue
+ }
+ name := strings.TrimSpace(entry.Name())
+ if name == "" {
+ continue
+ }
+ ok, err := filepath.Match(part, name)
+ if err != nil || !ok {
+ continue
+ }
+ candidate := filepath.Join(dir, name)
+ if !isLast && !entry.IsDir() {
+ continue
+ }
+ next = append(next, candidate)
+ }
+ continue
+ }
+
+ candidate := filepath.Join(dir, part)
+ if _, err := fs.Stat(candidate); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return nil, err
+ }
+ next = append(next, candidate)
+ }
+
+ paths = next
+ if len(paths) == 0 {
+ break
+ }
+ }
+
+ return paths, nil
+}
+
// walkFS recursively walks a filesystem using the provided FS implementation.
func walkFS(fs FS, root string, fn func(path string, info os.FileInfo, err error) error) error {
info, err := fs.Stat(root)
diff --git a/internal/orchestrator/backup_safety_glob_test.go b/internal/orchestrator/backup_safety_glob_test.go
new file mode 100644
index 0000000..dfdcc48
--- /dev/null
+++ b/internal/orchestrator/backup_safety_glob_test.go
@@ -0,0 +1,75 @@
+package orchestrator
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestCreateSafetyBackup_ExpandsGlobPaths(t *testing.T) {
+ origFS := safetyFS
+ origNow := safetyNow
+ t.Cleanup(func() {
+ safetyFS = origFS
+ safetyNow = origNow
+ })
+
+ fake := NewFakeFS()
+ safetyFS = fake
+ safetyNow = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
+
+ if err := fake.AddFile("/etc/auto.master", []byte("a\n")); err != nil {
+ t.Fatalf("add file: %v", err)
+ }
+ if err := fake.AddFile("/etc/auto.foo", []byte("b\n")); err != nil {
+ t.Fatalf("add file: %v", err)
+ }
+
+ cats := []Category{{
+ ID: "test_glob",
+ Name: "Test glob",
+ Paths: []string{"./etc/auto.*"},
+ }}
+
+ res, err := CreateSafetyBackup(newTestLogger(), cats, "/")
+ if err != nil {
+ t.Fatalf("CreateSafetyBackup error: %v", err)
+ }
+ if res == nil || res.BackupPath == "" {
+ t.Fatalf("expected backup result with path")
+ }
+
+ f, err := safetyFS.Open(res.BackupPath)
+ if err != nil {
+ t.Fatalf("open backup: %v", err)
+ }
+ defer f.Close()
+
+ gzReader, err := gzip.NewReader(f)
+ if err != nil {
+ t.Fatalf("gzip reader: %v", err)
+ }
+ defer gzReader.Close()
+
+ tr := tar.NewReader(gzReader)
+ seen := map[string]bool{}
+ for {
+ h, err := tr.Next()
+ if err != nil {
+ break
+ }
+ seen[h.Name] = true
+ }
+
+ if !seen["etc/auto.master"] {
+ t.Fatalf("missing etc/auto.master in archive: seen=%v", seen)
+ }
+ if !seen["etc/auto.foo"] {
+ t.Fatalf("missing etc/auto.foo in archive: seen=%v", seen)
+ }
+ if _, err := os.Stat(fake.onDisk(res.BackupPath)); err != nil {
+ t.Fatalf("expected backup file to exist on disk: %v", err)
+ }
+}
diff --git a/internal/orchestrator/backup_safety_test.go b/internal/orchestrator/backup_safety_test.go
index 553103d..80c8cf5 100644
--- a/internal/orchestrator/backup_safety_test.go
+++ b/internal/orchestrator/backup_safety_test.go
@@ -435,6 +435,7 @@ func TestCleanupOldSafetyBackups(t *testing.T) {
func TestCreateSafetyBackupArchivesSelectedPaths(t *testing.T) {
fake := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fake.Root) })
origFS := safetyFS
safetyFS = fake
t.Cleanup(func() { safetyFS = origFS })
@@ -720,6 +721,7 @@ func TestWalkFS_CallbackError(t *testing.T) {
func TestWalkFS_StatError(t *testing.T) {
mock := newMockFS()
+ t.Cleanup(func() { _ = os.RemoveAll(mock.Root) })
nonExistentPath := "/nonexistent/path"
var callbackCalled bool
@@ -1646,6 +1648,7 @@ func TestBackupFile_SpecialModes(t *testing.T) {
func TestCreateSafetyBackup_NonExistentPaths(t *testing.T) {
fake := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fake.Root) })
origFS := safetyFS
safetyFS = fake
t.Cleanup(func() { safetyFS = origFS })
@@ -1680,6 +1683,7 @@ func TestCreateSafetyBackup_NonExistentPaths(t *testing.T) {
func TestCreateSafetyBackup_MixedExistentNonExistent(t *testing.T) {
fake := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fake.Root) })
origFS := safetyFS
safetyFS = fake
t.Cleanup(func() { safetyFS = origFS })
@@ -1714,6 +1718,7 @@ func TestCreateSafetyBackup_MixedExistentNonExistent(t *testing.T) {
func TestCreateSafetyBackup_StatError(t *testing.T) {
mock := newMockFS()
+ t.Cleanup(func() { _ = os.RemoveAll(mock.Root) })
origFS := safetyFS
safetyFS = mock
t.Cleanup(func() { safetyFS = origFS })
diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go
index 8642ae3..22a44f6 100644
--- a/internal/orchestrator/backup_sources.go
+++ b/internal/orchestrator/backup_sources.go
@@ -2,6 +2,7 @@ package orchestrator
import (
"context"
+ "errors"
"fmt"
"os/exec"
"path"
@@ -84,10 +85,16 @@ func buildDecryptPathOptions(cfg *config.Config, logger *logging.Logger) (option
// discoverRcloneBackups lists backup candidates from an rclone remote and returns
// decrypt candidates backed by that remote (bundles and raw archives).
-func discoverRcloneBackups(ctx context.Context, remotePath string, logger *logging.Logger) (candidates []*decryptCandidate, err error) {
+func discoverRcloneBackups(ctx context.Context, cfg *config.Config, remotePath string, logger *logging.Logger, report ProgressReporter) (candidates []*decryptCandidate, err error) {
done := logging.DebugStart(logger, "discover rclone backups", "remote=%s", remotePath)
defer func() { done(err) }()
start := time.Now()
+
+ timeout := 30 * time.Second
+ if cfg != nil && cfg.RcloneTimeoutConnection > 0 {
+ timeout = time.Duration(cfg.RcloneTimeoutConnection) * time.Second
+ }
+ logging.DebugStep(logger, "discover rclone backups", "per_command_timeout=%s", timeout)
// Build full remote path - ensure it ends with ":" if it's just a remote name
fullPath := strings.TrimSpace(remotePath)
if !strings.Contains(fullPath, ":") {
@@ -98,12 +105,20 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi
logging.DebugStep(logger, "discover rclone backups", "filters=bundle.tar and raw .metadata")
logDebug(logger, "Cloud (rclone): listing backups under %s", fullPath)
logDebug(logger, "Cloud (rclone): executing: rclone lsf %s", fullPath)
+ if report != nil {
+ report(fmt.Sprintf("Listing cloud path: %s", fullPath))
+ }
// Use rclone lsf to list files inside the backup directory
- cmd := exec.CommandContext(ctx, "rclone", "lsf", fullPath)
+ lsfCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ cmd := exec.CommandContext(lsfCtx, "rclone", "lsf", fullPath)
lsfStart := time.Now()
output, err := cmd.CombinedOutput()
if err != nil {
+ if errors.Is(lsfCtx.Err(), context.DeadlineExceeded) {
+ return nil, fmt.Errorf("timed out while listing rclone remote %s (timeout=%s). Increase RCLONE_TIMEOUT_CONNECTION if needed: %w (output: %s)", fullPath, timeout, err, strings.TrimSpace(string(output)))
+ }
return nil, fmt.Errorf("failed to list rclone remote %s: %w (output: %s)", fullPath, err, string(output))
}
logging.DebugStep(logger, "discover rclone backups", "rclone lsf output bytes=%d elapsed=%s", len(output), time.Since(lsfStart))
@@ -140,36 +155,25 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi
return remoteFile + rel
}
+ type inspectItem struct {
+ kind decryptSourceType
+ filename string
+ remoteBundle string
+ remoteArchive string
+ remoteMetadata string
+ remoteChecksum string
+ }
+
+ items := make([]inspectItem, 0)
for _, filename := range ordered {
- // Only process bundle files (both plain and age-encrypted)
- // Valid patterns:
- // - *.tar.{gz|xz|zst}.bundle.tar (plain bundle)
- // - *.tar.{gz|xz|zst}.age.bundle.tar (age-encrypted bundle)
switch {
case strings.HasSuffix(filename, ".bundle.tar"):
- remoteFile := joinRemote(fullPath, filename)
- manifest, err := inspectRcloneBundleManifest(ctx, remoteFile, logger)
- if err != nil {
- manifestErrors++
- logWarning(logger, "Skipping rclone bundle %s: %v", filename, err)
- continue
- }
-
- displayBase := filepath.Base(manifest.ArchivePath)
- if strings.TrimSpace(displayBase) == "" {
- displayBase = filepath.Base(filename)
- }
- candidates = append(candidates, &decryptCandidate{
- Manifest: manifest,
- Source: sourceBundle,
- BundlePath: remoteFile,
- DisplayBase: displayBase,
- IsRclone: true,
+ items = append(items, inspectItem{
+ kind: sourceBundle,
+ filename: filename,
+ remoteBundle: joinRemote(fullPath, filename),
})
- logDebug(logger, "Cloud (rclone): accepted backup bundle: %s", filename)
-
case strings.HasSuffix(filename, ".metadata"):
- // Raw backups: archive + .metadata (+ optional .sha256).
archiveName := strings.TrimSuffix(filename, ".metadata")
if !strings.Contains(archiveName, ".tar") {
nonCandidateEntries++
@@ -186,28 +190,84 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi
if _, ok := snapshot[archiveName+".sha256"]; ok {
remoteChecksum = joinRemote(fullPath, archiveName+".sha256")
}
+ items = append(items, inspectItem{
+ kind: sourceRaw,
+ filename: filename,
+ remoteArchive: remoteArchive,
+ remoteMetadata: remoteMetadata,
+ remoteChecksum: remoteChecksum,
+ })
+ default:
+ nonCandidateEntries++
+ }
+ }
+
+ if report != nil {
+ report(fmt.Sprintf("Inspecting %d candidate(s)...", len(items)))
+ }
+
+ for idx, item := range items {
+ if report != nil {
+ report(fmt.Sprintf("Inspecting %d/%d: %s", idx+1, len(items), item.filename))
+ }
+
+ itemCtx, cancel := context.WithTimeout(ctx, timeout)
+ switch item.kind {
+ case sourceBundle:
+ manifest, perr := inspectRcloneBundleManifest(itemCtx, item.remoteBundle, logger)
+ cancel()
+ if perr != nil {
+ if errors.Is(perr, context.DeadlineExceeded) {
+ return nil, fmt.Errorf("timed out while inspecting %s (timeout=%s). Increase RCLONE_TIMEOUT_CONNECTION if needed: %w", item.filename, timeout, perr)
+ }
+ if errors.Is(perr, context.Canceled) {
+ return nil, perr
+ }
+ manifestErrors++
+ logWarning(logger, "Skipping rclone bundle %s: %v", item.filename, perr)
+ continue
+ }
- manifest, err := inspectRcloneMetadataManifest(ctx, remoteMetadata, remoteArchive, logger)
- if err != nil {
+ displayBase := filepath.Base(manifest.ArchivePath)
+ if strings.TrimSpace(displayBase) == "" {
+ displayBase = filepath.Base(item.filename)
+ }
+ candidates = append(candidates, &decryptCandidate{
+ Manifest: manifest,
+ Source: sourceBundle,
+ BundlePath: item.remoteBundle,
+ DisplayBase: displayBase,
+ IsRclone: true,
+ })
+ logDebug(logger, "Cloud (rclone): accepted backup bundle: %s", item.filename)
+
+ case sourceRaw:
+ manifest, perr := inspectRcloneMetadataManifest(itemCtx, item.remoteMetadata, item.remoteArchive, logger)
+ cancel()
+ if perr != nil {
+ if errors.Is(perr, context.DeadlineExceeded) {
+ return nil, fmt.Errorf("timed out while inspecting %s (timeout=%s). Increase RCLONE_TIMEOUT_CONNECTION if needed: %w", item.filename, timeout, perr)
+ }
+ if errors.Is(perr, context.Canceled) {
+ return nil, perr
+ }
manifestErrors++
- logWarning(logger, "Skipping rclone metadata %s: %v", filename, err)
+ logWarning(logger, "Skipping rclone metadata %s: %v", item.filename, perr)
continue
}
displayBase := filepath.Base(manifest.ArchivePath)
if strings.TrimSpace(displayBase) == "" {
- displayBase = filepath.Base(archiveName)
+ displayBase = filepath.Base(baseNameFromRemoteRef(item.remoteArchive))
}
candidates = append(candidates, &decryptCandidate{
Manifest: manifest,
Source: sourceRaw,
- RawArchivePath: remoteArchive,
- RawMetadataPath: remoteMetadata,
- RawChecksumPath: remoteChecksum,
+ RawArchivePath: item.remoteArchive,
+ RawMetadataPath: item.remoteMetadata,
+ RawChecksumPath: item.remoteChecksum,
DisplayBase: displayBase,
IsRclone: true,
})
- default:
- nonCandidateEntries++
}
}
@@ -240,6 +300,14 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi
logDebug(logger, "Cloud (rclone): scanned %d entries, found %d valid backup candidate(s)", len(lines), len(candidates))
logDebug(logger, "Cloud (rclone): discovered %d bundle candidate(s) in %s", len(candidates), fullPath)
+ if manifestErrors > 0 {
+ if len(candidates) > 0 {
+ logWarning(logger, "Cloud scan summary: %d usable backup(s), %d candidate(s) skipped due to manifest/metadata errors (see warnings above)", len(candidates), manifestErrors)
+ } else if len(items) > 0 {
+ return nil, fmt.Errorf("no usable cloud backups found under %s: %d candidate(s) skipped due to manifest/metadata read errors (timeout=%s). This can happen with slow remotes, rclone failures, or older bundle layouts where metadata is not stored at the beginning. Consider creating a fresh backup or increasing RCLONE_TIMEOUT_CONNECTION; see warnings above for details", fullPath, manifestErrors, timeout)
+ }
+ }
+
return candidates, nil
}
diff --git a/internal/orchestrator/backup_sources_test.go b/internal/orchestrator/backup_sources_test.go
index 96ac581..30b9f0d 100644
--- a/internal/orchestrator/backup_sources_test.go
+++ b/internal/orchestrator/backup_sources_test.go
@@ -207,7 +207,7 @@ func TestDiscoverRcloneBackups_ListsAndParsesBundles(t *testing.T) {
manifest, cleanup := setupFakeRcloneListAndCat(t)
defer cleanup()
- candidates, err := discoverRcloneBackups(ctx, "gdrive:pbs-backups/server1", logger)
+ candidates, err := discoverRcloneBackups(ctx, nil, "gdrive:pbs-backups/server1", logger, nil)
if err != nil {
t.Fatalf("discoverRcloneBackups() error = %v", err)
}
@@ -278,7 +278,7 @@ esac
defer os.Unsetenv("METADATA_PATH")
ctx := context.Background()
- candidates, err := discoverRcloneBackups(ctx, "gdrive:pbs-backups/server1", nil)
+ candidates, err := discoverRcloneBackups(ctx, nil, "gdrive:pbs-backups/server1", nil, nil)
if err != nil {
t.Fatalf("discoverRcloneBackups() error = %v", err)
}
@@ -407,7 +407,7 @@ esac
_ = os.WriteFile(rawNewestArchive, []byte("x"), 0o600)
_ = os.WriteFile(rawOldArchive, []byte("x"), 0o600)
- candidates, err := discoverRcloneBackups(context.Background(), "gdrive:backups", nil)
+ candidates, err := discoverRcloneBackups(context.Background(), nil, "gdrive:backups", nil, nil)
if err != nil {
t.Fatalf("discoverRcloneBackups error: %v", err)
}
@@ -433,7 +433,7 @@ func TestDiscoverRcloneBackups_AllowsNilLogger(t *testing.T) {
manifest, cleanup := setupFakeRcloneListAndCat(t)
defer cleanup()
- candidates, err := discoverRcloneBackups(ctx, "gdrive:pbs-backups/server1", nil)
+ candidates, err := discoverRcloneBackups(ctx, nil, "gdrive:pbs-backups/server1", nil, nil)
if err != nil {
t.Fatalf("discoverRcloneBackups() error = %v", err)
}
diff --git a/internal/orchestrator/categories.go b/internal/orchestrator/categories.go
index cf9e34d..053bb83 100644
--- a/internal/orchestrator/categories.go
+++ b/internal/orchestrator/categories.go
@@ -1,6 +1,8 @@
package orchestrator
import (
+ "path"
+ "path/filepath"
"strings"
)
@@ -62,22 +64,78 @@ func GetAllCategories() []Category {
{
ID: "storage_pve",
Name: "PVE Storage Configuration",
- Description: "Storage definitions and backup job configurations",
+ Description: "Storage definitions (applied via API) and VZDump configuration",
Type: CategoryTypePVE,
Paths: []string{
+ "./etc/pve/storage.cfg",
+ "./etc/pve/datacenter.cfg",
"./etc/vzdump.conf",
},
},
{
ID: "pve_jobs",
Name: "PVE Backup Jobs",
- Description: "Scheduled backup job definitions",
+ Description: "Scheduled backup job definitions (applied via API)",
Type: CategoryTypePVE,
Paths: []string{
"./etc/pve/jobs.cfg",
"./etc/pve/vzdump.cron",
},
},
+ {
+ ID: "pve_notifications",
+ Name: "PVE Notifications",
+ Description: "Datacenter notification targets and matchers (applied via API)",
+ Type: CategoryTypePVE,
+ Paths: []string{
+ "./etc/pve/notifications.cfg",
+ "./etc/pve/priv/notifications.cfg",
+ },
+ },
+ {
+ ID: "pve_access_control",
+ Name: "PVE Access Control",
+ Description: "Users, roles, groups, ACLs and realms (applied via API)",
+ Type: CategoryTypePVE,
+ Paths: []string{
+ "./etc/pve/user.cfg",
+ "./etc/pve/domains.cfg",
+ "./etc/pve/priv/shadow.cfg",
+ "./etc/pve/priv/token.cfg",
+ "./etc/pve/priv/tfa.cfg",
+ },
+ },
+ {
+ ID: "pve_firewall",
+ Name: "PVE Firewall",
+ Description: "Firewall rules and options (staged; applied with rollback safety)",
+ Type: CategoryTypePVE,
+ Paths: []string{
+ "./etc/pve/firewall/",
+ "./etc/pve/nodes/*/host.fw",
+ },
+ },
+ {
+ ID: "pve_ha",
+ Name: "PVE High Availability (HA)",
+ Description: "HA resources/groups/rules (staged; applied with rollback safety)",
+ Type: CategoryTypePVE,
+ Paths: []string{
+ "./etc/pve/ha/resources.cfg",
+ "./etc/pve/ha/groups.cfg",
+ "./etc/pve/ha/rules.cfg",
+ },
+ },
+ {
+ ID: "pve_sdn",
+ Name: "PVE SDN",
+ Description: "Software-defined networking configuration (staged; applied to pmxcfs)",
+ Type: CategoryTypePVE,
+ Paths: []string{
+ "./etc/pve/sdn/",
+ "./etc/pve/sdn.cfg",
+ },
+ },
{
ID: "corosync",
Name: "Corosync Configuration",
@@ -108,13 +166,28 @@ func GetAllCategories() []Category {
},
ExportOnly: true,
},
+ {
+ ID: "pbs_host",
+ Name: "PBS Host & Integrations",
+ Description: "Node settings, ACME configuration, proxy, external metric servers and traffic control rules",
+ Type: CategoryTypePBS,
+ Paths: []string{
+ "./etc/proxmox-backup/node.cfg",
+ "./etc/proxmox-backup/proxy.cfg",
+ "./etc/proxmox-backup/acme/accounts.cfg",
+ "./etc/proxmox-backup/acme/plugins.cfg",
+ "./etc/proxmox-backup/metricserver.cfg",
+ "./etc/proxmox-backup/traffic-control.cfg",
+ },
+ },
{
ID: "datastore_pbs",
Name: "PBS Datastore Configuration",
- Description: "Datastore definitions and settings",
+ Description: "Datastore definitions and settings (including S3 endpoint definitions)",
Type: CategoryTypePBS,
Paths: []string{
"./etc/proxmox-backup/datastore.cfg",
+ "./etc/proxmox-backup/s3.cfg",
},
},
{
@@ -137,6 +210,52 @@ func GetAllCategories() []Category {
"./etc/proxmox-backup/prune.cfg",
},
},
+ {
+ ID: "pbs_remotes",
+ Name: "PBS Remotes",
+ Description: "Remote definitions for sync/verify jobs (may include credentials)",
+ Type: CategoryTypePBS,
+ Paths: []string{
+ "./etc/proxmox-backup/remote.cfg",
+ },
+ },
+ {
+ ID: "pbs_notifications",
+ Name: "PBS Notifications",
+ Description: "Notification targets and matchers",
+ Type: CategoryTypePBS,
+ Paths: []string{
+ "./etc/proxmox-backup/notifications.cfg",
+ "./etc/proxmox-backup/notifications-priv.cfg",
+ },
+ },
+ {
+ ID: "pbs_access_control",
+ Name: "PBS Access Control",
+ Description: "Users, realms and permissions",
+ Type: CategoryTypePBS,
+ Paths: []string{
+ "./etc/proxmox-backup/user.cfg",
+ "./etc/proxmox-backup/domains.cfg",
+ "./etc/proxmox-backup/acl.cfg",
+ "./etc/proxmox-backup/token.cfg",
+ "./etc/proxmox-backup/shadow.json",
+ "./etc/proxmox-backup/token.shadow",
+ "./etc/proxmox-backup/tfa.json",
+ },
+ },
+ {
+ ID: "pbs_tape",
+ Name: "PBS Tape Backup",
+ Description: "Tape jobs, pools, changers and tape encryption keys",
+ Type: CategoryTypePBS,
+ Paths: []string{
+ "./etc/proxmox-backup/tape.cfg",
+ "./etc/proxmox-backup/tape-job.cfg",
+ "./etc/proxmox-backup/media-pool.cfg",
+ "./etc/proxmox-backup/tape-encryption-keys.json",
+ },
+ },
// Common Categories
{
@@ -148,6 +267,26 @@ func GetAllCategories() []Category {
"./etc/fstab",
},
},
+ {
+ ID: "storage_stack",
+ Name: "Storage Stack (Mounts/Targets)",
+ Description: "Storage stack configuration used by mounts (iSCSI/LVM/MDADM/multipath/autofs/crypttab)",
+ Type: CategoryTypeCommon,
+ Paths: []string{
+ "./etc/crypttab",
+ "./etc/iscsi/",
+ "./var/lib/iscsi/",
+ "./etc/multipath/",
+ "./etc/multipath.conf",
+ "./etc/mdadm/",
+ "./etc/lvm/backup/",
+ "./etc/lvm/archive/",
+ "./etc/autofs.conf",
+ "./etc/auto.master",
+ "./etc/auto.master.d/",
+ "./etc/auto.*",
+ },
+ },
{
ID: "network",
Name: "Network Configuration",
@@ -155,6 +294,9 @@ func GetAllCategories() []Category {
Type: CategoryTypeCommon,
Paths: []string{
"./etc/network/",
+ "./etc/netplan/",
+ "./etc/systemd/network/",
+ "./etc/NetworkManager/system-connections/",
"./etc/hosts",
"./etc/hostname",
"./etc/resolv.conf",
@@ -168,7 +310,10 @@ func GetAllCategories() []Category {
Description: "SSL/TLS certificates and private keys",
Type: CategoryTypeCommon,
Paths: []string{
+ "./etc/ssl/",
"./etc/proxmox-backup/proxy.pem",
+ "./etc/proxmox-backup/proxy.key",
+ "./etc/proxmox-backup/ssl/",
},
},
{
@@ -211,6 +356,26 @@ func GetAllCategories() []Category {
"./etc/systemd/system/",
"./etc/default/",
"./etc/udev/rules.d/",
+ "./etc/apt/",
+ "./etc/logrotate.d/",
+ "./etc/timezone",
+ "./etc/sysctl.conf",
+ "./etc/sysctl.d/",
+ "./etc/modprobe.d/",
+ "./etc/modules",
+ "./etc/iptables/",
+ "./etc/nftables.conf",
+ "./etc/nftables.d/",
+ },
+ },
+ {
+ ID: "user_data",
+ Name: "User Data (Home Directories)",
+ Description: "Root and user home directories (/root and /home)",
+ Type: CategoryTypeCommon,
+ Paths: []string{
+ "./root/",
+ "./home/",
},
},
{
@@ -223,6 +388,17 @@ func GetAllCategories() []Category {
"./etc/hostid",
},
},
+ {
+ ID: "proxsave_info",
+ Name: "ProxSave Diagnostics (Export Only)",
+ Description: "ProxSave command outputs and inventory reports (export-only; never written to system paths)",
+ Type: CategoryTypeCommon,
+ Paths: []string{
+ "./var/lib/proxsave-info/",
+ "./manifest.json",
+ },
+ ExportOnly: true,
+ },
}
}
@@ -291,22 +467,37 @@ func PathMatchesCategory(filePath string, category Category) bool {
if !strings.HasPrefix(normalized, "./") && !strings.HasPrefix(normalized, "../") {
normalized = "./" + normalized
}
+ normalized = filepath.ToSlash(normalized)
for _, catPath := range category.Paths {
+ if catPath == "" {
+ continue
+ }
+ normalizedCat := catPath
+ if !strings.HasPrefix(normalizedCat, "./") && !strings.HasPrefix(normalizedCat, "../") {
+ normalizedCat = "./" + normalizedCat
+ }
+ normalizedCat = filepath.ToSlash(normalizedCat)
+
+ if strings.ContainsAny(normalizedCat, "*?[") && !strings.HasSuffix(normalizedCat, "/") {
+ if ok, err := path.Match(normalizedCat, normalized); err == nil && ok {
+ return true
+ }
+ }
// Check for exact match
- if normalized == catPath {
+ if normalized == normalizedCat {
return true
}
// Check if the file is under a category directory
- if strings.HasSuffix(catPath, "/") {
+ if strings.HasSuffix(normalizedCat, "/") {
// Handle directory path both with and without trailing slash
- dirPath := strings.TrimSuffix(catPath, "/")
+ dirPath := strings.TrimSuffix(normalizedCat, "/")
if normalized == dirPath {
return true
}
- if strings.HasPrefix(normalized, catPath) {
+ if strings.HasPrefix(normalized, normalizedCat) {
return true
}
}
@@ -349,16 +540,16 @@ func GetStorageModeCategories(systemType string) []Category {
var categories []Category
if systemType == "pve" {
- // PVE: cluster + storage + jobs + zfs + filesystem
+ // PVE: cluster + storage + jobs + zfs + filesystem + storage stack
for _, cat := range all {
- if cat.ID == "pve_cluster" || cat.ID == "storage_pve" || cat.ID == "pve_jobs" || cat.ID == "zfs" || cat.ID == "filesystem" {
+ if cat.ID == "pve_cluster" || cat.ID == "storage_pve" || cat.ID == "pve_jobs" || cat.ID == "zfs" || cat.ID == "filesystem" || cat.ID == "storage_stack" {
categories = append(categories, cat)
}
}
} else if systemType == "pbs" {
- // PBS: config export + datastore + maintenance + jobs + zfs + filesystem
+ // PBS: config export + datastore + maintenance + jobs + remotes + zfs + filesystem + storage stack
for _, cat := range all {
- if cat.ID == "pbs_config" || cat.ID == "datastore_pbs" || cat.ID == "maintenance_pbs" || cat.ID == "pbs_jobs" || cat.ID == "zfs" || cat.ID == "filesystem" {
+ if cat.ID == "pbs_config" || cat.ID == "datastore_pbs" || cat.ID == "maintenance_pbs" || cat.ID == "pbs_jobs" || cat.ID == "pbs_remotes" || cat.ID == "zfs" || cat.ID == "filesystem" || cat.ID == "storage_stack" {
categories = append(categories, cat)
}
}
diff --git a/internal/orchestrator/compatibility_test.go b/internal/orchestrator/compatibility_test.go
index 9719988..5c7ef98 100644
--- a/internal/orchestrator/compatibility_test.go
+++ b/internal/orchestrator/compatibility_test.go
@@ -12,6 +12,7 @@ func TestValidateCompatibility_Mismatch(t *testing.T) {
defer func() { compatFS = orig }()
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
compatFS = fake
if err := os.MkdirAll(fake.onDisk("/etc/pve"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
@@ -26,7 +27,9 @@ func TestValidateCompatibility_Mismatch(t *testing.T) {
func TestDetectCurrentSystem_Unknown(t *testing.T) {
orig := compatFS
defer func() { compatFS = orig }()
- compatFS = NewFakeFS()
+ fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
+ compatFS = fake
if got := DetectCurrentSystem(); got != SystemTypeUnknown {
t.Fatalf("expected unknown system, got %s", got)
@@ -38,6 +41,7 @@ func TestGetSystemInfoDetectsPVE(t *testing.T) {
defer func() { compatFS = orig }()
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
compatFS = fake
if err := fake.AddDir("/etc/pve"); err != nil {
t.Fatalf("add dir: %v", err)
@@ -69,6 +73,7 @@ func TestGetSystemInfoDetectsPBS(t *testing.T) {
defer func() { compatFS = orig }()
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
compatFS = fake
if err := fake.AddDir("/etc/proxmox-backup"); err != nil {
t.Fatalf("add dir: %v", err)
diff --git a/internal/orchestrator/decrypt.go b/internal/orchestrator/decrypt.go
index fa4fbdb..92e9e6b 100644
--- a/internal/orchestrator/decrypt.go
+++ b/internal/orchestrator/decrypt.go
@@ -79,94 +79,9 @@ func RunDecryptWorkflowWithDeps(ctx context.Context, deps *Deps, version string)
}
done := logging.DebugStart(logger, "decrypt workflow", "version=%s", version)
defer func() { done(err) }()
- defer func() {
- if err == nil {
- return
- }
- if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) {
- err = ErrDecryptAborted
- }
- }()
-
- reader := bufio.NewReader(os.Stdin)
- _, prepared, err := prepareDecryptedBackup(ctx, reader, cfg, logger, version, true)
- if err != nil {
- return err
- }
- defer prepared.Cleanup()
-
- // Ask for destination directory (where the final decrypted bundle will live)
- destDir, err := promptDestinationDir(ctx, reader, cfg)
- if err != nil {
- return err
- }
- if err := restoreFS.MkdirAll(destDir, 0o755); err != nil {
- return fmt.Errorf("create destination directory: %w", err)
- }
- destDir, _ = filepath.Abs(destDir)
- logger.Info("Destination directory: %s", destDir)
-
- // Determine the logical decrypted archive path for naming purposes.
- // This keeps the same defaults and prompts as before, but the archive
- // itself stays in the temporary working directory.
- destArchivePath := filepath.Join(destDir, filepath.Base(prepared.ArchivePath))
- destArchivePath, err = ensureWritablePath(ctx, reader, destArchivePath, "decrypted archive")
- if err != nil {
- return err
- }
-
- // Work exclusively inside the temporary directory created by preparePlainBundle.
- workDir := filepath.Dir(prepared.ArchivePath)
- archiveBase := filepath.Base(destArchivePath)
- tempArchivePath := filepath.Join(workDir, archiveBase)
-
- // Ensure the staged archive in the temp dir has the desired basename.
- if tempArchivePath != prepared.ArchivePath {
- if err := moveFileSafe(prepared.ArchivePath, tempArchivePath); err != nil {
- return fmt.Errorf("move decrypted archive within temp dir: %w", err)
- }
- }
-
- manifestCopy := prepared.Manifest
- // Keep manifest path consistent with previous behavior: it refers to the
- // archive location in the destination directory, even though the archive
- // itself is not written there during the decrypt process.
- manifestCopy.ArchivePath = destArchivePath
- metadataPath := tempArchivePath + ".metadata"
- if err := backup.CreateManifest(ctx, logger, &manifestCopy, metadataPath); err != nil {
- return fmt.Errorf("write metadata: %w", err)
- }
-
- checksumPath := tempArchivePath + ".sha256"
- if err := restoreFS.WriteFile(checksumPath, []byte(fmt.Sprintf("%s %s\n", prepared.Checksum, filepath.Base(tempArchivePath))), 0o640); err != nil {
- return fmt.Errorf("write checksum file: %w", err)
- }
-
- logger.Info("Creating decrypted bundle...")
- bundlePath, err := createBundle(ctx, logger, tempArchivePath)
- if err != nil {
- return err
- }
-
- // Only the final decrypted bundle is moved into the destination directory.
- // All temporary plain artifacts remain confined to the temp workdir and
- // are removed by prepared.Cleanup().
- logicalBundlePath := destArchivePath + ".bundle.tar"
- targetBundlePath := strings.TrimSuffix(logicalBundlePath, ".bundle.tar") + ".decrypted.bundle.tar"
- targetBundlePath, err = ensureWritablePath(ctx, reader, targetBundlePath, "decrypted bundle")
- if err != nil {
- return err
- }
- if err := restoreFS.Remove(targetBundlePath); err != nil && !errors.Is(err, os.ErrNotExist) {
- logger.Warning("Failed to remove existing bundle target: %v", err)
- }
- if err := moveFileSafe(bundlePath, targetBundlePath); err != nil {
- return fmt.Errorf("move decrypted bundle: %w", err)
- }
-
- logger.Info("Decrypted bundle created: %s", targetBundlePath)
- return nil
+ ui := newCLIWorkflowUI(bufio.NewReader(os.Stdin), logger)
+ return runDecryptWorkflowWithUI(ctx, cfg, logger, version, ui)
}
// RunDecryptWorkflow is the legacy entrypoint that builds default deps.
@@ -185,105 +100,9 @@ func RunDecryptWorkflow(ctx context.Context, cfg *config.Config, logger *logging
func selectDecryptCandidate(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *decryptCandidate, err error) {
done := logging.DebugStart(logger, "select backup candidate", "requireEncrypted=%v", requireEncrypted)
defer func() { done(err) }()
- pathOptions := buildDecryptPathOptions(cfg, logger)
- if len(pathOptions) == 0 {
- return nil, fmt.Errorf("no backup paths configured in backup.env")
- }
-
- if logger != nil {
- for _, opt := range pathOptions {
- logger.Debug("Backup source option prepared: label=%q path=%q isRclone=%v", opt.Label, opt.Path, opt.IsRclone)
- }
- }
-
- var candidates []*decryptCandidate
- var selectedPath string
-
- for {
- option, err := promptPathSelection(ctx, reader, pathOptions)
- if err != nil {
- return nil, err
- }
-
- if logger != nil {
- logger.Debug("Backup source selected by user: label=%q path=%q isRclone=%v", option.Label, option.Path, option.IsRclone)
- }
-
- logger.Info("Scanning %s for backups...", option.Path)
-
- // Handle rclone remotes differently from filesystem paths
- if option.IsRclone {
- logging.DebugStep(logger, "select backup candidate", "scanning rclone remote: %s", option.Path)
- candidates, err = discoverRcloneBackups(ctx, option.Path, logger)
- if err != nil {
- logger.Warning("Failed to inspect cloud remote %s: %v", option.Path, err)
- // On persistent failures, remove this option so it is no longer offered.
- pathOptions = removeDecryptPathOption(pathOptions, option)
- if len(pathOptions) == 0 {
- return nil, fmt.Errorf("no usable backup sources available")
- }
- continue
- }
- if logger != nil {
- logger.Debug("Cloud (rclone): %d candidate bundle(s) returned for %s", len(candidates), option.Path)
- }
- } else {
- logging.DebugStep(logger, "select backup candidate", "scanning filesystem path: %s", option.Path)
- info, err := restoreFS.Stat(option.Path)
- if err != nil || !info.IsDir() {
- logger.Warning("Path %s is not accessible (%v)", option.Path, err)
- continue
- }
-
- candidates, err = discoverBackupCandidates(logger, option.Path)
- if err != nil {
- logger.Warning("Failed to inspect %s: %v", option.Path, err)
- continue
- }
- }
- if len(candidates) == 0 {
- logger.Warning("No backups found in %s – removing from source list", option.Path)
- if logger != nil {
- logger.Debug("Removing backup source %q (%s) due to empty candidate list", option.Label, option.Path)
- }
- pathOptions = removeDecryptPathOption(pathOptions, option)
- if len(pathOptions) == 0 {
- return nil, fmt.Errorf("no usable backup sources available")
- }
- continue
- }
-
- if requireEncrypted {
- encrypted := filterEncryptedCandidates(candidates)
- if len(encrypted) == 0 {
- logger.Warning("No encrypted backups found in %s – removing from source list", option.Path)
- if logger != nil {
- logger.Debug("Removing backup source %q (%s) because all candidates are plain (non-encrypted)", option.Label, option.Path)
- }
- pathOptions = removeDecryptPathOption(pathOptions, option)
- if len(pathOptions) == 0 {
- return nil, fmt.Errorf("no usable backup sources available")
- }
- continue
- }
-
- if logger != nil {
- logger.Debug("Backup candidates after encryption filter: total=%d encrypted=%d", len(candidates), len(encrypted))
- }
-
- candidates = encrypted
- }
- selectedPath = option.Path
- break
- }
- if requireEncrypted {
- logger.Info("Found %d encrypted backup(s) in %s", len(candidates), selectedPath)
- } else {
- logger.Info("Found %d backup(s) in %s", len(candidates), selectedPath)
- }
- candidate, err = promptCandidateSelection(ctx, reader, candidates)
- return candidate, err
+ ui := newCLIWorkflowUI(reader, logger)
+ return selectBackupCandidateWithUI(ctx, ui, cfg, logger, requireEncrypted)
}
func promptPathSelection(ctx context.Context, reader *bufio.Reader, options []decryptPathOption) (decryptPathOption, error) {
@@ -609,113 +428,8 @@ func downloadRcloneBackup(ctx context.Context, remotePath string, logger *loggin
}
func preparePlainBundle(ctx context.Context, reader *bufio.Reader, cand *decryptCandidate, version string, logger *logging.Logger) (bundle *preparedBundle, err error) {
- done := logging.DebugStart(logger, "prepare plain bundle", "source=%v rclone=%v", cand.Source, cand.IsRclone)
- defer func() { done(err) }()
- // If this is an rclone backup, download it first
- var rcloneCleanup func()
- if cand.IsRclone && cand.Source == sourceBundle {
- logger.Debug("Detected rclone backup, downloading...")
- localPath, cleanup, err := downloadRcloneBackup(ctx, cand.BundlePath, logger)
- if err != nil {
- return nil, fmt.Errorf("failed to download rclone backup: %w", err)
- }
- rcloneCleanup = cleanup
- // Update candidate to use local path
- cand.BundlePath = localPath
- }
-
- tempRoot := filepath.Join("/tmp", "proxsave")
- if err := restoreFS.MkdirAll(tempRoot, 0o755); err != nil {
- if rcloneCleanup != nil {
- rcloneCleanup()
- }
- return nil, fmt.Errorf("create temp root: %w", err)
- }
- workDir, err := restoreFS.MkdirTemp(tempRoot, "proxmox-decrypt-*")
- if err != nil {
- if rcloneCleanup != nil {
- rcloneCleanup()
- }
- return nil, fmt.Errorf("create temp dir: %w", err)
- }
- logging.DebugStep(logger, "prepare plain bundle", "workdir=%s", workDir)
- cleanup := func() {
- _ = restoreFS.RemoveAll(workDir)
- if rcloneCleanup != nil {
- rcloneCleanup()
- }
- }
-
- var staged stagedFiles
- switch cand.Source {
- case sourceBundle:
- logger.Info("Extracting bundle %s", filepath.Base(cand.BundlePath))
- staged, err = extractBundleToWorkdirWithLogger(cand.BundlePath, workDir, logger)
- case sourceRaw:
- logger.Info("Staging raw artifacts for %s", filepath.Base(cand.RawArchivePath))
- staged, err = copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, logger)
- default:
- err = fmt.Errorf("unsupported candidate source")
- }
- if err != nil {
- cleanup()
- return nil, err
- }
-
- manifestCopy := *cand.Manifest
- currentEncryption := strings.ToLower(manifestCopy.EncryptionMode)
-
- logging.DebugStep(logger, "prepare plain bundle", "encryption=%s", currentEncryption)
- logger.Info("Preparing archive %s for decryption (mode: %s)", manifestCopy.ArchivePath, statusFromManifest(&manifestCopy))
-
- plainArchiveName := strings.TrimSuffix(filepath.Base(staged.ArchivePath), ".age")
- plainArchivePath := filepath.Join(workDir, plainArchiveName)
-
- if currentEncryption == "age" {
- if err := decryptArchiveWithPrompts(ctx, reader, staged.ArchivePath, plainArchivePath, logger); err != nil {
- cleanup()
- return nil, err
- }
- } else {
- // For plain archives, only copy if source and destination are different
- // to avoid truncating the file when copying to itself
- if staged.ArchivePath != plainArchivePath {
- if err := copyFile(restoreFS, staged.ArchivePath, plainArchivePath); err != nil {
- cleanup()
- return nil, fmt.Errorf("copy archive: %w", err)
- }
- }
- // If paths are identical, file is already in the correct location
- }
-
- archiveInfo, err := restoreFS.Stat(plainArchivePath)
- if err != nil {
- cleanup()
- return nil, fmt.Errorf("stat decrypted archive: %w", err)
- }
-
- checksum, err := backup.GenerateChecksum(ctx, logger, plainArchivePath)
- if err != nil {
- cleanup()
- return nil, fmt.Errorf("generate checksum: %w", err)
- }
- logging.DebugStep(logger, "prepare plain bundle", "checksum computed")
-
- manifestCopy.ArchivePath = plainArchivePath
- manifestCopy.ArchiveSize = archiveInfo.Size()
- manifestCopy.SHA256 = checksum
- manifestCopy.EncryptionMode = "none"
- if version != "" {
- manifestCopy.ScriptVersion = version
- }
-
- bundle = &preparedBundle{
- ArchivePath: plainArchivePath,
- Manifest: manifestCopy,
- Checksum: checksum,
- cleanup: cleanup,
- }
- return bundle, nil
+ ui := newCLIWorkflowUI(reader, logger)
+ return preparePlainBundleWithUI(ctx, cand, version, logger, ui)
}
func prepareDecryptedBackup(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (candidate *decryptCandidate, prepared *preparedBundle, err error) {
@@ -936,43 +650,9 @@ func copyRawArtifactsToWorkdirWithLogger(ctx context.Context, cand *decryptCandi
}
func decryptArchiveWithPrompts(ctx context.Context, reader *bufio.Reader, encryptedPath, outputPath string, logger *logging.Logger) error {
- for {
- fmt.Print("Enter decryption key or passphrase (0 = exit): ")
- inputBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd()))
- fmt.Println()
- if err != nil {
- return err
- }
- trimmed := bytes.TrimSpace(inputBytes)
- if len(trimmed) == 0 {
- zeroBytes(inputBytes)
- logger.Warning("Input cannot be empty")
- continue
- }
- input := string(trimmed)
- zeroBytes(trimmed)
- zeroBytes(inputBytes)
- if input == "0" {
- return ErrDecryptAborted
- }
-
- identities, err := parseIdentityInput(input)
- resetString(&input)
- if err != nil {
- logger.Warning("Invalid key/passphrase: %v", err)
- continue
- }
-
- if err := decryptWithIdentity(encryptedPath, outputPath, identities...); err != nil {
- var noMatch *age.NoIdentityMatchError
- if errors.Is(err, age.ErrIncorrectIdentity) || errors.As(err, &noMatch) {
- logger.Warning("Provided key or passphrase does not match this archive. Try again or press 0 to exit.")
- continue
- }
- return err
- }
- return nil
- }
+ ui := newCLIWorkflowUI(reader, logger)
+ displayName := filepath.Base(encryptedPath)
+ return decryptArchiveWithSecretPrompt(ctx, encryptedPath, outputPath, displayName, logger, ui.PromptDecryptSecret)
}
func parseIdentityInput(input string) ([]age.Identity, error) {
@@ -1063,48 +743,8 @@ func moveFileSafe(src, dst string) error {
}
func ensureWritablePath(ctx context.Context, reader *bufio.Reader, path, description string) (string, error) {
- current := filepath.Clean(path)
- for {
- if _, err := restoreFS.Stat(current); errors.Is(err, os.ErrNotExist) {
- return current, nil
- } else if err != nil && !errors.Is(err, os.ErrExist) {
- return "", fmt.Errorf("stat %s: %w", current, err)
- }
-
- fmt.Printf("%s %s already exists.\n", titleCaser.String(description), current)
- fmt.Println(" [1] Overwrite")
- fmt.Println(" [2] Enter a different path")
- fmt.Println(" [0] Exit")
- fmt.Print("Choice: ")
-
- inputLine, err := input.ReadLineWithContext(ctx, reader)
- if err != nil {
- return "", err
- }
- switch strings.TrimSpace(inputLine) {
- case "1":
- if err := restoreFS.Remove(current); err != nil {
- fmt.Printf("Failed to remove existing file: %v\n", err)
- continue
- }
- return current, nil
- case "2":
- fmt.Print("Enter new path: ")
- newPath, err := input.ReadLineWithContext(ctx, reader)
- if err != nil {
- return "", err
- }
- trimmed := strings.TrimSpace(newPath)
- if trimmed == "" {
- continue
- }
- current = filepath.Clean(trimmed)
- case "0":
- return "", ErrDecryptAborted
- default:
- fmt.Println("Please enter 1, 2 or 0.")
- }
- }
+ ui := newCLIWorkflowUI(reader, nil)
+ return ensureWritablePathWithUI(ctx, ui, path, description)
}
func formatClusterMode(value string) string {
diff --git a/internal/orchestrator/decrypt_move_test.go b/internal/orchestrator/decrypt_move_test.go
index f137d16..38c8bba 100644
--- a/internal/orchestrator/decrypt_move_test.go
+++ b/internal/orchestrator/decrypt_move_test.go
@@ -45,6 +45,7 @@ func TestMoveFileSafe_RenameFailsFallsBackToCopy(t *testing.T) {
orig := restoreFS
defer func() { restoreFS = orig }()
fake := &fakeFSRenameFail{NewFakeFS()}
+ defer func() { _ = os.RemoveAll(fake.Root) }()
restoreFS = fake
src := "/src/file.txt"
diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go
index 3bfa705..d663df7 100644
--- a/internal/orchestrator/decrypt_test.go
+++ b/internal/orchestrator/decrypt_test.go
@@ -1805,6 +1805,7 @@ func TestEnsureWritablePath_StatError(t *testing.T) {
fakeFS.StatErrors["/fake/path"] = fmt.Errorf("permission denied")
restoreFS = fakeFS
t.Cleanup(func() { restoreFS = origFS })
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
reader := bufio.NewReader(strings.NewReader(""))
_, err := ensureWritablePath(context.Background(), reader, "/fake/path", "test file")
@@ -2163,6 +2164,7 @@ func TestExtractBundleToWorkdir_WithFakeFS(t *testing.T) {
fakeFS := NewFakeFS()
restoreFS = fakeFS
t.Cleanup(func() { restoreFS = origFS })
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
// Create a bundle in the fakeFS using the real os module
dir := t.TempDir()
@@ -3311,6 +3313,7 @@ func TestPromptCandidateSelection_Exit(t *testing.T) {
func TestPreparePlainBundle_MkdirAllError(t *testing.T) {
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
fake.MkdirAllErr = os.ErrPermission
orig := restoreFS
restoreFS = fake
@@ -3336,6 +3339,7 @@ func TestPreparePlainBundle_MkdirAllError(t *testing.T) {
func TestPreparePlainBundle_MkdirTempError(t *testing.T) {
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
fake.MkdirTempErr = os.ErrPermission
orig := restoreFS
restoreFS = fake
@@ -3407,6 +3411,7 @@ func TestExtractBundleToWorkdir_OpenFileErrorOnExtract(t *testing.T) {
// Use fake FS with OpenFile error for the archive target
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
fake.OpenFileErr[filepath.Join(workDir, "backup.tar.xz")] = os.ErrPermission
// Copy bundle to fake FS
bundleContent, _ := os.ReadFile(bundlePath)
@@ -3775,6 +3780,7 @@ func TestPreparePlainBundle_StatErrorAfterExtract(t *testing.T) {
// Create FakeFS that will fail on stat for the extracted archive
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
// Copy bundle to fake FS
bundleContent, _ := os.ReadFile(bundlePath)
@@ -4069,6 +4075,7 @@ func TestPreparePlainBundle_CopyFileError(t *testing.T) {
// Use FakeFS
fake := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fake.Root) }()
bundleContent, _ := os.ReadFile(bundlePath)
if err := fake.WriteFile(bundlePath, bundleContent, 0o640); err != nil {
t.Fatalf("copy bundle to fake: %v", err)
diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go
index a45af1a..b0f23a8 100644
--- a/internal/orchestrator/decrypt_tui.go
+++ b/internal/orchestrator/decrypt_tui.go
@@ -7,8 +7,6 @@ import (
"os"
"path/filepath"
"strings"
- "sync/atomic"
- "time"
"filippo.io/age"
"github.com/gdamore/tcell/v2"
@@ -21,15 +19,9 @@ import (
"github.com/tis24dev/proxsave/internal/tui/components"
)
-type decryptSelection struct {
- Candidate *decryptCandidate
- DestDir string
-}
-
const (
decryptWizardSubtitle = "Decrypt Backup Workflow"
decryptNavText = "[yellow]Navigation:[white] TAB/↑↓ to move | ENTER to select | ESC to exit screens | Mouse clicks enabled"
- errorModalPage = "decrypt-error-modal"
pathActionOverwrite = "overwrite"
pathActionNew = "new"
@@ -55,401 +47,16 @@ func RunDecryptWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logg
done := logging.DebugStart(logger, "decrypt workflow (tui)", "version=%s", version)
defer func() { done(err) }()
- selection, err := runDecryptSelectionWizard(ctx, cfg, logger, configPath, buildSig)
- if err != nil {
+ ui := newTUIWorkflowUI(configPath, buildSig, logger)
+ if err := runDecryptWorkflowWithUI(ctx, cfg, logger, version, ui); err != nil {
if errors.Is(err, ErrDecryptAborted) {
return ErrDecryptAborted
}
return err
}
-
- prepared, err := preparePlainBundleTUI(ctx, selection.Candidate, version, logger, configPath, buildSig)
- if err != nil {
- return err
- }
- defer prepared.Cleanup()
-
- destDir := selection.DestDir
- if err := restoreFS.MkdirAll(destDir, 0o755); err != nil {
- return fmt.Errorf("create destination directory: %w", err)
- }
-
- // Determine the logical decrypted archive path for naming purposes.
- // This keeps the same defaults and prompts as before, but the archive
- // itself stays in the temporary working directory.
- destArchivePath := filepath.Join(destDir, filepath.Base(prepared.ArchivePath))
- destArchivePath, err = ensureWritablePathTUI(destArchivePath, "decrypted archive", configPath, buildSig)
- if err != nil {
- return err
- }
-
- // Work exclusively inside the temporary directory created by preparePlainBundleTUI.
- workDir := filepath.Dir(prepared.ArchivePath)
- archiveBase := filepath.Base(destArchivePath)
- tempArchivePath := filepath.Join(workDir, archiveBase)
-
- // Ensure the staged archive in the temp dir has the desired basename.
- if tempArchivePath != prepared.ArchivePath {
- if err := moveFileSafe(prepared.ArchivePath, tempArchivePath); err != nil {
- return fmt.Errorf("move decrypted archive within temp dir: %w", err)
- }
- }
-
- manifestCopy := prepared.Manifest
- // As in the CLI workflow, keep the manifest's ArchivePath pointing to the
- // destination archive location while the actual archive continues to live
- // only inside the temporary work directory.
- manifestCopy.ArchivePath = destArchivePath
-
- metadataPath := tempArchivePath + ".metadata"
- if err := backup.CreateManifest(ctx, logger, &manifestCopy, metadataPath); err != nil {
- return fmt.Errorf("write metadata: %w", err)
- }
-
- checksumPath := tempArchivePath + ".sha256"
- if err := restoreFS.WriteFile(checksumPath, []byte(fmt.Sprintf("%s %s\n", prepared.Checksum, filepath.Base(tempArchivePath))), 0o640); err != nil {
- return fmt.Errorf("write checksum file: %w", err)
- }
-
- logger.Debug("Creating decrypted bundle...")
- bundlePath, err := createBundle(ctx, logger, tempArchivePath)
- if err != nil {
- return err
- }
-
- // Only the final decrypted bundle is moved into the destination directory.
- logicalBundlePath := destArchivePath + ".bundle.tar"
- targetBundlePath := strings.TrimSuffix(logicalBundlePath, ".bundle.tar") + ".decrypted.bundle.tar"
- targetBundlePath, err = ensureWritablePathTUI(targetBundlePath, "decrypted bundle", configPath, buildSig)
- if err != nil {
- return err
- }
- if err := restoreFS.Remove(targetBundlePath); err != nil && !errors.Is(err, os.ErrNotExist) {
- logger.Warning("Failed to remove existing bundle target: %v", err)
- }
- if err := moveFileSafe(bundlePath, targetBundlePath); err != nil {
- return fmt.Errorf("move decrypted bundle: %w", err)
- }
-
- logger.Info("Decrypted bundle created: %s", targetBundlePath)
return nil
}
-func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, logger *logging.Logger, configPath, buildSig string) (selection *decryptSelection, err error) {
- if ctx == nil {
- ctx = context.Background()
- }
- done := logging.DebugStart(logger, "decrypt selection wizard", "tui=true")
- defer func() { done(err) }()
- options := buildDecryptPathOptions(cfg, logger)
- if len(options) == 0 {
- err = fmt.Errorf("no backup paths configured in backup.env")
- return nil, err
- }
- for _, opt := range options {
- logging.DebugStep(logger, "decrypt selection wizard", "option label=%q path=%q rclone=%v", opt.Label, opt.Path, opt.IsRclone)
- }
-
- app := newTUIApp()
- pages := tview.NewPages()
-
- selection = &decryptSelection{}
- var selectionErr error
- var scan scanController
- var scanSeq uint64
-
- pathList := tview.NewList().ShowSecondaryText(false)
- pathList.SetMainTextColor(tcell.ColorWhite).
- SetSelectedTextColor(tcell.ColorWhite).
- SetSelectedBackgroundColor(tui.ProxmoxOrange)
-
- for _, opt := range options {
- // Use parentheses instead of square brackets (tview interprets [] as color tags)
- label := fmt.Sprintf("%s (%s)", opt.Label, opt.Path)
- pathList.AddItem(label, "", 0, nil)
- }
-
- pathList.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
- if index < 0 || index >= len(options) {
- return
- }
- selectedOption := options[index]
- scanID := atomic.AddUint64(&scanSeq, 1)
- logging.DebugStep(logger, "decrypt selection wizard", "selected source label=%q path=%q rclone=%v", selectedOption.Label, selectedOption.Path, selectedOption.IsRclone)
- pages.SwitchToPage("loading")
- go func() {
- scanCtx, finish := scan.Start(ctx)
- defer finish()
-
- var candidates []*decryptCandidate
- var scanErr error
- scanDone := logging.DebugStart(logger, "scan backup source", "id=%d path=%s rclone=%v", scanID, selectedOption.Path, selectedOption.IsRclone)
- defer func() { scanDone(scanErr) }()
-
- if selectedOption.IsRclone {
- timeout := 30 * time.Second
- if cfg != nil && cfg.RcloneTimeoutConnection > 0 {
- timeout = time.Duration(cfg.RcloneTimeoutConnection) * time.Second
- }
- logging.DebugStep(logger, "scan backup source", "id=%d rclone_timeout=%s", scanID, timeout)
- rcloneCtx, cancel := context.WithTimeout(scanCtx, timeout)
- defer cancel()
- candidates, scanErr = discoverRcloneBackups(rcloneCtx, selectedOption.Path, logger)
- } else {
- candidates, scanErr = discoverBackupCandidates(logger, selectedOption.Path)
- }
- logging.DebugStep(logger, "scan backup source", "candidates=%d", len(candidates))
- if scanCtx.Err() != nil {
- scanErr = scanCtx.Err()
- return
- }
- app.QueueUpdateDraw(func() {
- if scanErr != nil {
- message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, scanErr)
- if selectedOption.IsRclone && errors.Is(scanErr, context.DeadlineExceeded) {
- message = fmt.Sprintf("Timed out while scanning %s (rclone). Check connectivity/rclone config or increase RCLONE_TIMEOUT_CONNECTION. (%v)", selectedOption.Path, scanErr)
- }
- showErrorModal(app, pages, configPath, buildSig, message, func() {
- pages.SwitchToPage("paths")
- })
- return
- }
-
- encrypted := filterEncryptedCandidates(candidates)
- if len(encrypted) == 0 {
- message := "No encrypted backups found in selected path."
- showErrorModal(app, pages, configPath, buildSig, message, func() {
- pages.SwitchToPage("paths")
- })
- return
- }
-
- showCandidatePage(app, pages, encrypted, configPath, buildSig, func(c *decryptCandidate) {
- selection.Candidate = c
- showDestinationForm(app, pages, cfg, c, configPath, buildSig, func(dest string) {
- selection.DestDir = dest
- app.Stop()
- })
- }, func() {
- selectionErr = ErrDecryptAborted
- app.Stop()
- })
- })
- }()
- })
- pathList.SetDoneFunc(func() {
- logging.DebugStep(logger, "decrypt selection wizard", "cancel requested (done func)")
- scan.Cancel()
- selectionErr = ErrDecryptAborted
- app.Stop()
- })
-
- form := components.NewForm(app)
- listHeight := len(options)
- if listHeight < 8 {
- listHeight = 8
- }
- if listHeight > 14 {
- listHeight = 14
- }
- listItem := components.NewListFormItem(pathList).
- SetLabel("Available backup sources").
- SetFieldHeight(listHeight)
- form.Form.AddFormItem(listItem)
- form.Form.SetFocus(0)
-
- form.SetOnCancel(func() {
- logging.DebugStep(logger, "decrypt selection wizard", "cancel requested (form)")
- scan.Cancel()
- selectionErr = ErrDecryptAborted
- })
- form.AddCancelButton("Cancel")
- enableFormNavigation(form, nil)
-
- pathPage := buildWizardPage("Select backup source", configPath, buildSig, form.Form)
- pages.AddPage("paths", pathPage, true, true)
-
- loadingText := tview.NewTextView().
- SetText("Scanning backup path...").
- SetTextAlign(tview.AlignCenter)
-
- loadingForm := components.NewForm(app)
- loadingForm.SetOnCancel(func() {
- logging.DebugStep(logger, "decrypt selection wizard", "cancel requested (loading form)")
- scan.Cancel()
- selectionErr = ErrDecryptAborted
- })
- loadingForm.AddCancelButton("Cancel")
- loadingContent := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(loadingText, 0, 1, false).
- AddItem(loadingForm.Form, 3, 0, false)
- loadingPage := buildWizardPage("Loading backups", configPath, buildSig, loadingContent)
- pages.AddPage("loading", loadingPage, true, false)
-
- app.SetRoot(pages, true).SetFocus(form.Form)
- if runErr := app.Run(); runErr != nil {
- err = runErr
- return nil, err
- }
- if selectionErr != nil {
- err = selectionErr
- return nil, err
- }
- if selection.Candidate == nil || selection.DestDir == "" {
- err = ErrDecryptAborted
- return nil, err
- }
- return selection, nil
-}
-
-func showErrorModal(app *tui.App, pages *tview.Pages, configPath, buildSig, message string, onDismiss func()) {
- modal := tview.NewModal().
- SetText(fmt.Sprintf("%s %s\n\n[yellow]Press ENTER to continue[white]", tui.SymbolError, message)).
- AddButtons([]string{"OK"}).
- SetDoneFunc(func(buttonIndex int, buttonLabel string) {
- if pages.HasPage(errorModalPage) {
- pages.RemovePage(errorModalPage)
- }
- if onDismiss != nil {
- onDismiss()
- }
- })
-
- modal.SetBorder(true).
- SetTitle(" Decrypt Error ").
- SetTitleAlign(tview.AlignCenter).
- SetTitleColor(tui.ErrorRed).
- SetBorderColor(tui.ErrorRed).
- SetBackgroundColor(tcell.ColorBlack)
-
- page := buildWizardPage("Error", configPath, buildSig, modal)
- if pages.HasPage(errorModalPage) {
- pages.RemovePage(errorModalPage)
- }
- pages.AddPage(errorModalPage, page, true, true)
- app.SetFocus(modal)
-}
-
-func showCandidatePage(app *tui.App, pages *tview.Pages, candidates []*decryptCandidate, configPath, buildSig string, onSelect func(*decryptCandidate), onCancel func()) {
- list := tview.NewList().ShowSecondaryText(false)
- list.SetMainTextColor(tcell.ColorWhite).
- SetSelectedTextColor(tcell.ColorWhite).
- SetSelectedBackgroundColor(tui.ProxmoxOrange)
-
- type row struct {
- created string
- mode string
- tool string
- targets string
- compression string
- }
-
- rows := make([]row, len(candidates))
- var maxMode, maxTool, maxTargets, maxComp int
-
- for idx, cand := range candidates {
- created := cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05")
-
- mode := strings.ToUpper(statusFromManifest(cand.Manifest))
- if mode == "" {
- mode = "UNKNOWN"
- }
-
- toolVersion := strings.TrimSpace(cand.Manifest.ScriptVersion)
- if toolVersion == "" {
- toolVersion = "unknown"
- }
- tool := "Tool " + toolVersion
-
- targets := buildTargetInfo(cand.Manifest)
-
- comp := ""
- if c := strings.TrimSpace(cand.Manifest.CompressionType); c != "" {
- comp = strings.ToUpper(c)
- }
-
- rows[idx] = row{
- created: created,
- mode: mode,
- tool: tool,
- targets: targets,
- compression: comp,
- }
-
- if len(mode) > maxMode {
- maxMode = len(mode)
- }
- if len(tool) > maxTool {
- maxTool = len(tool)
- }
- if len(targets) > maxTargets {
- maxTargets = len(targets)
- }
- if len(comp) > maxComp {
- maxComp = len(comp)
- }
- }
-
- for idx, r := range rows {
- line := fmt.Sprintf(
- "%2d) %s %-*s %-*s %-*s",
- idx+1,
- r.created,
- maxMode, r.mode,
- maxTool, r.tool,
- maxTargets, r.targets,
- )
- if maxComp > 0 {
- line = fmt.Sprintf("%s %-*s", line, maxComp, r.compression)
- }
- list.AddItem(line, "", 0, nil)
- }
-
- list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
- if index < 0 || index >= len(candidates) {
- return
- }
- onSelect(candidates[index])
- })
- list.SetDoneFunc(func() {
- pages.SwitchToPage("paths")
- })
-
- form := components.NewForm(app)
- listHeight := len(candidates)
- if listHeight < 8 {
- listHeight = 8
- }
- if listHeight > 14 {
- listHeight = 14
- }
- listItem := components.NewListFormItem(list).
- SetLabel("Available backups").
- SetFieldHeight(listHeight)
- form.Form.AddFormItem(listItem)
- form.Form.SetFocus(0)
-
- form.SetOnCancel(func() {
- if onCancel != nil {
- onCancel()
- }
- })
-
- // Back goes on the left, Cancel on the right (order of AddButton calls)
- form.Form.AddButton("Back", func() {
- pages.SwitchToPage("paths")
- })
- form.AddCancelButton("Cancel")
- enableFormNavigation(form, nil)
-
- page := buildWizardPage("Select backup to decrypt", configPath, buildSig, form.Form)
- if pages.HasPage("candidates") {
- pages.RemovePage("candidates")
- }
- pages.AddPage("candidates", page, true, true)
-}
-
func buildTargetInfo(manifest *backup.Manifest) string {
targets := formatTargets(manifest)
if targets == "" {
@@ -497,66 +104,6 @@ func filterEncryptedCandidates(candidates []*decryptCandidate) []*decryptCandida
return filtered
}
-func showDestinationForm(app *tui.App, pages *tview.Pages, cfg *config.Config, selected *decryptCandidate, configPath, buildSig string, onSubmit func(string)) {
- defaultDir := "./decrypt"
- if cfg != nil && strings.TrimSpace(cfg.BaseDir) != "" {
- defaultDir = filepath.Join(strings.TrimSpace(cfg.BaseDir), "decrypt")
- }
- form := components.NewForm(app)
- form.AddInputField("Destination directory", defaultDir, 48, nil, nil)
-
- form.SetOnSubmit(func(values map[string]string) error {
- dest := strings.TrimSpace(values["Destination directory"])
- if dest == "" {
- return fmt.Errorf("destination directory cannot be empty")
- }
- onSubmit(filepath.Clean(dest))
- return nil
- })
- form.SetOnCancel(func() {
- pages.SwitchToPage("candidates")
- })
-
- // Buttons: Back (left), Continue, Cancel (right)
- form.Form.AddButton("Back", func() {
- pages.SwitchToPage("candidates")
- })
- form.AddSubmitButton("Continue")
- form.AddCancelButton("Cancel")
-
- // Selected backup summary
- var summary string
- if selected != nil && selected.Manifest != nil {
- created := selected.Manifest.CreatedAt.Format("2006-01-02 15:04:05")
- mode := strings.ToUpper(statusFromManifest(selected.Manifest))
- if mode == "" {
- mode = "UNKNOWN"
- }
- targetInfo := buildTargetInfo(selected.Manifest)
- summary = fmt.Sprintf("Selected backup: %s • %s • %s", created, mode, targetInfo)
- }
-
- var content tview.Primitive
- if summary != "" {
- selText := tview.NewTextView().
- SetText(summary).
- SetTextColor(tcell.ColorWhite).
- SetDynamicColors(true)
- content = tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(selText, 2, 0, false).
- AddItem(form.Form, 0, 1, true)
- } else {
- content = form.Form
- }
-
- page := buildWizardPage("Destination directory", configPath, buildSig, content)
- if pages.HasPage("destination") {
- pages.RemovePage("destination")
- }
- pages.AddPage("destination", page, true, true)
-}
-
func ensureWritablePathTUI(path, description, configPath, buildSig string) (string, error) {
current := filepath.Clean(path)
if description == "" {
diff --git a/internal/orchestrator/decrypt_tui_test.go b/internal/orchestrator/decrypt_tui_test.go
index 9b913ab..6404b76 100644
--- a/internal/orchestrator/decrypt_tui_test.go
+++ b/internal/orchestrator/decrypt_tui_test.go
@@ -11,10 +11,7 @@ import (
"github.com/rivo/tview"
"github.com/tis24dev/proxsave/internal/backup"
- "github.com/tis24dev/proxsave/internal/config"
"github.com/tis24dev/proxsave/internal/logging"
- "github.com/tis24dev/proxsave/internal/tui"
- "github.com/tis24dev/proxsave/internal/tui/components"
"github.com/tis24dev/proxsave/internal/types"
)
@@ -249,131 +246,6 @@ func TestPreparePlainBundleTUICopiesRawArtifacts(t *testing.T) {
}
}
-func TestShowErrorModalAddsWizardPage(t *testing.T) {
- app := tui.NewApp()
- pages := tview.NewPages()
-
- showErrorModal(app, pages, "cfg", "sig", "boom", nil)
-
- if !pages.HasPage(errorModalPage) {
- t.Fatalf("expected %q page to be present", errorModalPage)
- }
-
- page := pages.GetPage(errorModalPage)
- flex, ok := page.(*tview.Flex)
- if !ok {
- t.Fatalf("expected *tview.Flex, got %T", page)
- }
- content := flex.GetItem(3)
- modal, ok := content.(*tview.Modal)
- if !ok {
- t.Fatalf("expected *tview.Modal content, got %T", content)
- }
- if modal.GetTitle() != " Decrypt Error " {
- t.Fatalf("modal title=%q; want %q", modal.GetTitle(), " Decrypt Error ")
- }
-}
-
-func TestShowCandidatePageAddsCandidatesPageWithItems(t *testing.T) {
- app := tui.NewApp()
- pages := tview.NewPages()
-
- now := time.Unix(1700000000, 0)
- candidates := []*decryptCandidate{
- {
- Manifest: &backup.Manifest{
- CreatedAt: now,
- EncryptionMode: "age",
- ProxmoxTargets: []string{"pve"},
- ProxmoxVersion: "8.1",
- CompressionType: "zstd",
- ClusterMode: "standalone",
- ScriptVersion: "1.0.0",
- },
- },
- {
- Manifest: &backup.Manifest{
- CreatedAt: now.Add(-time.Hour),
- EncryptionMode: "age",
- ProxmoxTargets: []string{"pbs"},
- CompressionType: "xz",
- ScriptVersion: "1.0.0",
- },
- },
- }
-
- showCandidatePage(app, pages, candidates, "cfg", "sig", func(*decryptCandidate) {}, func() {})
-
- if !pages.HasPage("candidates") {
- t.Fatalf("expected candidates page to be present")
- }
- page := pages.GetPage("candidates")
- flex, ok := page.(*tview.Flex)
- if !ok {
- t.Fatalf("expected *tview.Flex, got %T", page)
- }
- content := flex.GetItem(3)
- form, ok := content.(*tview.Form)
- if !ok {
- t.Fatalf("expected *tview.Form content, got %T", content)
- }
- if form.GetFormItemCount() != 1 {
- t.Fatalf("form items=%d; want 1", form.GetFormItemCount())
- }
- listItem, ok := form.GetFormItem(0).(*components.ListFormItem)
- if !ok {
- t.Fatalf("expected *components.ListFormItem, got %T", form.GetFormItem(0))
- }
- if got := listItem.GetItemCount(); got != len(candidates) {
- t.Fatalf("list items=%d; want %d", got, len(candidates))
- }
-}
-
-func TestShowDestinationFormAddsDestinationPageWithInput(t *testing.T) {
- app := tui.NewApp()
- pages := tview.NewPages()
-
- cfg := &config.Config{BaseDir: t.TempDir()}
- selected := &decryptCandidate{
- Manifest: &backup.Manifest{
- CreatedAt: time.Unix(1700000000, 0),
- EncryptionMode: "age",
- ProxmoxTargets: []string{"pve"},
- ScriptVersion: "1.0.0",
- },
- }
-
- showDestinationForm(app, pages, cfg, selected, "cfg", "sig", func(string) {})
-
- if !pages.HasPage("destination") {
- t.Fatalf("expected destination page to be present")
- }
- page := pages.GetPage("destination")
- flex, ok := page.(*tview.Flex)
- if !ok {
- t.Fatalf("expected *tview.Flex, got %T", page)
- }
- content := flex.GetItem(3)
- inner, ok := content.(*tview.Flex)
- if !ok {
- t.Fatalf("expected inner *tview.Flex, got %T", content)
- }
- form, ok := inner.GetItem(1).(*tview.Form)
- if !ok {
- t.Fatalf("expected *tview.Form, got %T", inner.GetItem(1))
- }
- if form.GetFormItemCount() < 1 {
- t.Fatalf("expected at least 1 form item")
- }
- field, ok := form.GetFormItem(0).(*tview.InputField)
- if !ok {
- t.Fatalf("expected first form item to be *tview.InputField, got %T", form.GetFormItem(0))
- }
- if field.GetLabel() != "Destination directory" {
- t.Fatalf("label=%q; want %q", field.GetLabel(), "Destination directory")
- }
-}
-
func TestPreparePlainBundleTUIRejectsInvalidCandidate(t *testing.T) {
logger := logging.New(types.LogLevelError, false)
ctx := context.Background()
diff --git a/internal/orchestrator/decrypt_workflow_ui.go b/internal/orchestrator/decrypt_workflow_ui.go
new file mode 100644
index 0000000..24d0377
--- /dev/null
+++ b/internal/orchestrator/decrypt_workflow_ui.go
@@ -0,0 +1,385 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "filippo.io/age"
+ "github.com/tis24dev/proxsave/internal/backup"
+ "github.com/tis24dev/proxsave/internal/config"
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+func selectBackupCandidateWithUI(ctx context.Context, ui BackupSelectionUI, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *decryptCandidate, err error) {
+ done := logging.DebugStart(logger, "select backup candidate (ui)", "requireEncrypted=%v", requireEncrypted)
+ defer func() { done(err) }()
+
+ pathOptions := buildDecryptPathOptions(cfg, logger)
+ if len(pathOptions) == 0 {
+ return nil, fmt.Errorf("no backup paths configured in backup.env")
+ }
+
+ for {
+ option, err := ui.SelectBackupSource(ctx, pathOptions)
+ if err != nil {
+ return nil, err
+ }
+
+ logger.Info("Scanning %s for backups...", option.Path)
+
+ var candidates []*decryptCandidate
+ scanErr := ui.RunTask(ctx, "Scanning backups", "Scanning backup source...", func(scanCtx context.Context, report ProgressReporter) error {
+ if option.IsRclone {
+ found, err := discoverRcloneBackups(scanCtx, cfg, option.Path, logger, report)
+ if err != nil {
+ return err
+ }
+ candidates = found
+ return nil
+ }
+
+ if report != nil {
+ report(fmt.Sprintf("Listing local path: %s", option.Path))
+ }
+ found, err := discoverBackupCandidates(logger, option.Path)
+ if err != nil {
+ return err
+ }
+ candidates = found
+ return nil
+ })
+
+ if scanErr != nil {
+ logger.Warning("Failed to inspect %s: %v", option.Path, scanErr)
+ _ = ui.ShowError(ctx, "Backup scan failed", fmt.Sprintf("Failed to inspect %s: %v", option.Path, scanErr))
+ if option.IsRclone {
+ // For rclone remotes, persistent failures are unlikely to self-heal,
+ // so remove the option to avoid a broken loop.
+ pathOptions = removeDecryptPathOption(pathOptions, option)
+ if len(pathOptions) == 0 {
+ return nil, fmt.Errorf("no usable backup sources available")
+ }
+ }
+ continue
+ }
+
+ if len(candidates) == 0 {
+ logger.Warning("No backups found in %s", option.Path)
+ _ = ui.ShowError(ctx, "No backups found", fmt.Sprintf("No backups found in %s.", option.Path))
+ pathOptions = removeDecryptPathOption(pathOptions, option)
+ if len(pathOptions) == 0 {
+ return nil, fmt.Errorf("no usable backup sources available")
+ }
+ continue
+ }
+
+ if requireEncrypted {
+ encrypted := filterEncryptedCandidates(candidates)
+ if len(encrypted) == 0 {
+ logger.Warning("No encrypted backups found in %s", option.Path)
+ _ = ui.ShowError(ctx, "No encrypted backups", fmt.Sprintf("No encrypted backups found in %s.", option.Path))
+ pathOptions = removeDecryptPathOption(pathOptions, option)
+ if len(pathOptions) == 0 {
+ return nil, fmt.Errorf("no usable backup sources available")
+ }
+ continue
+ }
+ candidates = encrypted
+ }
+
+ candidate, err = ui.SelectBackupCandidate(ctx, candidates)
+ if err != nil {
+ return nil, err
+ }
+ return candidate, nil
+ }
+}
+
+func ensureWritablePathWithUI(ctx context.Context, ui DecryptWorkflowUI, targetPath, description string) (string, error) {
+ current := filepath.Clean(targetPath)
+ failure := ""
+
+ for {
+ if _, err := restoreFS.Stat(current); errors.Is(err, os.ErrNotExist) {
+ return current, nil
+ } else if err != nil && !errors.Is(err, os.ErrExist) {
+ return "", fmt.Errorf("stat %s: %w", current, err)
+ }
+
+ decision, newPath, err := ui.ResolveExistingPath(ctx, current, description, failure)
+ if err != nil {
+ return "", err
+ }
+
+ switch decision {
+ case PathDecisionOverwrite:
+ if err := restoreFS.Remove(current); err != nil {
+ failure = fmt.Sprintf("Failed to remove existing %s: %v", description, err)
+ continue
+ }
+ return current, nil
+ case PathDecisionNewPath:
+ trimmed := strings.TrimSpace(newPath)
+ if trimmed == "" {
+ failure = fmt.Sprintf("New %s path cannot be empty", description)
+ continue
+ }
+ current = filepath.Clean(trimmed)
+ failure = ""
+ default:
+ return "", ErrDecryptAborted
+ }
+ }
+}
+
+func decryptArchiveWithSecretPrompt(ctx context.Context, encryptedPath, outputPath, displayName string, logger *logging.Logger, prompt func(ctx context.Context, displayName, previousError string) (string, error)) error {
+ promptError := ""
+ for {
+ secret, err := prompt(ctx, displayName, promptError)
+ if err != nil {
+ if errors.Is(err, ErrDecryptAborted) || errors.Is(err, input.ErrInputAborted) {
+ return ErrDecryptAborted
+ }
+ return err
+ }
+
+ secret = strings.TrimSpace(secret)
+ if secret == "" {
+ resetString(&secret)
+ promptError = "Input cannot be empty."
+ continue
+ }
+
+ identities, err := parseIdentityInput(secret)
+ resetString(&secret)
+ if err != nil {
+ promptError = fmt.Sprintf("Invalid key or passphrase: %v", err)
+ continue
+ }
+
+ if err := decryptWithIdentity(encryptedPath, outputPath, identities...); err != nil {
+ var noMatch *age.NoIdentityMatchError
+ if errors.Is(err, age.ErrIncorrectIdentity) || errors.As(err, &noMatch) {
+ promptError = "Provided key or passphrase does not match this archive."
+ continue
+ }
+ return err
+ }
+ return nil
+ }
+}
+
+func preparePlainBundleWithUI(ctx context.Context, cand *decryptCandidate, version string, logger *logging.Logger, ui interface {
+ PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error)
+}) (bundle *preparedBundle, err error) {
+ done := logging.DebugStart(logger, "prepare plain bundle (ui)", "source=%v rclone=%v", cand.Source, cand.IsRclone)
+ defer func() { done(err) }()
+
+ if cand == nil || cand.Manifest == nil {
+ return nil, fmt.Errorf("invalid backup candidate")
+ }
+
+ var rcloneCleanup func()
+ if cand.IsRclone && cand.Source == sourceBundle {
+ logger.Debug("Detected rclone backup, downloading...")
+ localPath, cleanup, err := downloadRcloneBackup(ctx, cand.BundlePath, logger)
+ if err != nil {
+ return nil, fmt.Errorf("failed to download rclone backup: %w", err)
+ }
+ rcloneCleanup = cleanup
+ cand.BundlePath = localPath
+ }
+
+ tempRoot := filepath.Join("/tmp", "proxsave")
+ if err := restoreFS.MkdirAll(tempRoot, 0o755); err != nil {
+ if rcloneCleanup != nil {
+ rcloneCleanup()
+ }
+ return nil, fmt.Errorf("create temp root: %w", err)
+ }
+
+ workDir, err := restoreFS.MkdirTemp(tempRoot, "proxmox-decrypt-*")
+ if err != nil {
+ if rcloneCleanup != nil {
+ rcloneCleanup()
+ }
+ return nil, fmt.Errorf("create temp dir: %w", err)
+ }
+
+ cleanup := func() {
+ _ = restoreFS.RemoveAll(workDir)
+ if rcloneCleanup != nil {
+ rcloneCleanup()
+ }
+ }
+
+ var staged stagedFiles
+ switch cand.Source {
+ case sourceBundle:
+ logger.Info("Extracting bundle %s", filepath.Base(cand.BundlePath))
+ staged, err = extractBundleToWorkdirWithLogger(cand.BundlePath, workDir, logger)
+ case sourceRaw:
+ logger.Info("Staging raw artifacts for %s", filepath.Base(cand.RawArchivePath))
+ staged, err = copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, logger)
+ default:
+ err = fmt.Errorf("unsupported candidate source")
+ }
+ if err != nil {
+ cleanup()
+ return nil, err
+ }
+
+ manifestCopy := *cand.Manifest
+ currentEncryption := strings.ToLower(manifestCopy.EncryptionMode)
+ logger.Info("Preparing archive %s for decryption (mode: %s)", manifestCopy.ArchivePath, statusFromManifest(&manifestCopy))
+
+ plainArchiveName := strings.TrimSuffix(filepath.Base(staged.ArchivePath), ".age")
+ plainArchivePath := filepath.Join(workDir, plainArchiveName)
+
+ if currentEncryption == "age" {
+ displayName := cand.DisplayBase
+ if strings.TrimSpace(displayName) == "" {
+ displayName = filepath.Base(manifestCopy.ArchivePath)
+ }
+ if err := decryptArchiveWithSecretPrompt(ctx, staged.ArchivePath, plainArchivePath, displayName, logger, ui.PromptDecryptSecret); err != nil {
+ cleanup()
+ return nil, err
+ }
+ } else {
+ if staged.ArchivePath != plainArchivePath {
+ if err := copyFile(restoreFS, staged.ArchivePath, plainArchivePath); err != nil {
+ cleanup()
+ return nil, fmt.Errorf("copy archive: %w", err)
+ }
+ }
+ }
+
+ archiveInfo, err := restoreFS.Stat(plainArchivePath)
+ if err != nil {
+ cleanup()
+ return nil, fmt.Errorf("stat decrypted archive: %w", err)
+ }
+
+ checksum, err := backup.GenerateChecksum(ctx, logger, plainArchivePath)
+ if err != nil {
+ cleanup()
+ return nil, fmt.Errorf("generate checksum: %w", err)
+ }
+
+ manifestCopy.ArchivePath = plainArchivePath
+ manifestCopy.ArchiveSize = archiveInfo.Size()
+ manifestCopy.SHA256 = checksum
+ manifestCopy.EncryptionMode = "none"
+ if version != "" {
+ manifestCopy.ScriptVersion = version
+ }
+
+ bundle = &preparedBundle{
+ ArchivePath: plainArchivePath,
+ Manifest: manifestCopy,
+ Checksum: checksum,
+ cleanup: cleanup,
+ }
+ return bundle, nil
+}
+
+func runDecryptWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui DecryptWorkflowUI) (err error) {
+ if cfg == nil {
+ return fmt.Errorf("configuration not available")
+ }
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+ done := logging.DebugStart(logger, "decrypt workflow (ui)", "version=%s", version)
+ defer func() { done(err) }()
+ defer func() {
+ if err == nil {
+ return
+ }
+ if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) {
+ err = ErrDecryptAborted
+ }
+ }()
+
+ candidate, err := selectBackupCandidateWithUI(ctx, ui, cfg, logger, true)
+ if err != nil {
+ return err
+ }
+
+ prepared, err := preparePlainBundleWithUI(ctx, candidate, version, logger, ui)
+ if err != nil {
+ return err
+ }
+ defer prepared.Cleanup()
+
+ defaultDir := "./decrypt"
+ if strings.TrimSpace(cfg.BaseDir) != "" {
+ defaultDir = filepath.Join(strings.TrimSpace(cfg.BaseDir), "decrypt")
+ }
+ destDir, err := ui.PromptDestinationDir(ctx, defaultDir)
+ if err != nil {
+ return err
+ }
+
+ if err := restoreFS.MkdirAll(destDir, 0o755); err != nil {
+ return fmt.Errorf("create destination directory: %w", err)
+ }
+ destDir, _ = filepath.Abs(destDir)
+ logger.Info("Destination directory: %s", destDir)
+
+ destArchivePath := filepath.Join(destDir, filepath.Base(prepared.ArchivePath))
+ destArchivePath, err = ensureWritablePathWithUI(ctx, ui, destArchivePath, "decrypted archive")
+ if err != nil {
+ return err
+ }
+
+ workDir := filepath.Dir(prepared.ArchivePath)
+ archiveBase := filepath.Base(destArchivePath)
+ tempArchivePath := filepath.Join(workDir, archiveBase)
+ if tempArchivePath != prepared.ArchivePath {
+ if err := moveFileSafe(prepared.ArchivePath, tempArchivePath); err != nil {
+ return fmt.Errorf("move decrypted archive within temp dir: %w", err)
+ }
+ }
+
+ manifestCopy := prepared.Manifest
+ manifestCopy.ArchivePath = destArchivePath
+
+ metadataPath := tempArchivePath + ".metadata"
+ if err := backup.CreateManifest(ctx, logger, &manifestCopy, metadataPath); err != nil {
+ return fmt.Errorf("write metadata: %w", err)
+ }
+
+ checksumPath := tempArchivePath + ".sha256"
+ if err := restoreFS.WriteFile(checksumPath, []byte(fmt.Sprintf("%s %s\n", prepared.Checksum, filepath.Base(tempArchivePath))), 0o640); err != nil {
+ return fmt.Errorf("write checksum file: %w", err)
+ }
+
+ logger.Info("Creating decrypted bundle...")
+ bundlePath, err := createBundle(ctx, logger, tempArchivePath)
+ if err != nil {
+ return err
+ }
+
+ logicalBundlePath := destArchivePath + ".bundle.tar"
+ targetBundlePath := strings.TrimSuffix(logicalBundlePath, ".bundle.tar") + ".decrypted.bundle.tar"
+ targetBundlePath, err = ensureWritablePathWithUI(ctx, ui, targetBundlePath, "decrypted bundle")
+ if err != nil {
+ return err
+ }
+
+ if err := restoreFS.Remove(targetBundlePath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ logger.Warning("Failed to remove existing bundle target: %v", err)
+ }
+ if err := moveFileSafe(bundlePath, targetBundlePath); err != nil {
+ return fmt.Errorf("move decrypted bundle: %w", err)
+ }
+
+ logger.Info("Decrypted bundle created: %s", targetBundlePath)
+ return nil
+}
diff --git a/internal/orchestrator/guards_cleanup.go b/internal/orchestrator/guards_cleanup.go
new file mode 100644
index 0000000..efb4d0e
--- /dev/null
+++ b/internal/orchestrator/guards_cleanup.go
@@ -0,0 +1,192 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+var (
+ cleanupGeteuid = os.Geteuid
+ cleanupStat = os.Stat
+ cleanupReadFile = os.ReadFile
+ cleanupRemoveAll = os.RemoveAll
+ cleanupSysUnmount = syscall.Unmount
+)
+
+// CleanupMountGuards removes ProxSave mount guards created under mountGuardBaseDir.
+//
+// Safety: this will only unmount guard bind mounts when they are the currently-visible
+// mount on the mountpoint (i.e. the guard is the top-most mount at that mountpoint).
+// If a real mount is stacked on top, the guard will be left in place.
+func CleanupMountGuards(ctx context.Context, logger *logging.Logger, dryRun bool) error {
+ _ = ctx // reserved for future timeouts/cancellation hooks
+
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+
+ if cleanupGeteuid() != 0 {
+ return fmt.Errorf("cleanup guards requires root privileges")
+ }
+
+ if _, err := cleanupStat(mountGuardBaseDir); err != nil {
+ if os.IsNotExist(err) {
+ logger.Info("No guard directory found at %s", mountGuardBaseDir)
+ return nil
+ }
+ return fmt.Errorf("stat guards dir: %w", err)
+ }
+
+ mountinfo, err := cleanupReadFile("/proc/self/mountinfo")
+ if err != nil {
+ return fmt.Errorf("read mountinfo: %w", err)
+ }
+
+ visibleMountpoints, hiddenMountpoints, totalGuardMounts := guardMountpointsFromMountinfo(string(mountinfo))
+ if totalGuardMounts == 0 {
+ if dryRun {
+ logger.Info("DRY RUN: would remove %s", mountGuardBaseDir)
+ return nil
+ }
+ if err := cleanupRemoveAll(mountGuardBaseDir); err != nil {
+ return fmt.Errorf("remove guards dir: %w", err)
+ }
+ logger.Info("Removed guard directory %s", mountGuardBaseDir)
+ return nil
+ }
+
+ var targets []string
+ dedup := make(map[string]struct{}, len(visibleMountpoints))
+ for _, mp := range visibleMountpoints {
+ mp = filepath.Clean(strings.TrimSpace(mp))
+ if mp == "" || mp == "." || mp == string(os.PathSeparator) {
+ continue
+ }
+ if _, ok := dedup[mp]; ok {
+ continue
+ }
+ dedup[mp] = struct{}{}
+ targets = append(targets, mp)
+ }
+ sort.Strings(targets)
+ for _, mp := range hiddenMountpoints {
+ mp = filepath.Clean(strings.TrimSpace(mp))
+ if mp == "" || mp == "." || mp == string(os.PathSeparator) {
+ continue
+ }
+ logger.Debug("Guard cleanup: guard mount at %s is hidden under another mount; skipping unmount", mp)
+ }
+
+ unmounted := 0
+ for _, mp := range targets {
+ if !isConfirmableDatastoreMountRoot(mp) {
+ logger.Debug("Guard cleanup: skip non-datastore mount root %s", mp)
+ continue
+ }
+
+ if dryRun {
+ logger.Info("DRY RUN: would unmount guard mount at %s", mp)
+ continue
+ }
+
+ if err := cleanupSysUnmount(mp, 0); err != nil {
+ // EINVAL: not a mountpoint (already unmounted).
+ if errno, ok := err.(syscall.Errno); ok && errno == syscall.EINVAL {
+ logger.Debug("Guard cleanup: %s is not a mountpoint (already unmounted)", mp)
+ continue
+ }
+ logger.Warning("Guard cleanup: failed to unmount %s: %v", mp, err)
+ continue
+ }
+ unmounted++
+ logger.Info("Guard cleanup: unmounted guard at %s", mp)
+ }
+
+ if dryRun {
+ logger.Info("DRY RUN: would remove %s", mountGuardBaseDir)
+ return nil
+ }
+
+ // If any guard mounts remain (for example hidden under a real mount), avoid removing the directory.
+ after, err := cleanupReadFile("/proc/self/mountinfo")
+ if err == nil {
+ _, _, remaining := guardMountpointsFromMountinfo(string(after))
+ if remaining > 0 {
+ logger.Warning("Guard cleanup: %d guard mount(s) still present; not removing %s", remaining, mountGuardBaseDir)
+ return nil
+ }
+ }
+
+ if err := cleanupRemoveAll(mountGuardBaseDir); err != nil {
+ return fmt.Errorf("remove guards dir: %w", err)
+ }
+ logger.Info("Removed guard directory %s (unmounted=%d)", mountGuardBaseDir, unmounted)
+ return nil
+}
+
+func guardMountpointsFromMountinfo(mountinfo string) (visible, hidden []string, guardMounts int) {
+ prefix := mountGuardBaseDir + string(os.PathSeparator)
+ type mountpointInfo struct {
+ topmostID int
+ topmostIsGuard bool
+ hasGuard bool
+ }
+
+ mountpoints := make(map[string]*mountpointInfo)
+ for _, line := range strings.Split(mountinfo, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
+ continue
+ }
+
+ mountID, err := strconv.Atoi(fields[0])
+ if err != nil {
+ continue
+ }
+ root := unescapeProcPath(fields[3])
+ mp := unescapeProcPath(fields[4])
+
+ isGuard := root == mountGuardBaseDir || strings.HasPrefix(root, prefix)
+ if isGuard {
+ guardMounts++
+ }
+
+ info := mountpoints[mp]
+ if info == nil {
+ info = &mountpointInfo{topmostID: -1}
+ mountpoints[mp] = info
+ }
+ if mountID > info.topmostID {
+ info.topmostID = mountID
+ info.topmostIsGuard = isGuard
+ }
+ if isGuard {
+ info.hasGuard = true
+ }
+ }
+
+ for mp, info := range mountpoints {
+ if info.topmostIsGuard {
+ visible = append(visible, mp)
+ continue
+ }
+ if info.hasGuard {
+ hidden = append(hidden, mp)
+ }
+ }
+ sort.Strings(visible)
+ sort.Strings(hidden)
+ return visible, hidden, guardMounts
+}
diff --git a/internal/orchestrator/guards_cleanup_test.go b/internal/orchestrator/guards_cleanup_test.go
new file mode 100644
index 0000000..7e4e968
--- /dev/null
+++ b/internal/orchestrator/guards_cleanup_test.go
@@ -0,0 +1,143 @@
+package orchestrator
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+ "github.com/tis24dev/proxsave/internal/types"
+)
+
+func TestGuardMountpointsFromMountinfo_VisibleAndHidden(t *testing.T) {
+ t.Parallel()
+
+ mountinfo := strings.Join([]string{
+ "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/visible rw - ext4 /dev/sda1 rw",
+ "20 1 0:1 " + mountGuardBaseDir + "/g2 /mnt/hidden rw - ext4 /dev/sda1 rw",
+ "30 1 0:1 / /mnt/hidden rw - ext4 /dev/sdb1 rw",
+ }, "\n")
+
+ visible, hidden, mounts := guardMountpointsFromMountinfo(mountinfo)
+ if mounts != 2 {
+ t.Fatalf("guard mounts=%d want 2 (visible+hidden guard entries)", mounts)
+ }
+ if len(visible) != 1 || visible[0] != "/mnt/visible" {
+ t.Fatalf("visible=%#v want [\"/mnt/visible\"]", visible)
+ }
+ if len(hidden) != 1 || hidden[0] != "/mnt/hidden" {
+ t.Fatalf("hidden=%#v want [\"/mnt/hidden\"]", hidden)
+ }
+}
+
+func TestGuardMountpointsFromMountinfo_UnescapesMountpoint(t *testing.T) {
+ t.Parallel()
+
+ mountinfo := "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/with\\040space rw - ext4 /dev/sda1 rw\n"
+ visible, hidden, mounts := guardMountpointsFromMountinfo(mountinfo)
+ if mounts != 1 {
+ t.Fatalf("guard mounts=%d want 1", mounts)
+ }
+ if len(hidden) != 0 {
+ t.Fatalf("hidden=%#v want empty", hidden)
+ }
+ if len(visible) != 1 || visible[0] != "/mnt/with space" {
+ t.Fatalf("visible=%#v want [\"/mnt/with space\"]", visible)
+ }
+}
+
+func TestCleanupMountGuards_UnmountsVisibleAndRemovesDirWhenNoRemaining(t *testing.T) {
+ origGeteuid := cleanupGeteuid
+ origStat := cleanupStat
+ origReadFile := cleanupReadFile
+ origRemoveAll := cleanupRemoveAll
+ origUnmount := cleanupSysUnmount
+ t.Cleanup(func() {
+ cleanupGeteuid = origGeteuid
+ cleanupStat = origStat
+ cleanupReadFile = origReadFile
+ cleanupRemoveAll = origRemoveAll
+ cleanupSysUnmount = origUnmount
+ })
+
+ cleanupGeteuid = func() int { return 0 }
+ cleanupStat = func(string) (os.FileInfo, error) { return nil, nil }
+
+ initialMountinfo := "10 1 0:1 " + mountGuardBaseDir + "/g1 /mnt/visible rw - ext4 /dev/sda1 rw\n"
+ afterMountinfo := "30 1 0:1 / / rw - ext4 /dev/sda1 rw\n"
+ readCount := 0
+ cleanupReadFile = func(path string) ([]byte, error) {
+ if path != "/proc/self/mountinfo" {
+ return nil, os.ErrNotExist
+ }
+ readCount++
+ if readCount == 1 {
+ return []byte(initialMountinfo), nil
+ }
+ return []byte(afterMountinfo), nil
+ }
+
+ var unmounted []string
+ cleanupSysUnmount = func(target string, flags int) error {
+ _ = flags
+ unmounted = append(unmounted, target)
+ return nil
+ }
+
+ var removed []string
+ cleanupRemoveAll = func(path string) error {
+ removed = append(removed, path)
+ return nil
+ }
+
+ logger := logging.New(types.LogLevelError, false)
+ if err := CleanupMountGuards(context.Background(), logger, false); err != nil {
+ t.Fatalf("CleanupMountGuards error: %v", err)
+ }
+ if len(unmounted) != 1 || unmounted[0] != "/mnt/visible" {
+ t.Fatalf("unmounted=%#v want [\"/mnt/visible\"]", unmounted)
+ }
+ if len(removed) != 1 || removed[0] != mountGuardBaseDir {
+ t.Fatalf("removed=%#v want [%q]", removed, mountGuardBaseDir)
+ }
+}
+
+func TestCleanupMountGuards_DoesNotUnmountHiddenGuards(t *testing.T) {
+ origGeteuid := cleanupGeteuid
+ origStat := cleanupStat
+ origReadFile := cleanupReadFile
+ origRemoveAll := cleanupRemoveAll
+ origUnmount := cleanupSysUnmount
+ t.Cleanup(func() {
+ cleanupGeteuid = origGeteuid
+ cleanupStat = origStat
+ cleanupReadFile = origReadFile
+ cleanupRemoveAll = origRemoveAll
+ cleanupSysUnmount = origUnmount
+ })
+
+ cleanupGeteuid = func() int { return 0 }
+ cleanupStat = func(string) (os.FileInfo, error) { return nil, nil }
+
+ hiddenMountinfo := strings.Join([]string{
+ "20 1 0:1 " + mountGuardBaseDir + "/g2 /mnt/hidden rw - ext4 /dev/sda1 rw",
+ "30 1 0:1 / /mnt/hidden rw - ext4 /dev/sdb1 rw",
+ }, "\n")
+ cleanupReadFile = func(string) ([]byte, error) { return []byte(hiddenMountinfo), nil }
+
+ cleanupSysUnmount = func(string, int) error {
+ t.Fatalf("unexpected unmount")
+ return nil
+ }
+
+ cleanupRemoveAll = func(string) error {
+ t.Fatalf("unexpected removeAll")
+ return nil
+ }
+
+ logger := logging.New(types.LogLevelError, false)
+ if err := CleanupMountGuards(context.Background(), logger, false); err != nil {
+ t.Fatalf("CleanupMountGuards error: %v", err)
+ }
+}
diff --git a/internal/orchestrator/helpers_test.go b/internal/orchestrator/helpers_test.go
index 73996d1..a01050c 100644
--- a/internal/orchestrator/helpers_test.go
+++ b/internal/orchestrator/helpers_test.go
@@ -160,7 +160,7 @@ func TestPathMatchesCategory(t *testing.T) {
name: "absolute path match",
filePath: "/etc/hosts",
category: Category{Paths: []string{"/etc/hosts"}},
- want: false, // current implementation expects ./-prefixed matches
+ want: true, // absolute paths are normalized to ./ for matching
},
{
name: "exact match with prefix",
@@ -356,6 +356,9 @@ func TestGetStorageModeCategories(t *testing.T) {
if !pbsIDs["pbs_config"] {
t.Error("PBS storage mode should include pbs_config")
}
+ if !pbsIDs["pbs_remotes"] {
+ t.Error("PBS storage mode should include pbs_remotes (sync jobs depend on remotes)")
+ }
if !pbsIDs["filesystem"] {
t.Error("PBS storage mode should include filesystem")
}
diff --git a/internal/orchestrator/ifupdown2_nodad_patch_test.go b/internal/orchestrator/ifupdown2_nodad_patch_test.go
index 957e516..fe1358f 100644
--- a/internal/orchestrator/ifupdown2_nodad_patch_test.go
+++ b/internal/orchestrator/ifupdown2_nodad_patch_test.go
@@ -1,6 +1,7 @@
package orchestrator
import (
+ "os"
"strings"
"testing"
"time"
@@ -8,6 +9,7 @@ import (
func TestPatchIfupdown2NlcacheNodadSignature_AppliesAndBacksUp(t *testing.T) {
fs := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fs.Root) })
const nlcachePath = "/usr/share/ifupdown2/lib/nlcache.py"
orig := []byte("x\n" +
@@ -51,6 +53,7 @@ func TestPatchIfupdown2NlcacheNodadSignature_AppliesAndBacksUp(t *testing.T) {
func TestPatchIfupdown2NlcacheNodadSignature_SkipsIfAlreadyPatched(t *testing.T) {
fs := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fs.Root) })
const nlcachePath = "/usr/share/ifupdown2/lib/nlcache.py"
orig := []byte("def addr_add_dry_run(self, ifname, addr, broadcast=None, peer=None, scope=None, preferred_lifetime=None, metric=None, nodad=False):\n")
diff --git a/internal/orchestrator/log_parser.go b/internal/orchestrator/log_parser.go
index 6c90cfc..4222087 100644
--- a/internal/orchestrator/log_parser.go
+++ b/internal/orchestrator/log_parser.go
@@ -118,33 +118,93 @@ func classifyLogLine(line string) (entryType, message string) {
return "", ""
}
- // Try to match both formats:
- // - Go format: "[2025-11-14 10:30:45] WARNING message"
- // - Bash format: "[WARNING] message"
- for _, tag := range []struct {
- Type string
- Tags []string
- }{
- {"error", []string{"[ERROR]", "[Error]", "[error]", "ERROR", "Error"}},
- {"warning", []string{"[WARNING]", "[Warning]", "[warning]", "WARNING", "Warning"}},
- } {
- for _, marker := range tag.Tags {
- if idx := strings.Index(line, marker); idx != -1 {
- // Extract message after the marker
- afterMarker := idx + len(marker)
- if afterMarker < len(line) {
- msg := strings.TrimSpace(line[afterMarker:])
- msg = sanitizeLogMessage(msg)
- if msg != "" {
- return tag.Type, msg
- }
+ // Only count issues from non-debug lines.
+ //
+ // Supported formats:
+ // - Go file logger: "[2025-11-14 10:30:45] WARNING message"
+ // - Legacy/Bash: "[WARNING] message"
+ // - Mixed: "[2025-11-14 10:30:45] [WARNING] message"
+
+ // 1) Pure bracketed legacy format (no timestamp).
+ if t, msg := classifyBracketedIssueLine(line); t != "" {
+ return t, msg
+ }
+
+ // 2) Go logger format with timestamp prefix.
+ if strings.HasPrefix(line, "[") {
+ if closeIdx := strings.Index(line, "]"); closeIdx != -1 {
+ rest := strings.TrimSpace(line[closeIdx+1:])
+ if rest == "" {
+ return "", ""
+ }
+
+ // Timestamp + "[WARNING]/[ERROR]" legacy style.
+ if t, msg := classifyBracketedIssueLine(rest); t != "" {
+ return t, msg
+ }
+
+ levelToken, msg := splitFirstToken(rest)
+ if levelToken == "" {
+ return "", ""
+ }
+
+ switch strings.ToUpper(levelToken) {
+ case "DEBUG":
+ return "", ""
+ case "WARNING":
+ msg = sanitizeLogMessage(msg)
+ if msg == "" {
+ return "", ""
}
+ return "warning", msg
+ case "ERROR", "CRITICAL":
+ msg = sanitizeLogMessage(msg)
+ if msg == "" {
+ return "", ""
+ }
+ return "error", msg
+ default:
+ return "", ""
}
}
}
+
+ return "", ""
+}
+
+func classifyBracketedIssueLine(line string) (entryType, message string) {
+ if strings.HasPrefix(line, "[ERROR]") || strings.HasPrefix(line, "[Error]") || strings.HasPrefix(line, "[error]") {
+ msg := strings.TrimSpace(line[strings.Index(line, "]")+1:])
+ msg = sanitizeLogMessage(msg)
+ if msg == "" {
+ return "", ""
+ }
+ return "error", msg
+ }
+ if strings.HasPrefix(line, "[WARNING]") || strings.HasPrefix(line, "[Warning]") || strings.HasPrefix(line, "[warning]") {
+ msg := strings.TrimSpace(line[strings.Index(line, "]")+1:])
+ msg = sanitizeLogMessage(msg)
+ if msg == "" {
+ return "", ""
+ }
+ return "warning", msg
+ }
return "", ""
}
+func splitFirstToken(value string) (token, rest string) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "", ""
+ }
+ for i := 0; i < len(value); i++ {
+ if value[i] == ' ' || value[i] == '\t' || value[i] == '\n' || value[i] == '\r' {
+ return value[:i], strings.TrimSpace(value[i:])
+ }
+ }
+ return value, ""
+}
+
// sanitizeLogMessage cleans up log messages
func sanitizeLogMessage(msg string) string {
msg = strings.TrimSpace(msg)
diff --git a/internal/orchestrator/log_parser_test.go b/internal/orchestrator/log_parser_test.go
index ab7ade6..fbe1c54 100644
--- a/internal/orchestrator/log_parser_test.go
+++ b/internal/orchestrator/log_parser_test.go
@@ -177,6 +177,12 @@ func TestClassifyLogLine(t *testing.T) {
wantType: "",
wantMessage: "",
},
+ {
+ name: "DEBUG line with Error text (should be ignored)",
+ line: "[2025-11-10 14:30:00] DEBUG Something failed: Error: Corosync config does not exist",
+ wantType: "",
+ wantMessage: "",
+ },
}
for _, tt := range tests {
diff --git a/internal/orchestrator/mount_guard.go b/internal/orchestrator/mount_guard.go
new file mode 100644
index 0000000..bc352f9
--- /dev/null
+++ b/internal/orchestrator/mount_guard.go
@@ -0,0 +1,464 @@
+package orchestrator
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+const mountGuardBaseDir = "/var/lib/proxsave/guards"
+const mountGuardMountAttemptTimeout = 10 * time.Second
+
+func maybeApplyPBSDatastoreMountGuards(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot, destRoot string, dryRun bool) error {
+ if plan == nil || plan.SystemType != SystemTypePBS || !plan.HasCategoryID("datastore_pbs") {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ return nil
+ }
+ if filepath.Clean(strings.TrimSpace(destRoot)) != string(os.PathSeparator) {
+ if logger != nil {
+ logger.Debug("Skipping PBS mount guards: restore destination is not system root (dest=%s)", destRoot)
+ }
+ return nil
+ }
+
+ if dryRun {
+ if logger != nil {
+ logger.Info("Dry run enabled: skipping PBS mount guards")
+ }
+ return nil
+ }
+ if !isRealRestoreFS(restoreFS) {
+ if logger != nil {
+ logger.Debug("Skipping PBS mount guards: non-system filesystem in use")
+ }
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ if logger != nil {
+ logger.Warning("Skipping PBS mount guards: requires root privileges")
+ }
+ return nil
+ }
+
+ stagePath := filepath.Join(stageRoot, "etc/proxmox-backup/datastore.cfg")
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read staged datastore.cfg: %w", err)
+ }
+ if strings.TrimSpace(string(data)) == "" {
+ return nil
+ }
+
+ normalized, _ := normalizePBSDatastoreCfgContent(string(data))
+ blocks, err := parsePBSDatastoreCfgBlocks(normalized)
+ if err != nil {
+ return err
+ }
+ if len(blocks) == 0 {
+ return nil
+ }
+
+ var fstabMounts map[string]struct{}
+ var mountpointCandidates []string
+ currentFstab := filepath.Join(destRoot, "etc", "fstab")
+ if mounts, err := fstabMountpointsSet(currentFstab); err != nil {
+ if logger != nil {
+ logger.Warning("PBS mount guard: unable to parse current fstab %s: %v (continuing without fstab cross-check)", currentFstab, err)
+ }
+ } else {
+ fstabMounts = mounts
+ for mp := range mounts {
+ if mp == "" || mp == "." || mp == string(os.PathSeparator) {
+ continue
+ }
+ if !isConfirmableDatastoreMountRoot(mp) {
+ continue
+ }
+ mountpointCandidates = append(mountpointCandidates, mp)
+ }
+ sortByLengthDesc(mountpointCandidates)
+ }
+
+ protected := make(map[string]struct{})
+ for _, block := range blocks {
+ dsPath := filepath.Clean(strings.TrimSpace(block.Path))
+ if dsPath == "" || dsPath == "." || dsPath == string(os.PathSeparator) {
+ continue
+ }
+
+ guardTarget := ""
+ if len(mountpointCandidates) > 0 {
+ guardTarget = firstFstabMountpointMatch(dsPath, mountpointCandidates)
+ }
+ if guardTarget == "" {
+ guardTarget = pbsMountGuardRootForDatastorePath(dsPath)
+ }
+ guardTarget = filepath.Clean(strings.TrimSpace(guardTarget))
+ if guardTarget == "" || guardTarget == "." || guardTarget == string(os.PathSeparator) {
+ continue
+ }
+ if _, seen := protected[guardTarget]; seen {
+ continue
+ }
+
+ // If we can parse /etc/fstab, only guard mountpoints that exist there.
+ // This avoids making local (rootfs) datastores immutable by mistake.
+ if fstabMounts != nil {
+ if _, ok := fstabMounts[guardTarget]; !ok {
+ continue
+ }
+ }
+
+ if err := os.MkdirAll(guardTarget, 0o755); err != nil {
+ if logger != nil {
+ logger.Warning("PBS mount guard: unable to create mountpoint directory %s: %v", guardTarget, err)
+ }
+ continue
+ }
+
+ onRootFS, _, devErr := isPathOnRootFilesystem(guardTarget)
+ if devErr != nil {
+ if logger != nil {
+ logger.Warning("PBS mount guard: unable to determine filesystem device for %s: %v", guardTarget, devErr)
+ }
+ continue
+ }
+ if !onRootFS {
+ continue
+ }
+
+ mounted, mountErr := isMounted(guardTarget)
+ if mountErr != nil && logger != nil {
+ logger.Warning("PBS mount guard: unable to check mount status for %s: %v (continuing)", guardTarget, mountErr)
+ }
+ if mountErr == nil && mounted {
+ if logger != nil {
+ logger.Debug("PBS mount guard: mountpoint %s already mounted, skipping guard", guardTarget)
+ }
+ continue
+ }
+
+ // Best-effort attempt to mount now (the entry may have just been restored to /etc/fstab).
+ // If the storage is online, this avoids applying guards on mountpoints that would mount cleanly.
+ mountCtx, cancel := context.WithTimeout(ctx, mountGuardMountAttemptTimeout)
+ out, attemptErr := restoreCmd.Run(mountCtx, "mount", guardTarget)
+ cancel()
+ if attemptErr == nil {
+ onRootFSNow, _, devErrNow := isPathOnRootFilesystem(guardTarget)
+ if devErrNow == nil && !onRootFSNow {
+ if logger != nil {
+ logger.Info("PBS mount guard: mountpoint %s is now mounted (mount attempt succeeded)", guardTarget)
+ }
+ continue
+ }
+ if mountedNow, mountErrNow := isMounted(guardTarget); mountErrNow == nil && mountedNow {
+ if logger != nil {
+ logger.Info("PBS mount guard: mountpoint %s is now mounted (mount attempt succeeded)", guardTarget)
+ }
+ continue
+ }
+ } else {
+ if logger != nil {
+ if errors.Is(mountCtx.Err(), context.DeadlineExceeded) {
+ logger.Warning("PBS mount guard: mount attempt timed out for %s after %s", guardTarget, mountGuardMountAttemptTimeout)
+ } else {
+ trimmed := strings.TrimSpace(string(out))
+ if trimmed != "" {
+ logger.Debug("PBS mount guard: mount attempt failed for %s: %v (output=%s)", guardTarget, attemptErr, trimmed)
+ } else {
+ logger.Debug("PBS mount guard: mount attempt failed for %s: %v", guardTarget, attemptErr)
+ }
+ }
+ }
+ }
+
+ if logger != nil {
+ logger.Info("PBS mount guard: mountpoint %s offline, applying guard bind mount", guardTarget)
+ }
+
+ if err := guardMountPoint(ctx, guardTarget); err != nil {
+ if logger != nil {
+ logger.Warning("PBS mount guard: failed to bind-mount guard on %s: %v; falling back to chattr +i", guardTarget, err)
+ }
+ if _, fallbackErr := restoreCmd.Run(ctx, "chattr", "+i", guardTarget); fallbackErr != nil {
+ if logger != nil {
+ logger.Warning("PBS mount guard: failed to set immutable attribute on %s: %v", guardTarget, fallbackErr)
+ }
+ continue
+ }
+ protected[guardTarget] = struct{}{}
+ if logger != nil {
+ logger.Warning("PBS mount guard: %s resolves to root filesystem (mount missing?) — marked immutable (chattr +i) to prevent writes until storage is available", guardTarget)
+ }
+ continue
+ }
+
+ protected[guardTarget] = struct{}{}
+ if logger != nil {
+ if entries, err := os.ReadDir(guardTarget); err == nil && len(entries) > 0 {
+ logger.Warning("PBS mount guard: guard mount point %s is not empty (entries=%d)", guardTarget, len(entries))
+ }
+ logger.Warning("PBS mount guard: %s resolves to root filesystem (mount missing?) — bind-mounted a read-only guard to prevent writes until storage is available", guardTarget)
+ }
+ }
+
+ return nil
+}
+
+func guardMountPoint(ctx context.Context, guardTarget string) error {
+ target := filepath.Clean(strings.TrimSpace(guardTarget))
+ if target == "" || target == "." || target == string(os.PathSeparator) {
+ return fmt.Errorf("invalid guard target: %q", guardTarget)
+ }
+
+ mounted, err := isMounted(target)
+ if err != nil {
+ return fmt.Errorf("check mount status: %w", err)
+ }
+ if mounted {
+ return nil
+ }
+
+ guardDir := guardDirForTarget(target)
+ if err := os.MkdirAll(guardDir, 0o755); err != nil {
+ return fmt.Errorf("mkdir guard dir: %w", err)
+ }
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return fmt.Errorf("mkdir target: %w", err)
+ }
+
+ // Bind mount guard directory over the mountpoint to avoid writes to the underlying rootfs path.
+ if err := syscall.Mount(guardDir, target, "", syscall.MS_BIND, ""); err != nil {
+ return fmt.Errorf("bind mount guard: %w", err)
+ }
+
+ // Make the bind mount read-only to ensure PBS cannot write backup data to the guard directory.
+ remountFlags := uintptr(syscall.MS_BIND | syscall.MS_REMOUNT | syscall.MS_RDONLY | syscall.MS_NODEV | syscall.MS_NOSUID | syscall.MS_NOEXEC)
+ if err := syscall.Mount("", target, "", remountFlags, ""); err != nil {
+ _ = syscall.Unmount(target, 0)
+ return fmt.Errorf("remount guard read-only: %w", err)
+ }
+
+ return nil
+}
+
+func guardDirForTarget(target string) string {
+ sum := sha256.Sum256([]byte(target))
+ id := fmt.Sprintf("%x", sum[:8])
+ base := filepath.Base(target)
+ if base == "" || base == "." || base == string(os.PathSeparator) {
+ base = "guard"
+ }
+ return filepath.Join(mountGuardBaseDir, fmt.Sprintf("%s-%s", base, id))
+}
+
+func isMounted(path string) (bool, error) {
+ data, err := os.ReadFile("/proc/self/mountinfo")
+ if err == nil {
+ return isMountedFromMountinfo(string(data), path), nil
+ }
+
+ mounted, mountsErr := isMountedFromProcMounts(path)
+ if mountsErr == nil {
+ return mounted, nil
+ }
+
+ // Prefer reporting the mountinfo error, but keep the /proc/mounts error context too.
+ if errors.Is(err, os.ErrNotExist) {
+ return false, mountsErr
+ }
+ return false, fmt.Errorf("mountinfo=%v mounts=%v", err, mountsErr)
+}
+
+func isMountedFromMountinfo(mountinfo, path string) bool {
+ target := filepath.Clean(strings.TrimSpace(path))
+ if target == "" || target == "." {
+ return false
+ }
+
+ for _, line := range strings.Split(mountinfo, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
+ continue
+ }
+ mp := unescapeProcPath(fields[4])
+ if filepath.Clean(mp) == target {
+ return true
+ }
+ }
+ return false
+}
+
+func isMountedFromProcMounts(path string) (bool, error) {
+ data, err := os.ReadFile("/proc/mounts")
+ if err != nil {
+ return false, err
+ }
+
+ target := filepath.Clean(strings.TrimSpace(path))
+ if target == "" || target == "." {
+ return false, nil
+ }
+
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+ mp := unescapeProcPath(fields[1])
+ if filepath.Clean(mp) == target {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func unescapeProcPath(s string) string {
+ // /proc/self/mountinfo uses octal escapes: \040, \011, \012, \134.
+ // Keep it minimal: decode any \XYZ sequence where XYZ are octal digits and the value fits into a byte (0-255).
+ if !strings.Contains(s, "\\") {
+ return s
+ }
+
+ var b strings.Builder
+ b.Grow(len(s))
+ for i := 0; i < len(s); {
+ if s[i] != '\\' || i+3 >= len(s) {
+ _ = b.WriteByte(s[i])
+ i++
+ continue
+ }
+
+ oct := s[i+1 : i+4]
+ if oct[0] < '0' || oct[0] > '7' || oct[1] < '0' || oct[1] > '7' || oct[2] < '0' || oct[2] > '7' {
+ _ = b.WriteByte(s[i])
+ i++
+ continue
+ }
+
+ val := (int(oct[0]-'0') << 6) | (int(oct[1]-'0') << 3) | int(oct[2]-'0')
+ if val > 255 {
+ _ = b.WriteByte(s[i])
+ i++
+ continue
+ }
+ _ = b.WriteByte(byte(val))
+ i += 4
+ }
+ return b.String()
+}
+
+func fstabMountpointsSet(path string) (map[string]struct{}, error) {
+ entries, _, err := parseFstab(path)
+ if err != nil {
+ return nil, err
+ }
+
+ out := make(map[string]struct{}, len(entries))
+ for _, entry := range entries {
+ mp := filepath.Clean(strings.TrimSpace(entry.MountPoint))
+ if mp == "" || mp == "." {
+ continue
+ }
+ out[mp] = struct{}{}
+ }
+ return out, nil
+}
+
+func pbsMountGuardRootForDatastorePath(path string) string {
+ p := filepath.Clean(strings.TrimSpace(path))
+ if p == "" || p == "." || p == string(os.PathSeparator) {
+ return ""
+ }
+
+ switch {
+ case strings.HasPrefix(p, "/mnt/"):
+ return mountRootWithPrefix(p, "/mnt/")
+ case strings.HasPrefix(p, "/media/"):
+ return mountRootWithPrefix(p, "/media/")
+ case strings.HasPrefix(p, "/run/media/"):
+ rest := strings.TrimPrefix(p, "/run/media/")
+ parts := splitPath(rest)
+ if len(parts) == 0 {
+ return ""
+ }
+ if len(parts) == 1 {
+ return filepath.Join("/run/media", parts[0])
+ }
+ return filepath.Join("/run/media", parts[0], parts[1])
+ default:
+ return ""
+ }
+}
+
+func mountRootWithPrefix(path, prefix string) string {
+ rest := strings.TrimPrefix(path, prefix)
+ parts := splitPath(rest)
+ if len(parts) == 0 {
+ return ""
+ }
+ return filepath.Join(strings.TrimSuffix(prefix, "/"), parts[0])
+}
+
+func splitPath(rest string) []string {
+ rest = strings.Trim(rest, "/")
+ if rest == "" {
+ return nil
+ }
+ var parts []string
+ for _, p := range strings.Split(rest, "/") {
+ if strings.TrimSpace(p) == "" {
+ continue
+ }
+ parts = append(parts, p)
+ }
+ return parts
+}
+
+func sortByLengthDesc(items []string) {
+ if len(items) < 2 {
+ return
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return len(items[i]) > len(items[j])
+ })
+}
+
+func firstFstabMountpointMatch(datastorePath string, mountpoints []string) string {
+ ds := filepath.Clean(strings.TrimSpace(datastorePath))
+ if ds == "" || ds == "." || ds == string(os.PathSeparator) {
+ return ""
+ }
+
+ for _, mp := range mountpoints {
+ if mp == "" || mp == "." || mp == string(os.PathSeparator) {
+ continue
+ }
+ if ds == mp || strings.HasPrefix(ds, mp+string(os.PathSeparator)) {
+ return mp
+ }
+ }
+ return ""
+}
diff --git a/internal/orchestrator/network_apply.go b/internal/orchestrator/network_apply.go
index 22de576..ad1b2e9 100644
--- a/internal/orchestrator/network_apply.go
+++ b/internal/orchestrator/network_apply.go
@@ -68,386 +68,6 @@ func shouldAttemptNetworkApply(plan *RestorePlan) bool {
return plan.HasCategoryID("network")
}
-func maybeApplyNetworkConfigCLI(ctx context.Context, reader *bufio.Reader, logger *logging.Logger, plan *RestorePlan, safetyBackup, networkRollbackBackup *SafetyBackupResult, stageRoot, archivePath string, dryRun bool) (err error) {
- if !shouldAttemptNetworkApply(plan) {
- if logger != nil {
- logger.Debug("Network safe apply (CLI): skipped (network category not selected)")
- }
- return nil
- }
- done := logging.DebugStart(logger, "network safe apply (cli)", "dryRun=%v euid=%d archive=%s", dryRun, os.Geteuid(), strings.TrimSpace(archivePath))
- defer func() { done(err) }()
-
- if !isRealRestoreFS(restoreFS) {
- logger.Debug("Skipping live network apply: non-system filesystem in use")
- return nil
- }
- if dryRun {
- logger.Info("Dry run enabled: skipping live network apply")
- return nil
- }
- if os.Geteuid() != 0 {
- logger.Warning("Skipping live network apply: requires root privileges")
- return nil
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Resolve rollback backup paths")
- networkRollbackPath := ""
- if networkRollbackBackup != nil {
- networkRollbackPath = strings.TrimSpace(networkRollbackBackup.BackupPath)
- }
- fullRollbackPath := ""
- if safetyBackup != nil {
- fullRollbackPath = strings.TrimSpace(safetyBackup.BackupPath)
- }
- logging.DebugStep(logger, "network safe apply (cli)", "Rollback backup resolved: network=%q full=%q", networkRollbackPath, fullRollbackPath)
- if networkRollbackPath == "" && fullRollbackPath == "" {
- logger.Warning("Skipping live network apply: rollback backup not available")
- if strings.TrimSpace(stageRoot) != "" {
- logger.Info("Network configuration is staged; skipping NIC repair/apply due to missing rollback backup.")
- return nil
- }
- repairNow, err := promptYesNo(ctx, reader, "Attempt NIC name repair in restored network config files now (no reload)? (y/N): ")
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User choice: repairNow=%v", repairNow)
- if repairNow {
- _ = maybeRepairNICNamesCLI(ctx, reader, logger, archivePath)
- }
- logger.Info("Skipping live network apply (you can reboot or apply manually later).")
- return nil
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Prompt: apply network now with rollback timer")
- rollbackSeconds := int(defaultNetworkRollbackTimeout.Seconds())
- fmt.Println()
- fmt.Println("Network restore: a restored network configuration is ready to apply.")
- if strings.TrimSpace(stageRoot) != "" {
- fmt.Printf("Source: %s (will be copied to /etc and applied)\n", strings.TrimSpace(stageRoot))
- }
- fmt.Println("This will reload networking immediately (no reboot).")
- fmt.Println("WARNING: This may change the active IP and disconnect SSH/Web sessions.")
- fmt.Printf("After applying, type COMMIT within %ds or ProxSave will roll back automatically.\n", rollbackSeconds)
- fmt.Println("Recommendation: run this step from the local console/IPMI, not over SSH.")
- applyNow, err := promptYesNoWithCountdown(ctx, reader, logger, "Apply network configuration now?", 90*time.Second, false)
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User choice: applyNow=%v", applyNow)
- if !applyNow {
- if strings.TrimSpace(stageRoot) == "" {
- repairNow, err := promptYesNo(ctx, reader, "Attempt NIC name repair in restored network config files now (no reload)? (y/N): ")
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User choice: repairNow=%v", repairNow)
- if repairNow {
- _ = maybeRepairNICNamesCLI(ctx, reader, logger, archivePath)
- }
- } else {
- logger.Info("Network configuration is staged (not yet written to /etc); skipping NIC repair prompt.")
- }
- logger.Info("Skipping live network apply (you can apply later).")
- return nil
- }
-
- rollbackPath := networkRollbackPath
- if rollbackPath == "" {
- if fullRollbackPath == "" {
- logger.Warning("Skipping live network apply: rollback backup not available")
- return nil
- }
- logging.DebugStep(logger, "network safe apply (cli)", "Prompt: network-only rollback missing; allow full rollback backup fallback")
- ok, err := promptYesNo(ctx, reader, "Network-only rollback backup not available. Use full safety backup for rollback instead (may revert other restored categories)? (y/N): ")
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User choice: allowFullRollback=%v", ok)
- if !ok {
- repairNow, err := promptYesNo(ctx, reader, "Attempt NIC name repair in restored network config files now (no reload)? (y/N): ")
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User choice: repairNow=%v", repairNow)
- if repairNow {
- _ = maybeRepairNICNamesCLI(ctx, reader, logger, archivePath)
- }
- logger.Info("Skipping live network apply (you can reboot or apply manually later).")
- return nil
- }
- rollbackPath = fullRollbackPath
- }
- logging.DebugStep(logger, "network safe apply (cli)", "Selected rollback backup: %s", rollbackPath)
-
- systemType := SystemTypeUnknown
- if plan != nil {
- systemType = plan.SystemType
- }
- if err := applyNetworkWithRollbackCLI(ctx, reader, logger, rollbackPath, networkRollbackPath, stageRoot, archivePath, defaultNetworkRollbackTimeout, systemType); err != nil {
- return err
- }
- return nil
-}
-
-func applyNetworkWithRollbackCLI(ctx context.Context, reader *bufio.Reader, logger *logging.Logger, rollbackBackupPath, networkRollbackPath, stageRoot, archivePath string, timeout time.Duration, systemType SystemType) (err error) {
- done := logging.DebugStart(
- logger,
- "network safe apply (cli)",
- "rollbackBackup=%s networkRollback=%s timeout=%s systemType=%s stage=%s",
- strings.TrimSpace(rollbackBackupPath),
- strings.TrimSpace(networkRollbackPath),
- timeout,
- systemType,
- strings.TrimSpace(stageRoot),
- )
- defer func() { done(err) }()
-
- logging.DebugStep(logger, "network safe apply (cli)", "Create diagnostics directory")
- diagnosticsDir, err := createNetworkDiagnosticsDir()
- if err != nil {
- logger.Warning("Network diagnostics disabled: %v", err)
- diagnosticsDir = ""
- } else {
- logger.Info("Network diagnostics directory: %s", diagnosticsDir)
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Detect management interface (SSH/default route)")
- iface, source := detectManagementInterface(ctx, logger)
- if iface != "" {
- logger.Info("Detected management interface: %s (%s)", iface, source)
- }
-
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (cli)", "Capture network snapshot (before)")
- if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "before", 3*time.Second); err != nil {
- logger.Debug("Network snapshot before apply failed: %v", err)
- } else {
- logger.Debug("Network snapshot (before): %s", snap)
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Run baseline health checks (before)")
- healthBefore := runNetworkHealthChecks(ctx, networkHealthOptions{
- SystemType: systemType,
- Logger: logger,
- CommandTimeout: 3 * time.Second,
- EnableGatewayPing: false,
- ForceSSHRouteCheck: false,
- EnableDNSResolve: false,
- })
- if path, err := writeNetworkHealthReportFileNamed(diagnosticsDir, "health_before.txt", healthBefore); err != nil {
- logger.Debug("Failed to write network health (before) report: %v", err)
- } else {
- logger.Debug("Network health (before) report: %s", path)
- }
- }
-
- if strings.TrimSpace(stageRoot) != "" {
- logging.DebugStep(logger, "network safe apply (cli)", "Apply staged network files to system paths (before NIC repair)")
- applied, err := applyNetworkFilesFromStage(logger, stageRoot)
- if err != nil {
- return err
- }
- if len(applied) > 0 {
- logging.DebugStep(logger, "network safe apply (cli)", "Staged network files written: %d", len(applied))
- }
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "NIC name repair (optional)")
- _ = maybeRepairNICNamesCLI(ctx, reader, logger, archivePath)
-
- if strings.TrimSpace(iface) != "" {
- if cur, err := currentNetworkEndpoint(ctx, iface, 2*time.Second); err == nil {
- if tgt, err := targetNetworkEndpointFromConfig(logger, iface); err == nil {
- logger.Info("Network plan: %s -> %s", cur.summary(), tgt.summary())
- }
- }
- }
-
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (cli)", "Write network plan (current -> target)")
- if planText, err := buildNetworkPlanReport(ctx, logger, iface, source, 2*time.Second); err != nil {
- logger.Debug("Network plan build failed: %v", err)
- } else if strings.TrimSpace(planText) != "" {
- if path, err := writeNetworkTextReportFile(diagnosticsDir, "plan.txt", planText+"\n"); err != nil {
- logger.Debug("Network plan write failed: %v", err)
- } else {
- logger.Debug("Network plan: %s", path)
- }
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Run ifquery diagnostic (pre-apply)")
- ifqueryPre := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
- if !ifqueryPre.Skipped {
- if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_pre_apply.txt", ifqueryPre); err != nil {
- logger.Debug("Failed to write ifquery (pre-apply) report: %v", err)
- } else {
- logger.Debug("ifquery (pre-apply) report: %s", path)
- }
- }
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Network preflight validation (ifupdown/ifupdown2)")
- preflight := runNetworkPreflightValidation(ctx, 5*time.Second, logger)
- if diagnosticsDir != "" {
- if path, err := writeNetworkPreflightReportFile(diagnosticsDir, preflight); err != nil {
- logger.Debug("Failed to write network preflight report: %v", err)
- } else {
- logger.Debug("Network preflight report: %s", path)
- }
- }
- if !preflight.Ok() {
- logger.Warning("%s", preflight.Summary())
- if diagnosticsDir != "" {
- logger.Info("Network diagnostics saved under: %s", diagnosticsDir)
- }
- if strings.TrimSpace(stageRoot) != "" && strings.TrimSpace(networkRollbackPath) != "" {
- logging.DebugStep(logger, "network safe apply (cli)", "Preflight failed in staged mode: rolling back network files automatically")
- rollbackLog, rbErr := rollbackNetworkFilesNow(ctx, logger, networkRollbackPath, diagnosticsDir)
- if strings.TrimSpace(rollbackLog) != "" {
- logger.Info("Network rollback log: %s", rollbackLog)
- }
- if rbErr != nil {
- logger.Error("Network apply aborted: preflight validation failed (%s) and rollback failed: %v", preflight.CommandLine(), rbErr)
- return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr)
- }
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (cli)", "Capture network snapshot (after rollback)")
- if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "after_rollback", 3*time.Second); err != nil {
- logger.Debug("Network snapshot after rollback failed: %v", err)
- } else {
- logger.Debug("Network snapshot (after rollback): %s", snap)
- }
- logging.DebugStep(logger, "network safe apply (cli)", "Run ifquery diagnostic (after rollback)")
- ifqueryAfterRollback := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
- if !ifqueryAfterRollback.Skipped {
- if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_after_rollback.txt", ifqueryAfterRollback); err != nil {
- logger.Debug("Failed to write ifquery (after rollback) report: %v", err)
- } else {
- logger.Debug("ifquery (after rollback) report: %s", path)
- }
- }
- }
- logger.Warning(
- "Network apply aborted: preflight validation failed (%s). Rolled back /etc/network/*, /etc/hosts, /etc/hostname, /etc/resolv.conf to the pre-restore state (rollback=%s).",
- preflight.CommandLine(),
- strings.TrimSpace(networkRollbackPath),
- )
- return fmt.Errorf("network preflight validation failed; network files rolled back")
- }
- if !preflight.Skipped && preflight.ExitError != nil && strings.TrimSpace(networkRollbackPath) != "" {
- fmt.Println()
- fmt.Println("WARNING: Network preflight failed. The restored network configuration may break connectivity on reboot.")
- rollbackNow, perr := promptYesNoWithDefault(
- ctx,
- reader,
- "Roll back restored network config files to the pre-restore configuration now? (Y/n): ",
- true,
- )
- if perr != nil {
- return perr
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User choice: rollbackNow=%v", rollbackNow)
- if rollbackNow {
- logging.DebugStep(logger, "network safe apply (cli)", "Rollback network files now (backup=%s)", strings.TrimSpace(networkRollbackPath))
- rollbackLog, rbErr := rollbackNetworkFilesNow(ctx, logger, networkRollbackPath, diagnosticsDir)
- if strings.TrimSpace(rollbackLog) != "" {
- logger.Info("Network rollback log: %s", rollbackLog)
- }
- if rbErr != nil {
- logger.Warning("Network rollback failed: %v", rbErr)
- return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr)
- }
- logger.Warning("Network files rolled back to pre-restore configuration due to preflight failure")
- return fmt.Errorf("network preflight validation failed; network files rolled back")
- }
- }
- return fmt.Errorf("network preflight validation failed; aborting live network apply")
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Arm rollback timer BEFORE applying changes")
- handle, err := armNetworkRollback(ctx, logger, rollbackBackupPath, timeout, diagnosticsDir)
- if err != nil {
- return err
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Apply network configuration now")
- if err := applyNetworkConfig(ctx, logger); err != nil {
- logger.Warning("Network apply failed: %v", err)
- return err
- }
-
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (cli)", "Capture network snapshot (after)")
- if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "after", 3*time.Second); err != nil {
- logger.Debug("Network snapshot after apply failed: %v", err)
- } else {
- logger.Debug("Network snapshot (after): %s", snap)
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Run ifquery diagnostic (post-apply)")
- ifqueryPost := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
- if !ifqueryPost.Skipped {
- if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_post_apply.txt", ifqueryPost); err != nil {
- logger.Debug("Failed to write ifquery (post-apply) report: %v", err)
- } else {
- logger.Debug("ifquery (post-apply) report: %s", path)
- }
- }
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Run post-apply health checks")
- health := runNetworkHealthChecks(ctx, networkHealthOptions{
- SystemType: systemType,
- Logger: logger,
- CommandTimeout: 3 * time.Second,
- EnableGatewayPing: true,
- ForceSSHRouteCheck: false,
- EnableDNSResolve: true,
- LocalPortChecks: defaultNetworkPortChecks(systemType),
- })
- logNetworkHealthReport(logger, health)
- fmt.Println(health.Details())
- if diagnosticsDir != "" {
- if path, err := writeNetworkHealthReportFile(diagnosticsDir, health); err != nil {
- logger.Debug("Failed to write network health report: %v", err)
- } else {
- logger.Debug("Network health report: %s", path)
- }
- fmt.Printf("Network diagnostics saved under: %s\n", diagnosticsDir)
- }
- if health.Severity == networkHealthCritical {
- fmt.Println("CRITICAL: Connectivity checks failed. Recommended action: do NOT commit and let rollback run.")
- }
-
- remaining := handle.remaining(time.Now())
- if remaining <= 0 {
- logger.Warning("Rollback window already expired; leaving rollback armed")
- return nil
- }
-
- logging.DebugStep(logger, "network safe apply (cli)", "Wait for COMMIT (rollback in %ds)", int(remaining.Seconds()))
- committed, err := promptNetworkCommitWithCountdown(ctx, reader, logger, remaining)
- if err != nil {
- logger.Warning("Commit input lost (%v); rollback remains ARMED and will proceed automatically.", err)
- return buildNetworkApplyNotCommittedError(ctx, logger, iface, handle)
- }
- logging.DebugStep(logger, "network safe apply (cli)", "User commit result: committed=%v", committed)
- if committed {
- if rollbackAlreadyRunning(ctx, logger, handle) {
- logger.Warning("Commit received too late: rollback already running. Network configuration NOT committed.")
- return buildNetworkApplyNotCommittedError(ctx, logger, iface, handle)
- }
- disarmNetworkRollback(ctx, logger, handle)
- logger.Info("Network configuration committed successfully.")
- return nil
- }
-
- // Not committed: keep rollback ARMED. Do not disarm.
- // The rollback script will run via systemd-run/nohup when the timer expires.
- return buildNetworkApplyNotCommittedError(ctx, logger, iface, handle)
-}
-
// extractIPFromSnapshot reads the IP address for a given interface from a network snapshot report file.
// It searches the output section that follows the "$ ip -br addr" command written by writeNetworkSnapshot.
func extractIPFromSnapshot(path, iface string) string {
diff --git a/internal/orchestrator/network_apply_countdown_test.go b/internal/orchestrator/network_apply_countdown_test.go
new file mode 100644
index 0000000..37a460f
--- /dev/null
+++ b/internal/orchestrator/network_apply_countdown_test.go
@@ -0,0 +1,74 @@
+package orchestrator
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+ "github.com/tis24dev/proxsave/internal/types"
+)
+
+func TestPromptNetworkCommitWithCountdown_ZeroRemaining(test *testing.T) {
+ reader := bufio.NewReader(strings.NewReader(""))
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptNetworkCommitWithCountdown(context.Background(), reader, logger, 0)
+ if result {
+ test.Fatalf("expected false when remaining is zero")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ test.Fatalf("err=%v; want %v", err, context.DeadlineExceeded)
+ }
+}
+
+func TestPromptNetworkCommitWithCountdown_CommitInputReturnsTrue(test *testing.T) {
+ reader := bufio.NewReader(strings.NewReader("COMMIT\n"))
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptNetworkCommitWithCountdown(context.Background(), reader, logger, 2*time.Second)
+ if err != nil {
+ test.Fatalf("unexpected error: %v", err)
+ }
+ if !result {
+ test.Fatalf("expected true for COMMIT input")
+ }
+}
+
+func TestPromptNetworkCommitWithCountdown_NonCommitInputReturnsFalse(test *testing.T) {
+ reader := bufio.NewReader(strings.NewReader("nope\n"))
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptNetworkCommitWithCountdown(context.Background(), reader, logger, 2*time.Second)
+ if err != nil {
+ test.Fatalf("unexpected error: %v", err)
+ }
+ if result {
+ test.Fatalf("expected false for non-COMMIT input")
+ }
+}
+
+func TestPromptNetworkCommitWithCountdown_TimeoutReturnsDeadlineExceeded(test *testing.T) {
+ pipeReader, pipeWriter := io.Pipe()
+ defer pipeReader.Close()
+ defer pipeWriter.Close()
+
+ reader := bufio.NewReader(pipeReader)
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptNetworkCommitWithCountdown(context.Background(), reader, logger, 100*time.Millisecond)
+ if result {
+ test.Fatalf("expected false on timeout")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ test.Fatalf("err=%v; want %v", err, context.DeadlineExceeded)
+ }
+}
diff --git a/internal/orchestrator/network_apply_preflight_rollback_test.go b/internal/orchestrator/network_apply_preflight_rollback_test.go
index 7483531..d5e56ad 100644
--- a/internal/orchestrator/network_apply_preflight_rollback_test.go
+++ b/internal/orchestrator/network_apply_preflight_rollback_test.go
@@ -1,7 +1,6 @@
package orchestrator
import (
- "bufio"
"context"
"fmt"
"os"
@@ -11,7 +10,7 @@ import (
"time"
)
-func TestApplyNetworkWithRollbackCLI_RollsBackFilesOnPreflightFailure(t *testing.T) {
+func TestApplyNetworkWithRollbackWithUI_RollsBackFilesOnPreflightFailure(t *testing.T) {
origFS := restoreFS
origCmd := restoreCmd
origTime := restoreTime
@@ -23,7 +22,9 @@ func TestApplyNetworkWithRollbackCLI_RollsBackFilesOnPreflightFailure(t *testing
networkDiagnosticsSequence = origSeq
})
- restoreFS = NewFakeFS()
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
restoreTime = &FakeTime{Current: time.Date(2026, 1, 18, 13, 47, 6, 0, time.UTC)}
networkDiagnosticsSequence = 0
@@ -50,13 +51,13 @@ func TestApplyNetworkWithRollbackCLI_RollsBackFilesOnPreflightFailure(t *testing
}
restoreCmd = fake
- reader := bufio.NewReader(strings.NewReader("\n"))
logger := newTestLogger()
rollbackBackup := "/tmp/proxsave/network_rollback_backup_20260118_134651.tar.gz"
- err := applyNetworkWithRollbackCLI(
+ ui := &fakeRestoreWorkflowUI{confirmAction: true}
+ err := applyNetworkWithRollbackWithUI(
context.Background(),
- reader,
+ ui,
logger,
rollbackBackup,
rollbackBackup,
diff --git a/internal/orchestrator/network_apply_workflow_ui.go b/internal/orchestrator/network_apply_workflow_ui.go
new file mode 100644
index 0000000..b8a3924
--- /dev/null
+++ b/internal/orchestrator/network_apply_workflow_ui.go
@@ -0,0 +1,518 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+func maybeApplyNetworkConfigWithUI(ctx context.Context, ui RestoreWorkflowUI, logger *logging.Logger, plan *RestorePlan, safetyBackup, networkRollbackBackup *SafetyBackupResult, stageRoot, archivePath string, dryRun bool) (err error) {
+ if !shouldAttemptNetworkApply(plan) {
+ if logger != nil {
+ logger.Debug("Network safe apply (UI): skipped (network category not selected)")
+ }
+ return nil
+ }
+ done := logging.DebugStart(logger, "network safe apply (ui)", "dryRun=%v euid=%d stage=%s archive=%s", dryRun, os.Geteuid(), strings.TrimSpace(stageRoot), strings.TrimSpace(archivePath))
+ defer func() { done(err) }()
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping live network apply: non-system filesystem in use")
+ return nil
+ }
+ if dryRun {
+ logger.Info("Dry run enabled: skipping live network apply")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping live network apply: requires root privileges")
+ return nil
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Resolve rollback backup paths")
+ networkRollbackPath := ""
+ if networkRollbackBackup != nil {
+ networkRollbackPath = strings.TrimSpace(networkRollbackBackup.BackupPath)
+ }
+ fullRollbackPath := ""
+ if safetyBackup != nil {
+ fullRollbackPath = strings.TrimSpace(safetyBackup.BackupPath)
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "Rollback backup resolved: network=%q full=%q", networkRollbackPath, fullRollbackPath)
+
+ if networkRollbackPath == "" && fullRollbackPath == "" {
+ logger.Warning("Skipping live network apply: rollback backup not available")
+ if strings.TrimSpace(stageRoot) != "" {
+ logger.Info("Network configuration is staged; skipping NIC repair/apply due to missing rollback backup.")
+ return nil
+ }
+
+ repairNow, err := ui.ConfirmAction(
+ ctx,
+ "NIC name repair (recommended)",
+ "Attempt NIC name repair in restored network config files now (no reload)?\n\nThis will only rewrite /etc/network/interfaces and /etc/network/interfaces.d/* when safe mappings are found.",
+ "Repair now",
+ "Skip repair",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User choice: repairNow=%v", repairNow)
+ if repairNow {
+ if repair, err := ui.RepairNICNames(ctx, archivePath); err != nil {
+ return err
+ } else if repair != nil && strings.TrimSpace(repair.Summary()) != "" {
+ _ = ui.ShowMessage(ctx, "NIC repair result", repair.Summary())
+ }
+ }
+
+ logger.Info("Skipping live network apply (you can reboot or apply manually later).")
+ return nil
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Prompt: apply network now with rollback timer")
+ sourceLine := "Source: /etc/network (will be applied)"
+ if strings.TrimSpace(stageRoot) != "" {
+ sourceLine = fmt.Sprintf("Source: %s (will be copied to /etc and applied)", strings.TrimSpace(stageRoot))
+ }
+ message := fmt.Sprintf(
+ "Network restore: a restored network configuration is ready to apply.\n%s\n\nThis will reload networking immediately (no reboot).\n\nWARNING: This may change the active IP and disconnect SSH/Web sessions.\n\nAfter applying, type COMMIT within %ds or ProxSave will roll back automatically.\n\nRecommendation: run this step from the local console/IPMI, not over SSH.\n\nApply network configuration now?",
+ sourceLine,
+ int(defaultNetworkRollbackTimeout.Seconds()),
+ )
+ applyNow, err := ui.ConfirmAction(ctx, "Apply network configuration", message, "Apply now", "Skip apply", 90*time.Second, false)
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User choice: applyNow=%v", applyNow)
+ if !applyNow {
+ if strings.TrimSpace(stageRoot) == "" {
+ repairNow, err := ui.ConfirmAction(
+ ctx,
+ "NIC name repair (recommended)",
+ "Attempt NIC name repair in restored network config files now (no reload)?\n\nThis will only rewrite /etc/network/interfaces and /etc/network/interfaces.d/* when safe mappings are found.",
+ "Repair now",
+ "Skip repair",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User choice: repairNow=%v", repairNow)
+ if repairNow {
+ if repair, err := ui.RepairNICNames(ctx, archivePath); err != nil {
+ return err
+ } else if repair != nil && strings.TrimSpace(repair.Summary()) != "" {
+ _ = ui.ShowMessage(ctx, "NIC repair result", repair.Summary())
+ }
+ }
+ } else {
+ logger.Info("Network configuration is staged (not yet written to /etc); skipping NIC repair prompt.")
+ }
+ logger.Info("Skipping live network apply (you can apply later).")
+ return nil
+ }
+
+ rollbackPath := networkRollbackPath
+ if rollbackPath == "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Prompt: network-only rollback missing; allow full rollback backup fallback")
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "Network-only rollback not available",
+ "Network-only rollback backup is not available.\n\nIf you proceed, the rollback timer will use the full safety backup, which may revert other restored categories.\n\nProceed anyway?",
+ "Proceed with full rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User choice: allowFullRollback=%v", ok)
+ if !ok {
+ if strings.TrimSpace(stageRoot) == "" {
+ repairNow, err := ui.ConfirmAction(
+ ctx,
+ "NIC name repair (recommended)",
+ "Attempt NIC name repair in restored network config files now (no reload)?\n\nThis will only rewrite /etc/network/interfaces and /etc/network/interfaces.d/* when safe mappings are found.",
+ "Repair now",
+ "Skip repair",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User choice: repairNow=%v", repairNow)
+ if repairNow {
+ if repair, err := ui.RepairNICNames(ctx, archivePath); err != nil {
+ return err
+ } else if repair != nil && strings.TrimSpace(repair.Summary()) != "" {
+ _ = ui.ShowMessage(ctx, "NIC repair result", repair.Summary())
+ }
+ }
+ }
+ logger.Info("Skipping live network apply (you can reboot or apply manually later).")
+ return nil
+ }
+ rollbackPath = fullRollbackPath
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "Selected rollback backup: %s", rollbackPath)
+
+ systemType := SystemTypeUnknown
+ if plan != nil {
+ systemType = plan.SystemType
+ }
+ return applyNetworkWithRollbackWithUI(ctx, ui, logger, rollbackPath, networkRollbackPath, stageRoot, archivePath, defaultNetworkRollbackTimeout, systemType)
+}
+
+func applyNetworkWithRollbackWithUI(ctx context.Context, ui RestoreWorkflowUI, logger *logging.Logger, rollbackBackupPath, networkRollbackPath, stageRoot, archivePath string, timeout time.Duration, systemType SystemType) (err error) {
+ done := logging.DebugStart(
+ logger,
+ "network safe apply (ui)",
+ "rollbackBackup=%s networkRollback=%s timeout=%s systemType=%s stage=%s",
+ strings.TrimSpace(rollbackBackupPath),
+ strings.TrimSpace(networkRollbackPath),
+ timeout,
+ systemType,
+ strings.TrimSpace(stageRoot),
+ )
+ defer func() { done(err) }()
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Create diagnostics directory")
+ diagnosticsDir, err := createNetworkDiagnosticsDir()
+ if err != nil {
+ logger.Warning("Network diagnostics disabled: %v", err)
+ diagnosticsDir = ""
+ } else {
+ logger.Info("Network diagnostics directory: %s", diagnosticsDir)
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Detect management interface (SSH/default route)")
+ iface, source := detectManagementInterface(ctx, logger)
+ if iface != "" {
+ logger.Info("Detected management interface: %s (%s)", iface, source)
+ }
+
+ if diagnosticsDir != "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Capture network snapshot (before)")
+ if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "before", 3*time.Second); err != nil {
+ logger.Debug("Network snapshot before apply failed: %v", err)
+ } else {
+ logger.Debug("Network snapshot (before): %s", snap)
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Run baseline health checks (before)")
+ healthBefore := runNetworkHealthChecks(ctx, networkHealthOptions{
+ SystemType: systemType,
+ Logger: logger,
+ CommandTimeout: 3 * time.Second,
+ EnableGatewayPing: false,
+ ForceSSHRouteCheck: false,
+ EnableDNSResolve: false,
+ })
+ if path, err := writeNetworkHealthReportFileNamed(diagnosticsDir, "health_before.txt", healthBefore); err != nil {
+ logger.Debug("Failed to write network health (before) report: %v", err)
+ } else {
+ logger.Debug("Network health (before) report: %s", path)
+ }
+ }
+
+ if strings.TrimSpace(stageRoot) != "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Apply staged network files to system paths (before NIC repair)")
+ applied, err := applyNetworkFilesFromStage(logger, stageRoot)
+ if err != nil {
+ return err
+ }
+ if len(applied) > 0 {
+ logging.DebugStep(logger, "network safe apply (ui)", "Staged network files written: %d", len(applied))
+ }
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "NIC name repair (optional)")
+ var nicRepair *nicRepairResult
+ if repair, err := ui.RepairNICNames(ctx, archivePath); err != nil {
+ logger.Warning("NIC repair failed: %v", err)
+ } else {
+ nicRepair = repair
+ if nicRepair != nil {
+ if nicRepair.Applied() || nicRepair.SkippedReason != "" {
+ logger.Info("%s", nicRepair.Summary())
+ } else {
+ logger.Debug("%s", nicRepair.Summary())
+ }
+ }
+ }
+
+ if strings.TrimSpace(iface) != "" {
+ if cur, err := currentNetworkEndpoint(ctx, iface, 2*time.Second); err == nil {
+ if tgt, err := targetNetworkEndpointFromConfig(logger, iface); err == nil {
+ logger.Info("Network plan: %s -> %s", cur.summary(), tgt.summary())
+ }
+ }
+ }
+
+ if diagnosticsDir != "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Write network plan (current -> target)")
+ if planText, err := buildNetworkPlanReport(ctx, logger, iface, source, 2*time.Second); err != nil {
+ logger.Debug("Network plan build failed: %v", err)
+ } else if strings.TrimSpace(planText) != "" {
+ if path, err := writeNetworkTextReportFile(diagnosticsDir, "plan.txt", planText+"\n"); err != nil {
+ logger.Debug("Network plan write failed: %v", err)
+ } else {
+ logger.Debug("Network plan: %s", path)
+ }
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Run ifquery diagnostic (pre-apply)")
+ ifqueryPre := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
+ if !ifqueryPre.Skipped {
+ if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_pre_apply.txt", ifqueryPre); err != nil {
+ logger.Debug("Failed to write ifquery (pre-apply) report: %v", err)
+ } else {
+ logger.Debug("ifquery (pre-apply) report: %s", path)
+ }
+ }
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Network preflight validation (ifupdown/ifupdown2)")
+ preflight := runNetworkPreflightValidation(ctx, 5*time.Second, logger)
+ if diagnosticsDir != "" {
+ if path, err := writeNetworkPreflightReportFile(diagnosticsDir, preflight); err != nil {
+ logger.Debug("Failed to write network preflight report: %v", err)
+ } else {
+ logger.Debug("Network preflight report: %s", path)
+ }
+ }
+ if !preflight.Ok() {
+ message := preflight.Summary()
+ if diagnosticsDir != "" {
+ message += "\n\nDiagnostics saved under:\n" + diagnosticsDir
+ }
+ if out := strings.TrimSpace(preflight.Output); out != "" {
+ message += "\n\nOutput:\n" + out
+ }
+
+ if strings.TrimSpace(stageRoot) != "" && strings.TrimSpace(networkRollbackPath) != "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Preflight failed in staged mode: rolling back network files automatically")
+ rollbackLog, rbErr := rollbackNetworkFilesNow(ctx, logger, networkRollbackPath, diagnosticsDir)
+ if strings.TrimSpace(rollbackLog) != "" {
+ logger.Info("Network rollback log: %s", rollbackLog)
+ }
+ if rbErr != nil {
+ logger.Error("Network apply aborted: preflight validation failed (%s) and rollback failed: %v", preflight.CommandLine(), rbErr)
+ return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr)
+ }
+ if diagnosticsDir != "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Capture network snapshot (after rollback)")
+ if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "after_rollback", 3*time.Second); err != nil {
+ logger.Debug("Network snapshot after rollback failed: %v", err)
+ } else {
+ logger.Debug("Network snapshot (after rollback): %s", snap)
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "Run ifquery diagnostic (after rollback)")
+ ifqueryAfterRollback := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
+ if !ifqueryAfterRollback.Skipped {
+ if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_after_rollback.txt", ifqueryAfterRollback); err != nil {
+ logger.Debug("Failed to write ifquery (after rollback) report: %v", err)
+ } else {
+ logger.Debug("ifquery (after rollback) report: %s", path)
+ }
+ }
+ }
+ logger.Warning(
+ "Network apply aborted: preflight validation failed (%s). Rolled back /etc/network/*, /etc/hosts, /etc/hostname, /etc/resolv.conf to the pre-restore state (rollback=%s).",
+ preflight.CommandLine(),
+ strings.TrimSpace(networkRollbackPath),
+ )
+ _ = ui.ShowError(ctx, "Network preflight failed", "Network configuration failed preflight and was rolled back automatically.")
+ return fmt.Errorf("network preflight validation failed; network files rolled back")
+ }
+
+ if !preflight.Skipped && preflight.ExitError != nil && strings.TrimSpace(networkRollbackPath) != "" {
+ message += "\n\nRollback restored network config files to the pre-restore configuration now? (recommended)"
+ rollbackNow, err := ui.ConfirmAction(ctx, "Network preflight failed", message, "Rollback now", "Keep restored files", 0, true)
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User choice: rollbackNow=%v", rollbackNow)
+ if rollbackNow {
+ logging.DebugStep(logger, "network safe apply (ui)", "Rollback network files now (backup=%s)", strings.TrimSpace(networkRollbackPath))
+ rollbackLog, rbErr := rollbackNetworkFilesNow(ctx, logger, networkRollbackPath, diagnosticsDir)
+ if strings.TrimSpace(rollbackLog) != "" {
+ logger.Info("Network rollback log: %s", rollbackLog)
+ }
+ if rbErr != nil {
+ logger.Warning("Network rollback failed: %v", rbErr)
+ return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr)
+ }
+ logger.Warning("Network files rolled back to pre-restore configuration due to preflight failure")
+ return fmt.Errorf("network preflight validation failed; network files rolled back")
+ }
+ }
+ return fmt.Errorf("network preflight validation failed; aborting live network apply")
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Arm rollback timer BEFORE applying changes")
+ handle, err := armNetworkRollback(ctx, logger, rollbackBackupPath, timeout, diagnosticsDir)
+ if err != nil {
+ return err
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Apply network configuration now")
+ if err := applyNetworkConfig(ctx, logger); err != nil {
+ logger.Warning("Network apply failed: %v", err)
+ return err
+ }
+
+ if diagnosticsDir != "" {
+ logging.DebugStep(logger, "network safe apply (ui)", "Capture network snapshot (after)")
+ if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "after", 3*time.Second); err != nil {
+ logger.Debug("Network snapshot after apply failed: %v", err)
+ } else {
+ logger.Debug("Network snapshot (after): %s", snap)
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Run ifquery diagnostic (post-apply)")
+ ifqueryPost := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
+ if !ifqueryPost.Skipped {
+ if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_post_apply.txt", ifqueryPost); err != nil {
+ logger.Debug("Failed to write ifquery (post-apply) report: %v", err)
+ } else {
+ logger.Debug("ifquery (post-apply) report: %s", path)
+ }
+ }
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Run post-apply health checks")
+ health := runNetworkHealthChecks(ctx, networkHealthOptions{
+ SystemType: systemType,
+ Logger: logger,
+ CommandTimeout: 3 * time.Second,
+ EnableGatewayPing: true,
+ ForceSSHRouteCheck: false,
+ EnableDNSResolve: true,
+ LocalPortChecks: defaultNetworkPortChecks(systemType),
+ })
+ logNetworkHealthReport(logger, health)
+ if diagnosticsDir != "" {
+ if path, err := writeNetworkHealthReportFile(diagnosticsDir, health); err != nil {
+ logger.Debug("Failed to write network health report: %v", err)
+ } else {
+ logger.Debug("Network health report: %s", path)
+ }
+ }
+
+ remaining := handle.remaining(time.Now())
+ if remaining <= 0 {
+ logger.Warning("Rollback window already expired; leaving rollback armed")
+ return nil
+ }
+
+ logging.DebugStep(logger, "network safe apply (ui)", "Wait for COMMIT (rollback in %ds)", int(remaining.Seconds()))
+ committed, commitErr := ui.PromptNetworkCommit(ctx, remaining, health, nicRepair, diagnosticsDir)
+ if commitErr != nil {
+ logger.Warning("Commit prompt error: %v", commitErr)
+ return buildNetworkApplyNotCommittedError(ctx, logger, iface, handle)
+ }
+ logging.DebugStep(logger, "network safe apply (ui)", "User commit result: committed=%v", committed)
+ if committed {
+ if rollbackAlreadyRunning(ctx, logger, handle) {
+ logger.Warning("Commit received too late: rollback already running. Network configuration NOT committed.")
+ return buildNetworkApplyNotCommittedError(ctx, logger, iface, handle)
+ }
+ disarmNetworkRollback(ctx, logger, handle)
+ logger.Info("Network configuration committed successfully.")
+ return nil
+ }
+
+ // Not committed: keep rollback ARMED.
+ if strings.TrimSpace(diagnosticsDir) != "" {
+ _ = ui.ShowMessage(ctx, "Network rollback armed", fmt.Sprintf("Network configuration not committed.\n\nRollback will run automatically.\n\nDiagnostics saved under:\n%s", diagnosticsDir))
+ }
+ return buildNetworkApplyNotCommittedError(ctx, logger, iface, handle)
+}
+
+func (c *cliWorkflowUI) ConfirmAction(ctx context.Context, title, message, yesLabel, noLabel string, timeout time.Duration, defaultYes bool) (bool, error) {
+ _ = yesLabel
+ _ = noLabel
+
+ title = strings.TrimSpace(title)
+ if title != "" {
+ fmt.Printf("\n%s\n", title)
+ }
+ message = strings.TrimSpace(message)
+ if message != "" {
+ fmt.Println(message)
+ fmt.Println()
+ }
+ question := title
+ if question == "" {
+ question = "Proceed?"
+ }
+ return promptYesNoWithCountdown(ctx, c.reader, c.logger, question, timeout, defaultYes)
+}
+
+func (c *cliWorkflowUI) RepairNICNames(ctx context.Context, archivePath string) (*nicRepairResult, error) {
+ return maybeRepairNICNamesCLI(ctx, c.reader, c.logger, archivePath), nil
+}
+
+func (c *cliWorkflowUI) PromptNetworkCommit(ctx context.Context, remaining time.Duration, health networkHealthReport, nicRepair *nicRepairResult, diagnosticsDir string) (bool, error) {
+ if strings.TrimSpace(diagnosticsDir) != "" {
+ fmt.Printf("Network diagnostics saved under: %s\n", strings.TrimSpace(diagnosticsDir))
+ }
+ fmt.Println(health.Details())
+ if health.Severity == networkHealthCritical {
+ fmt.Println("CRITICAL: Connectivity checks failed. Recommended action: do NOT commit and let rollback run.")
+ }
+ if nicRepair != nil && strings.TrimSpace(nicRepair.Summary()) != "" {
+ fmt.Printf("\nNIC repair: %s\n", nicRepair.Summary())
+ }
+ return promptNetworkCommitWithCountdown(ctx, c.reader, c.logger, remaining)
+}
+
+func (u *tuiWorkflowUI) ConfirmAction(ctx context.Context, title, message, yesLabel, noLabel string, timeout time.Duration, defaultYes bool) (bool, error) {
+ _ = defaultYes
+
+ title = strings.TrimSpace(title)
+ if title == "" {
+ title = "Confirm"
+ }
+ message = strings.TrimSpace(message)
+ if timeout > 0 {
+ return promptYesNoTUIWithCountdown(ctx, u.logger, title, u.configPath, u.buildSig, message, yesLabel, noLabel, timeout)
+ }
+ return promptYesNoTUIFunc(title, u.configPath, u.buildSig, message, yesLabel, noLabel)
+}
+
+func (u *tuiWorkflowUI) RepairNICNames(ctx context.Context, archivePath string) (*nicRepairResult, error) {
+ return maybeRepairNICNamesTUI(ctx, u.logger, archivePath, u.configPath, u.buildSig), nil
+}
+
+func (u *tuiWorkflowUI) PromptNetworkCommit(ctx context.Context, remaining time.Duration, health networkHealthReport, nicRepair *nicRepairResult, diagnosticsDir string) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ committed, err := promptNetworkCommitTUI(remaining, health, nicRepair, diagnosticsDir, u.configPath, u.buildSig)
+ if err != nil && errors.Is(err, input.ErrInputAborted) {
+ return false, err
+ }
+ return committed, err
+}
+
diff --git a/internal/orchestrator/nic_mapping.go b/internal/orchestrator/nic_mapping.go
index 69d5efc..f4fa1d1 100644
--- a/internal/orchestrator/nic_mapping.go
+++ b/internal/orchestrator/nic_mapping.go
@@ -296,6 +296,7 @@ func applyNICNameRepair(logger *logging.Logger, plan *nicRepairPlan, includeConf
func loadBackupNetworkInventoryFromArchive(ctx context.Context, archivePath string) (*archivedNetworkInventory, string, error) {
candidates := []string{
+ "./var/lib/proxsave-info/commands/system/network_inventory.json",
"./commands/network_inventory.json",
"./var/lib/proxsave-info/network_inventory.json",
}
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index 5b7d15b..6bc4db6 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -63,6 +63,7 @@ type BackupStats struct {
EndTime time.Time
FilesCollected int
FilesFailed int
+ FilesNotFound int
DirsCreated int
BytesCollected int64
ArchiveSize int64
@@ -654,10 +655,11 @@ func (o *Orchestrator) RunGoBackup(ctx context.Context, pType types.ProxmoxType,
collStats := collector.GetStats()
stats.FilesCollected = int(collStats.FilesProcessed)
stats.FilesFailed = int(collStats.FilesFailed)
+ stats.FilesNotFound = int(collStats.FilesNotFound)
stats.DirsCreated = int(collStats.DirsCreated)
stats.BytesCollected = collStats.BytesCollected
stats.FilesIncluded = int(collStats.FilesProcessed)
- stats.FilesMissing = int(collStats.FilesFailed)
+ stats.FilesMissing = int(collStats.FilesNotFound)
stats.UncompressedSize = collStats.BytesCollected
if pType == types.ProxmoxVE {
if collector.IsClusteredPVE() {
@@ -671,6 +673,11 @@ func (o *Orchestrator) RunGoBackup(ctx context.Context, pType types.ProxmoxType,
o.logger.Debug("Failed to write backup metadata: %v", err)
}
+ // Write backup manifest with file status details
+ if err := collector.WriteManifest(hostname); err != nil {
+ o.logger.Debug("Failed to write backup manifest: %v", err)
+ }
+
o.logger.Info("Collection completed: %d files (%s), %d failed, %d dirs created",
collStats.FilesProcessed,
backup.FormatBytes(collStats.BytesCollected),
@@ -745,6 +752,7 @@ func (o *Orchestrator) RunGoBackup(ctx context.Context, pType types.ProxmoxType,
o.dryRun,
o.cfg != nil && o.cfg.EncryptArchive,
ageRecipients,
+ collectorConfig.ExcludePatterns,
)
if err := archiverConfig.Validate(); err != nil {
@@ -1409,10 +1417,10 @@ func (o *Orchestrator) cleanupPreviousExecutionArtifacts() *TempDirRegistry {
func (o *Orchestrator) writeBackupMetadata(tempDir string, stats *BackupStats) error {
fs := o.filesystem()
- infoDir := filepath.Join(tempDir, "var/lib/proxsave-info")
- if err := fs.MkdirAll(infoDir, 0755); err != nil {
- return err
+ if o.dryRun {
+ return nil
}
+ infoDir := filepath.Join(tempDir, "var/lib/proxsave-info")
version := strings.TrimSpace(stats.Version)
if version == "" {
@@ -1433,6 +1441,18 @@ func (o *Orchestrator) writeBackupMetadata(tempDir string, stats *BackupStats) e
builder.WriteString("BACKUP_FEATURES=selective_restore,category_mapping,version_detection,auto_directory_creation\n")
target := filepath.Join(infoDir, "backup_metadata.txt")
+ patterns := append([]string(nil), o.excludePatterns...)
+ if o.cfg != nil && len(o.cfg.BackupBlacklist) > 0 {
+ patterns = append(patterns, o.cfg.BackupBlacklist...)
+ }
+ if excluded, pattern := backup.FindExcludeMatch(patterns, target, tempDir, ""); excluded {
+ o.logger.Debug("Skipping backup metadata %s (matches pattern %s)", target, pattern)
+ return nil
+ }
+
+ if err := fs.MkdirAll(infoDir, 0755); err != nil {
+ return err
+ }
if err := fs.WriteFile(target, []byte(builder.String()), 0640); err != nil {
return err
}
diff --git a/internal/orchestrator/pbs_mount_guard_test.go b/internal/orchestrator/pbs_mount_guard_test.go
new file mode 100644
index 0000000..f456170
--- /dev/null
+++ b/internal/orchestrator/pbs_mount_guard_test.go
@@ -0,0 +1,33 @@
+package orchestrator
+
+import "testing"
+
+func TestPBSMountGuardRootForDatastorePath(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {name: "mnt nested", in: "/mnt/datastore/Data1", want: "/mnt/datastore"},
+ {name: "mnt deep", in: "/mnt/Synology_NFS/PBS_Backup", want: "/mnt/Synology_NFS"},
+ {name: "media", in: "/media/USB/PBS", want: "/media/USB"},
+ {name: "run media", in: "/run/media/root/USB/PBS", want: "/run/media/root/USB"},
+ {name: "not mount style", in: "/srv/pbs", want: ""},
+ {name: "empty", in: "", want: ""},
+ {name: "root", in: "/", want: ""},
+ {name: "mnt root", in: "/mnt", want: ""},
+ {name: "mnt slash", in: "/mnt/", want: ""},
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := pbsMountGuardRootForDatastorePath(tt.in); got != tt.want {
+ t.Fatalf("pbsMountGuardRootForDatastorePath(%q)=%q want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/orchestrator/pbs_staged_apply.go b/internal/orchestrator/pbs_staged_apply.go
index dbfd1c4..57124f2 100644
--- a/internal/orchestrator/pbs_staged_apply.go
+++ b/internal/orchestrator/pbs_staged_apply.go
@@ -15,7 +15,7 @@ func maybeApplyPBSConfigsFromStage(ctx context.Context, logger *logging.Logger,
if plan == nil || plan.SystemType != SystemTypePBS {
return nil
}
- if !plan.HasCategoryID("datastore_pbs") && !plan.HasCategoryID("pbs_jobs") {
+ if !plan.HasCategoryID("datastore_pbs") && !plan.HasCategoryID("pbs_jobs") && !plan.HasCategoryID("pbs_remotes") && !plan.HasCategoryID("pbs_host") && !plan.HasCategoryID("pbs_tape") {
return nil
}
if strings.TrimSpace(stageRoot) == "" {
@@ -40,6 +40,9 @@ func maybeApplyPBSConfigsFromStage(ctx context.Context, logger *logging.Logger,
}
if plan.HasCategoryID("datastore_pbs") {
+ if err := applyPBSS3CfgFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PBS staged apply: s3.cfg: %v", err)
+ }
if err := applyPBSDatastoreCfgFromStage(ctx, logger, stageRoot); err != nil {
logger.Warning("PBS staged apply: datastore.cfg: %v", err)
}
@@ -49,6 +52,79 @@ func maybeApplyPBSConfigsFromStage(ctx context.Context, logger *logging.Logger,
logger.Warning("PBS staged apply: job configs: %v", err)
}
}
+ if plan.HasCategoryID("pbs_remotes") {
+ if err := applyPBSRemoteCfgFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PBS staged apply: remote.cfg: %v", err)
+ }
+ }
+ if plan.HasCategoryID("pbs_host") {
+ if err := applyPBSHostConfigsFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PBS staged apply: host configs: %v", err)
+ }
+ }
+ if plan.HasCategoryID("pbs_tape") {
+ if err := applyPBSTapeConfigsFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PBS staged apply: tape configs: %v", err)
+ }
+ }
+ return nil
+}
+
+func applyPBSRemoteCfgFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) (err error) {
+ done := logging.DebugStart(logger, "pbs staged apply remote.cfg", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ return applyPBSConfigFileFromStage(ctx, logger, stageRoot, "etc/proxmox-backup/remote.cfg")
+}
+
+func applyPBSS3CfgFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) (err error) {
+ done := logging.DebugStart(logger, "pbs staged apply s3.cfg", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ return applyPBSConfigFileFromStage(ctx, logger, stageRoot, "etc/proxmox-backup/s3.cfg")
+}
+
+func applyPBSHostConfigsFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) (err error) {
+ done := logging.DebugStart(logger, "pbs staged apply host configs", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ // ACME should be applied before node.cfg (node.cfg references ACME account/plugins).
+ paths := []string{
+ "etc/proxmox-backup/acme/accounts.cfg",
+ "etc/proxmox-backup/acme/plugins.cfg",
+ "etc/proxmox-backup/metricserver.cfg",
+ "etc/proxmox-backup/traffic-control.cfg",
+ "etc/proxmox-backup/proxy.cfg",
+ "etc/proxmox-backup/node.cfg",
+ }
+ for _, rel := range paths {
+ if err := applyPBSConfigFileFromStage(ctx, logger, stageRoot, rel); err != nil {
+ logger.Warning("PBS staged apply: %s: %v", rel, err)
+ }
+ }
+ return nil
+}
+
+func applyPBSTapeConfigsFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) (err error) {
+ done := logging.DebugStart(logger, "pbs staged apply tape configs", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ paths := []string{
+ "etc/proxmox-backup/tape.cfg",
+ "etc/proxmox-backup/tape-job.cfg",
+ "etc/proxmox-backup/media-pool.cfg",
+ }
+ for _, rel := range paths {
+ if err := applyPBSConfigFileFromStage(ctx, logger, stageRoot, rel); err != nil {
+ logger.Warning("PBS staged apply: %s: %v", rel, err)
+ }
+ }
+
+ // Tape encryption keys are JSON (no section headers) and should be applied as a sensitive file.
+ if err := applySensitiveFileFromStage(logger, stageRoot, "etc/proxmox-backup/tape-encryption-keys.json", "/etc/proxmox-backup/tape-encryption-keys.json", 0o600); err != nil {
+ logger.Warning("PBS staged apply: tape-encryption-keys.json: %v", err)
+ }
+
return nil
}
@@ -211,7 +287,12 @@ func shouldApplyPBSDatastoreBlock(block pbsDatastoreBlock, logger *logging.Logge
return false, fmt.Sprintf("filesystem identity check failed: %v", devErr)
}
if onRootFS && isSuspiciousDatastoreMountLocation(path) && !hasData {
- return false, "path resolves to root filesystem (mount missing?)"
+ // On fresh restores the mount backing this path may be offline/not mounted yet.
+ // We still apply the datastore definition 1:1 so PBS shows the datastore as unavailable
+ // rather than silently dropping it from datastore.cfg.
+ if logger != nil {
+ logger.Warning("PBS staged apply: datastore %s path %s resolves to root filesystem (mount missing?) — applying definition anyway", block.Name, path)
+ }
}
if hasData {
diff --git a/internal/orchestrator/pbs_staged_apply_test.go b/internal/orchestrator/pbs_staged_apply_test.go
new file mode 100644
index 0000000..ecb7abb
--- /dev/null
+++ b/internal/orchestrator/pbs_staged_apply_test.go
@@ -0,0 +1,78 @@
+package orchestrator
+
+import (
+ "context"
+ "os"
+ "testing"
+)
+
+func TestApplyPBSRemoteCfgFromStage_WritesRemoteCfg(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+ remoteCfg := "remote: pbs1\n host 10.0.0.10\n authid root@pam\n password secret\n"
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/remote.cfg", []byte(remoteCfg), 0o640); err != nil {
+ t.Fatalf("write staged remote.cfg: %v", err)
+ }
+
+ if err := applyPBSRemoteCfgFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPBSRemoteCfgFromStage error: %v", err)
+ }
+
+ if got, err := fakeFS.ReadFile("/etc/proxmox-backup/remote.cfg"); err != nil {
+ t.Fatalf("expected restored remote.cfg: %v", err)
+ } else if len(got) == 0 {
+ t.Fatalf("expected non-empty remote.cfg")
+ }
+ if info, err := fakeFS.Stat("/etc/proxmox-backup/remote.cfg"); err != nil {
+ t.Fatalf("stat remote.cfg: %v", err)
+ } else if info.Mode().Perm() != 0o640 {
+ t.Fatalf("remote.cfg mode=%#o want %#o", info.Mode().Perm(), 0o640)
+ }
+}
+
+func TestApplyPBSRemoteCfgFromStage_RemovesWhenEmpty(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ if err := fakeFS.WriteFile("/etc/proxmox-backup/remote.cfg", []byte("remote: old\n host 1.2.3.4\n"), 0o640); err != nil {
+ t.Fatalf("write existing remote.cfg: %v", err)
+ }
+
+ stageRoot := "/stage"
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/remote.cfg", []byte(" \n"), 0o640); err != nil {
+ t.Fatalf("write staged remote.cfg: %v", err)
+ }
+
+ if err := applyPBSRemoteCfgFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPBSRemoteCfgFromStage error: %v", err)
+ }
+ if _, err := fakeFS.Stat("/etc/proxmox-backup/remote.cfg"); err == nil {
+ t.Fatalf("expected remote.cfg removed")
+ }
+}
+
+func TestShouldApplyPBSDatastoreBlock_AllowsMountLikePathsOnRootFS(t *testing.T) {
+ t.Parallel()
+
+ dir, err := os.MkdirTemp("/mnt", "proxsave-test-ds-")
+ if err != nil {
+ t.Skipf("cannot create temp dir under /mnt: %v", err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
+
+ block := pbsDatastoreBlock{Name: "ds", Path: dir}
+ ok, reason := shouldApplyPBSDatastoreBlock(block, newTestLogger())
+ if !ok {
+ t.Fatalf("expected datastore block to be applied, got ok=false reason=%q", reason)
+ }
+}
diff --git a/internal/orchestrator/prompts_cli_test.go b/internal/orchestrator/prompts_cli_test.go
index bab4ff1..0377ae5 100644
--- a/internal/orchestrator/prompts_cli_test.go
+++ b/internal/orchestrator/prompts_cli_test.go
@@ -4,10 +4,14 @@ import (
"bufio"
"context"
"errors"
+ "io"
"strings"
"testing"
+ "time"
"github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+ "github.com/tis24dev/proxsave/internal/types"
)
func TestPromptYesNo(t *testing.T) {
@@ -50,3 +54,49 @@ func TestPromptYesNo_ContextCanceledReturnsAbortError(t *testing.T) {
t.Fatalf("err=%v; want %v", err, input.ErrInputAborted)
}
}
+
+func TestPromptYesNoWithCountdown_ZeroTimeoutUsesDefault(test *testing.T) {
+ reader := bufio.NewReader(strings.NewReader("\n"))
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptYesNoWithCountdown(context.Background(), reader, logger, "Proceed?", 0, true)
+ if err != nil {
+ test.Fatalf("unexpected error: %v", err)
+ }
+ if !result {
+ test.Fatalf("expected true for default yes")
+ }
+}
+
+func TestPromptYesNoWithCountdown_InputYes(test *testing.T) {
+ reader := bufio.NewReader(strings.NewReader("yes\n"))
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptYesNoWithCountdown(context.Background(), reader, logger, "Proceed?", 2*time.Second, false)
+ if err != nil {
+ test.Fatalf("unexpected error: %v", err)
+ }
+ if !result {
+ test.Fatalf("expected true for yes input")
+ }
+}
+
+func TestPromptYesNoWithCountdown_TimeoutReturnsNo(test *testing.T) {
+ pipeReader, pipeWriter := io.Pipe()
+ defer pipeReader.Close()
+ defer pipeWriter.Close()
+
+ reader := bufio.NewReader(pipeReader)
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ result, err := promptYesNoWithCountdown(context.Background(), reader, logger, "Proceed?", 100*time.Millisecond, true)
+ if err != nil {
+ test.Fatalf("unexpected error: %v", err)
+ }
+ if result {
+ test.Fatalf("expected false on timeout")
+ }
+}
diff --git a/internal/orchestrator/pve_safe_apply_mappings.go b/internal/orchestrator/pve_safe_apply_mappings.go
new file mode 100644
index 0000000..e907a6f
--- /dev/null
+++ b/internal/orchestrator/pve_safe_apply_mappings.go
@@ -0,0 +1,318 @@
+package orchestrator
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+type pveClusterMappingJSON struct {
+ ID string `json:"id"`
+ Comment string `json:"comment,omitempty"`
+ Map []map[string]interface{} `json:"map,omitempty"`
+}
+
+type pveClusterMappingSpec struct {
+ ID string
+ Comment string
+ MapEntries []string // canonical `k=v,k=v` entries (one per node mapping)
+}
+
+func maybeApplyPVEClusterResourceMappingsWithUI(ctx context.Context, ui RestoreWorkflowUI, logger *logging.Logger, exportRoot string) error {
+ specsByType, total, err := loadPVEClusterResourceMappingsFromExport(exportRoot)
+ if err != nil {
+ return err
+ }
+ if total == 0 {
+ return nil
+ }
+
+ var parts []string
+ for _, typ := range []string{"pci", "usb", "dir"} {
+ if n := len(specsByType[typ]); n > 0 {
+ parts = append(parts, fmt.Sprintf("%s=%d", typ, n))
+ }
+ }
+ summary := strings.Join(parts, ", ")
+ if summary == "" {
+ summary = fmt.Sprintf("total=%d", total)
+ }
+
+ message := fmt.Sprintf("Found %d resource mapping(s) (%s) in the backup.\n\nRecommended: apply mappings before VM/CT configs if your guests use mapping= for PCI/USB passthrough.", total, summary)
+ applyNow, err := ui.ConfirmAction(ctx, "Apply PVE resource mappings (pvesh)", message, "Apply now", "Skip apply", 0, false)
+ if err != nil {
+ return err
+ }
+ if !applyNow {
+ logger.Info("Skipping resource mappings apply")
+ return nil
+ }
+
+ applied := 0
+ failed := 0
+ for _, typ := range []string{"pci", "usb", "dir"} {
+ specs := specsByType[typ]
+ if len(specs) == 0 {
+ continue
+ }
+ ok, bad := applyPVEClusterResourceMappings(ctx, logger, typ, specs)
+ applied += ok
+ failed += bad
+ }
+
+ if failed > 0 {
+ return fmt.Errorf("applied=%d failed=%d", applied, failed)
+ }
+ logger.Info("Resource mappings apply completed: ok=%d failed=%d", applied, failed)
+ return nil
+}
+
+func loadPVEClusterResourceMappingsFromExport(exportRoot string) (map[string][]pveClusterMappingSpec, int, error) {
+ specsByType := make(map[string][]pveClusterMappingSpec, 3)
+ total := 0
+
+ for _, typ := range []string{"pci", "usb", "dir"} {
+ specs, err := readPVEClusterResourceMappingsFromExport(exportRoot, typ)
+ if err != nil {
+ return nil, 0, err
+ }
+ if len(specs) > 0 {
+ specsByType[typ] = specs
+ total += len(specs)
+ }
+ }
+ return specsByType, total, nil
+}
+
+func readPVEClusterResourceMappingsFromExport(exportRoot, mappingType string) ([]pveClusterMappingSpec, error) {
+ mappingType = strings.TrimSpace(mappingType)
+ if mappingType == "" {
+ return nil, nil
+ }
+
+ path := filepath.Join(exportRoot, "var", "lib", "proxsave-info", "commands", "pve", fmt.Sprintf("mapping_%s.json", mappingType))
+ data, err := restoreFS.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read %s mappings: %w", mappingType, err)
+ }
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ return nil, nil
+ }
+
+ var items []pveClusterMappingJSON
+ if err := json.Unmarshal([]byte(raw), &items); err != nil {
+ // Some environments wrap data in an object.
+ var wrapper struct {
+ Data []pveClusterMappingJSON `json:"data"`
+ }
+ if err2 := json.Unmarshal([]byte(raw), &wrapper); err2 != nil {
+ return nil, fmt.Errorf("parse %s mappings JSON: %w", mappingType, err)
+ }
+ items = wrapper.Data
+ }
+
+ var specs []pveClusterMappingSpec
+ for _, item := range items {
+ id := strings.TrimSpace(item.ID)
+ if id == "" {
+ continue
+ }
+ spec := pveClusterMappingSpec{
+ ID: id,
+ Comment: strings.TrimSpace(item.Comment),
+ }
+
+ for _, m := range item.Map {
+ entry := make(map[string]string, len(m))
+ for k, v := range m {
+ k = strings.TrimSpace(k)
+ if k == "" || v == nil {
+ continue
+ }
+ entry[k] = strings.TrimSpace(fmt.Sprint(v))
+ }
+ if len(entry) == 0 {
+ continue
+ }
+ if rendered := renderMappingEntry(entry); rendered != "" {
+ spec.MapEntries = append(spec.MapEntries, rendered)
+ }
+ }
+
+ spec.MapEntries = uniqueSortedStrings(spec.MapEntries)
+ specs = append(specs, spec)
+ }
+
+ sort.Slice(specs, func(i, j int) bool { return specs[i].ID < specs[j].ID })
+ return specs, nil
+}
+
+func applyPVEClusterResourceMappings(ctx context.Context, logger *logging.Logger, mappingType string, specs []pveClusterMappingSpec) (applied, failed int) {
+ for _, spec := range specs {
+ if err := ctx.Err(); err != nil {
+ logger.Warning("Resource mappings apply aborted: %v", err)
+ return applied, failed
+ }
+ if err := applyPVEClusterResourceMapping(ctx, logger, mappingType, spec); err != nil {
+ logger.Warning("Failed to apply %s mapping %s: %v", mappingType, spec.ID, err)
+ failed++
+ } else {
+ logger.Info("Applied %s mapping %s", mappingType, spec.ID)
+ applied++
+ }
+ }
+ return applied, failed
+}
+
+func applyPVEClusterResourceMapping(ctx context.Context, logger *logging.Logger, mappingType string, spec pveClusterMappingSpec) error {
+ mappingType = strings.TrimSpace(mappingType)
+ id := strings.TrimSpace(spec.ID)
+ if mappingType == "" || id == "" {
+ return fmt.Errorf("invalid mapping (type=%q id=%q)", mappingType, id)
+ }
+ if len(spec.MapEntries) == 0 {
+ return fmt.Errorf("mapping has no entries (type=%s id=%s)", mappingType, id)
+ }
+
+ createArgs := []string{"create", fmt.Sprintf("/cluster/mapping/%s", mappingType), "--id", id}
+ if strings.TrimSpace(spec.Comment) != "" {
+ createArgs = append(createArgs, "--comment", strings.TrimSpace(spec.Comment))
+ }
+ for _, entry := range spec.MapEntries {
+ createArgs = append(createArgs, "--map", entry)
+ }
+
+ if err := runPvesh(ctx, logger, createArgs); err == nil {
+ return nil
+ }
+
+ // Create may fail if mapping already exists. Try to merge by unioning current+backup entries and updating via set.
+ mergedEntries := append([]string(nil), spec.MapEntries...)
+ comment := strings.TrimSpace(spec.Comment)
+
+ getArgs := []string{"get", fmt.Sprintf("/cluster/mapping/%s/%s", mappingType, id), "--output-format=json"}
+ if out, getErr := runPveshSensitive(ctx, logger, getArgs); getErr == nil && len(out) > 0 {
+ if existing, ok, parseErr := parsePVEClusterMappingObject(out); parseErr == nil && ok {
+ mergedEntries = uniqueSortedStrings(append(existing.MapEntries, mergedEntries...))
+ if comment == "" {
+ comment = strings.TrimSpace(existing.Comment)
+ }
+ }
+ }
+
+ setArgs := []string{"set", fmt.Sprintf("/cluster/mapping/%s/%s", mappingType, id)}
+ if comment != "" {
+ setArgs = append(setArgs, "--comment", comment)
+ }
+ for _, entry := range mergedEntries {
+ setArgs = append(setArgs, "--map", entry)
+ }
+
+ return runPvesh(ctx, logger, setArgs)
+}
+
+func parsePVEClusterMappingObject(data []byte) (pveClusterMappingSpec, bool, error) {
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ return pveClusterMappingSpec{}, false, nil
+ }
+
+ var obj pveClusterMappingJSON
+ if err := json.Unmarshal([]byte(raw), &obj); err != nil {
+ // Some endpoints return arrays even for a single mapping.
+ var arr []pveClusterMappingJSON
+ if err2 := json.Unmarshal([]byte(raw), &arr); err2 != nil || len(arr) == 0 {
+ return pveClusterMappingSpec{}, false, err
+ }
+ obj = arr[0]
+ }
+
+ id := strings.TrimSpace(obj.ID)
+ if id == "" {
+ return pveClusterMappingSpec{}, false, nil
+ }
+ spec := pveClusterMappingSpec{
+ ID: id,
+ Comment: strings.TrimSpace(obj.Comment),
+ }
+ for _, m := range obj.Map {
+ entry := make(map[string]string, len(m))
+ for k, v := range m {
+ k = strings.TrimSpace(k)
+ if k == "" || v == nil {
+ continue
+ }
+ entry[k] = strings.TrimSpace(fmt.Sprint(v))
+ }
+ if rendered := renderMappingEntry(entry); rendered != "" {
+ spec.MapEntries = append(spec.MapEntries, rendered)
+ }
+ }
+ spec.MapEntries = uniqueSortedStrings(spec.MapEntries)
+ return spec, true, nil
+}
+
+func renderMappingEntry(entry map[string]string) string {
+ if len(entry) == 0 {
+ return ""
+ }
+
+ // Prefer stable ordering: node, path, id, then the rest alphabetically.
+ var keys []string
+ for k := range entry {
+ k = strings.TrimSpace(k)
+ if k == "" {
+ continue
+ }
+ if strings.TrimSpace(entry[k]) == "" {
+ continue
+ }
+ keys = append(keys, k)
+ }
+ if len(keys) == 0 {
+ return ""
+ }
+
+ priority := func(k string) int {
+ switch strings.ToLower(strings.TrimSpace(k)) {
+ case "node":
+ return 0
+ case "path":
+ return 1
+ case "id":
+ return 2
+ default:
+ return 3
+ }
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ pi := priority(keys[i])
+ pj := priority(keys[j])
+ if pi != pj {
+ return pi < pj
+ }
+ return strings.ToLower(keys[i]) < strings.ToLower(keys[j])
+ })
+
+ parts := make([]string, 0, len(keys))
+ for _, k := range keys {
+ v := strings.TrimSpace(entry[k])
+ if v == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", k, v))
+ }
+ return strings.Join(parts, ",")
+}
+
diff --git a/internal/orchestrator/pve_safe_apply_pools.go b/internal/orchestrator/pve_safe_apply_pools.go
new file mode 100644
index 0000000..f155a49
--- /dev/null
+++ b/internal/orchestrator/pve_safe_apply_pools.go
@@ -0,0 +1,301 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+type pvePoolSpec struct {
+ ID string
+ Comment string
+ VMIDs []string
+ Storages []string
+}
+
+func readPVEPoolsFromExportUserCfg(exportRoot string) ([]pvePoolSpec, error) {
+ userCfg := filepath.Join(exportRoot, "etc", "pve", "user.cfg")
+ data, err := restoreFS.ReadFile(userCfg)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read exported user.cfg: %w", err)
+ }
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ return nil, nil
+ }
+
+ sections, err := parseProxmoxNotificationSections(raw)
+ if err != nil {
+ return nil, fmt.Errorf("parse user.cfg: %w", err)
+ }
+
+ var pools []pvePoolSpec
+ for _, s := range sections {
+ if !strings.EqualFold(strings.TrimSpace(s.Type), "pool") {
+ continue
+ }
+ id := strings.TrimSpace(s.Name)
+ if id == "" {
+ continue
+ }
+
+ spec := pvePoolSpec{ID: id}
+ for _, kv := range s.Entries {
+ key := strings.ToLower(strings.TrimSpace(kv.Key))
+ val := strings.TrimSpace(kv.Value)
+ switch key {
+ case "comment":
+ spec.Comment = val
+ case "vms":
+ spec.VMIDs = splitProxmoxCSV(val)
+ case "storage":
+ spec.Storages = splitProxmoxCSV(val)
+ }
+ }
+
+ spec.VMIDs = uniqueSortedStrings(spec.VMIDs)
+ spec.Storages = uniqueSortedStrings(spec.Storages)
+ pools = append(pools, spec)
+ }
+
+ sort.Slice(pools, func(i, j int) bool { return pools[i].ID < pools[j].ID })
+ return pools, nil
+}
+
+func summarizePoolIDs(pools []pvePoolSpec, max int) string {
+ if len(pools) == 0 || max <= 0 {
+ return ""
+ }
+ var names []string
+ for _, p := range pools {
+ if strings.TrimSpace(p.ID) != "" {
+ names = append(names, strings.TrimSpace(p.ID))
+ }
+ }
+ names = uniqueSortedStrings(names)
+ if len(names) == 0 {
+ return ""
+ }
+ if len(names) <= max {
+ return strings.Join(names, ", ")
+ }
+ return fmt.Sprintf("%s (+%d more)", strings.Join(names[:max], ", "), len(names)-max)
+}
+
+func anyPoolHasVMs(pools []pvePoolSpec) bool {
+ for _, p := range pools {
+ if len(p.VMIDs) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func listPVEPoolIDs(ctx context.Context) (map[string]struct{}, error) {
+ output, err := restoreCmd.Run(ctx, "pveum", "pool", "list")
+ raw := strings.TrimSpace(string(output))
+ if raw == "" {
+ if err != nil {
+ return nil, fmt.Errorf("pveum pool list failed: %w", err)
+ }
+ return nil, nil
+ }
+
+ out := make(map[string]struct{})
+ for _, line := range strings.Split(raw, "\n") {
+ fields := strings.Fields(strings.TrimSpace(line))
+ if len(fields) == 0 {
+ continue
+ }
+ if strings.EqualFold(fields[0], "poolid") {
+ continue
+ }
+ out[strings.TrimSpace(fields[0])] = struct{}{}
+ }
+ if err != nil {
+ return out, fmt.Errorf("pveum pool list failed: %w", err)
+ }
+ return out, nil
+}
+
+func pvePoolAlreadyExists(existing map[string]struct{}, id string, addOutput []byte) bool {
+ if existing != nil {
+ if _, ok := existing[id]; ok {
+ return true
+ }
+ }
+ msg := strings.ToLower(strings.TrimSpace(string(addOutput)))
+ return strings.Contains(msg, "already exists") || strings.Contains(msg, "already exist")
+}
+
+func applyPVEPoolsDefinitions(ctx context.Context, logger *logging.Logger, pools []pvePoolSpec) (applied, failed int, err error) {
+ if len(pools) == 0 {
+ return 0, 0, nil
+ }
+ if _, lookErr := exec.LookPath("pveum"); lookErr != nil {
+ return 0, 0, fmt.Errorf("pveum not found in PATH")
+ }
+
+ done := logging.DebugStart(logger, "pve pools apply (definitions)", "pools=%d", len(pools))
+ defer func() { done(err) }()
+
+ existingPools, listErr := listPVEPoolIDs(ctx)
+ if listErr != nil {
+ logger.Debug("Pools: unable to list existing pools: %v", listErr)
+ }
+
+ for _, pool := range pools {
+ if err := ctx.Err(); err != nil {
+ return applied, failed, err
+ }
+ id := strings.TrimSpace(pool.ID)
+ if id == "" {
+ continue
+ }
+
+ comment := strings.TrimSpace(pool.Comment)
+ ok := false
+
+ addArgs := []string{"pool", "add", id}
+ if comment != "" {
+ addArgs = append(addArgs, "--comment", comment)
+ }
+ addOut, addErr := restoreCmd.Run(ctx, "pveum", addArgs...)
+ if addErr != nil {
+ logger.Debug("Pools: add %s failed (may already exist): %v", id, addErr)
+ if comment == "" && pvePoolAlreadyExists(existingPools, id, addOut) {
+ ok = true
+ }
+ } else {
+ ok = true
+ if existingPools != nil {
+ existingPools[id] = struct{}{}
+ }
+ }
+
+ // Ensure comment is applied even if the pool already existed.
+ if comment != "" {
+ modArgs := []string{"pool", "modify", id, "--comment", comment}
+ if _, modErr := restoreCmd.Run(ctx, "pveum", modArgs...); modErr != nil {
+ logger.Warning("Pools: failed to set comment for %s: %v", id, modErr)
+ } else {
+ ok = true
+ }
+ }
+
+ if ok {
+ applied++
+ logger.Info("Applied pool definition %s", id)
+ } else {
+ failed++
+ }
+ }
+
+ if failed > 0 {
+ return applied, failed, fmt.Errorf("applied=%d failed=%d", applied, failed)
+ }
+ return applied, failed, nil
+}
+
+func applyPVEPoolsMembership(ctx context.Context, logger *logging.Logger, pools []pvePoolSpec, allowMove bool) (applied, failed int, err error) {
+ if len(pools) == 0 {
+ return 0, 0, nil
+ }
+ if _, lookErr := exec.LookPath("pveum"); lookErr != nil {
+ return 0, 0, fmt.Errorf("pveum not found in PATH")
+ }
+
+ done := logging.DebugStart(logger, "pve pools apply (membership)", "pools=%d allowMove=%v", len(pools), allowMove)
+ defer func() { done(err) }()
+
+ for _, pool := range pools {
+ if err := ctx.Err(); err != nil {
+ return applied, failed, err
+ }
+ id := strings.TrimSpace(pool.ID)
+ if id == "" {
+ continue
+ }
+
+ vmids := uniqueSortedStrings(pool.VMIDs)
+ storages := uniqueSortedStrings(pool.Storages)
+ if len(vmids) == 0 && len(storages) == 0 {
+ continue
+ }
+
+ args := []string{"pool", "modify", id}
+ if allowMove && len(vmids) > 0 {
+ args = append(args, "--allow-move", "1")
+ }
+ if len(vmids) > 0 {
+ args = append(args, "--vms", strings.Join(vmids, ","))
+ }
+ if len(storages) > 0 {
+ args = append(args, "--storage", strings.Join(storages, ","))
+ }
+
+ if _, applyErr := restoreCmd.Run(ctx, "pveum", args...); applyErr != nil {
+ logger.Warning("Pools: failed to apply membership for %s: %v", id, applyErr)
+ failed++
+ continue
+ }
+
+ applied++
+ logger.Info("Applied pool membership %s", id)
+ }
+
+ if failed > 0 {
+ return applied, failed, fmt.Errorf("applied=%d failed=%d", applied, failed)
+ }
+ return applied, failed, nil
+}
+
+func splitProxmoxCSV(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.FieldsFunc(raw, func(r rune) bool {
+ switch r {
+ case ',', ';', ' ', '\t', '\n', '\r':
+ return true
+ default:
+ return false
+ }
+ })
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if trimmed := strings.TrimSpace(p); trimmed != "" {
+ out = append(out, trimmed)
+ }
+ }
+ return out
+}
+
+func uniqueSortedStrings(items []string) []string {
+ seen := make(map[string]struct{}, len(items))
+ var out []string
+ for _, s := range items {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ continue
+ }
+ if _, ok := seen[s]; ok {
+ continue
+ }
+ seen[s] = struct{}{}
+ out = append(out, s)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/orchestrator/pve_safe_apply_pools_test.go b/internal/orchestrator/pve_safe_apply_pools_test.go
new file mode 100644
index 0000000..f037ca1
--- /dev/null
+++ b/internal/orchestrator/pve_safe_apply_pools_test.go
@@ -0,0 +1,70 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestApplyPVEPoolsDefinitions_ExistingPoolNoComment_IsSuccess(t *testing.T) {
+ origCmd := restoreCmd
+ t.Cleanup(func() { restoreCmd = origCmd })
+
+ pathDir := t.TempDir()
+ pveumPath := filepath.Join(pathDir, "pveum")
+ if err := os.WriteFile(pveumPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write pveum stub: %v", err)
+ }
+ t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ runner := &FakeCommandRunner{
+ Outputs: map[string][]byte{
+ "pveum pool list": []byte("poolid comment\ndev\n"),
+ "pveum pool add dev": []byte("pool 'dev' already exists\n"),
+ },
+ Errors: map[string]error{
+ "pveum pool add dev": fmt.Errorf("exit status 255"),
+ },
+ }
+ restoreCmd = runner
+
+ applied, failed, err := applyPVEPoolsDefinitions(context.Background(), newTestLogger(), []pvePoolSpec{{ID: "dev"}})
+ if err != nil {
+ t.Fatalf("applyPVEPoolsDefinitions error: %v", err)
+ }
+ if applied != 1 || failed != 0 {
+ t.Fatalf("applyPVEPoolsDefinitions applied=%d failed=%d want applied=1 failed=0", applied, failed)
+ }
+}
+
+func TestApplyPVEPoolsDefinitions_AddFailsNoCommentAndPoolMissing_IsFailure(t *testing.T) {
+ origCmd := restoreCmd
+ t.Cleanup(func() { restoreCmd = origCmd })
+
+ pathDir := t.TempDir()
+ pveumPath := filepath.Join(pathDir, "pveum")
+ if err := os.WriteFile(pveumPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write pveum stub: %v", err)
+ }
+ t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ runner := &FakeCommandRunner{
+ Outputs: map[string][]byte{
+ "pveum pool list": []byte("poolid comment\n"),
+ },
+ Errors: map[string]error{
+ "pveum pool add dev": fmt.Errorf("boom"),
+ },
+ }
+ restoreCmd = runner
+
+ applied, failed, err := applyPVEPoolsDefinitions(context.Background(), newTestLogger(), []pvePoolSpec{{ID: "dev"}})
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ if applied != 0 || failed != 1 {
+ t.Fatalf("applyPVEPoolsDefinitions applied=%d failed=%d want applied=0 failed=1", applied, failed)
+ }
+}
diff --git a/internal/orchestrator/pve_staged_apply.go b/internal/orchestrator/pve_staged_apply.go
new file mode 100644
index 0000000..3af6892
--- /dev/null
+++ b/internal/orchestrator/pve_staged_apply.go
@@ -0,0 +1,506 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+func maybeApplyPVEConfigsFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot, destRoot string, dryRun bool) (err error) {
+ if plan == nil || plan.SystemType != SystemTypePVE {
+ return nil
+ }
+ if !plan.HasCategoryID("storage_pve") && !plan.HasCategoryID("pve_jobs") {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "pve staged apply", "Skipped: staging directory not available")
+ return nil
+ }
+ if filepath.Clean(strings.TrimSpace(destRoot)) != string(os.PathSeparator) {
+ logging.DebugStep(logger, "pve staged apply", "Skipped: restore destination is not system root (dest=%s)", destRoot)
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "pve staged apply", "dryRun=%v stage=%s", dryRun, stageRoot)
+ defer func() { done(err) }()
+
+ if dryRun {
+ logger.Info("Dry run enabled: skipping staged PVE config apply")
+ return nil
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping staged PVE config apply: non-system filesystem in use")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping staged PVE config apply: requires root privileges")
+ return nil
+ }
+
+ if plan.HasCategoryID("storage_pve") {
+ if err := applyPVEVzdumpConfFromStage(logger, stageRoot); err != nil {
+ logger.Warning("PVE staged apply: vzdump.conf: %v", err)
+ }
+
+ // In cluster RECOVERY mode, config.db restoration owns storage.cfg/datacenter.cfg.
+ // Still apply mount guards because they only protect mountpoints from accidental writes.
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "pve staged apply", "Skip PVE storage/datacenter apply: cluster RECOVERY restores config.db")
+ } else {
+ if err := applyPVEStorageCfgFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PVE staged apply: storage.cfg: %v", err)
+ }
+ }
+
+ if err := maybeApplyPVEStorageMountGuardsFromStage(ctx, logger, plan, stageRoot, destRoot); err != nil {
+ logger.Warning("PVE staged apply: mount guards: %v", err)
+ }
+
+ if !plan.NeedsClusterRestore {
+ if err := applyPVEDatacenterCfgFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PVE staged apply: datacenter.cfg: %v", err)
+ }
+ }
+ }
+
+ if plan.HasCategoryID("pve_jobs") {
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "pve staged apply", "Skip PVE backup jobs apply: cluster RECOVERY restores config.db")
+ } else {
+ if err := applyPVEBackupJobsFromStage(ctx, logger, stageRoot); err != nil {
+ logger.Warning("PVE staged apply: jobs.cfg: %v", err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func applyPVEVzdumpConfFromStage(logger *logging.Logger, stageRoot string) error {
+ rel := "etc/vzdump.conf"
+ stagePath := filepath.Join(stageRoot, rel)
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "pve staged apply file", "Skip %s: not present in staging directory", rel)
+ return nil
+ }
+ return fmt.Errorf("read staged %s: %w", rel, err)
+ }
+
+ trimmed := strings.TrimSpace(string(data))
+ destPath := "/etc/vzdump.conf"
+ if trimmed == "" {
+ logger.Warning("PVE staged apply: %s is empty; removing %s", rel, destPath)
+ return removeIfExists(destPath)
+ }
+
+ if err := restoreFS.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
+ return fmt.Errorf("ensure %s: %w", filepath.Dir(destPath), err)
+ }
+ if err := restoreFS.WriteFile(destPath, []byte(trimmed+"\n"), 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", destPath, err)
+ }
+
+ logging.DebugStep(logger, "pve staged apply file", "Applied %s -> %s", rel, destPath)
+ return nil
+}
+
+func applyPVEStorageCfgFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) error {
+ if _, err := restoreCmd.Run(ctx, "which", "pvesh"); err != nil {
+ logger.Warning("pvesh not found; skipping PVE storage.cfg apply")
+ return nil
+ }
+
+ stagePath := filepath.Join(stageRoot, "etc/pve/storage.cfg")
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "pve staged apply storage.cfg", "Skipped: storage.cfg not present in staging directory")
+ return nil
+ }
+ return fmt.Errorf("read staged storage.cfg: %w", err)
+ }
+ if strings.TrimSpace(string(data)) == "" {
+ logging.DebugStep(logger, "pve staged apply storage.cfg", "Staged storage.cfg is empty; skipping apply")
+ return nil
+ }
+
+ applied, failed, err := applyStorageCfg(ctx, stagePath, logger)
+ if err != nil {
+ return err
+ }
+ logger.Info("PVE staged apply: storage.cfg applied (ok=%d failed=%d)", applied, failed)
+ return nil
+}
+
+func applyPVEDatacenterCfgFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) error {
+ if _, err := restoreCmd.Run(ctx, "which", "pvesh"); err != nil {
+ logger.Warning("pvesh not found; skipping PVE datacenter.cfg apply")
+ return nil
+ }
+
+ stagePath := filepath.Join(stageRoot, "etc/pve/datacenter.cfg")
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "pve staged apply datacenter.cfg", "Skipped: datacenter.cfg not present in staging directory")
+ return nil
+ }
+ return fmt.Errorf("read staged datacenter.cfg: %w", err)
+ }
+ if strings.TrimSpace(string(data)) == "" {
+ logging.DebugStep(logger, "pve staged apply datacenter.cfg", "Staged datacenter.cfg is empty; skipping apply")
+ return nil
+ }
+
+ if err := runPvesh(ctx, logger, []string{"set", "/cluster/config", "-conf", stagePath}); err != nil {
+ return err
+ }
+ logger.Info("PVE staged apply: datacenter.cfg applied")
+ return nil
+}
+
+func applyPVEBackupJobsFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) error {
+ if _, err := restoreCmd.Run(ctx, "which", "pvesh"); err != nil {
+ logger.Warning("pvesh not found; skipping PVE jobs apply")
+ return nil
+ }
+
+ stagePath := filepath.Join(stageRoot, "etc/pve/jobs.cfg")
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "pve staged apply jobs.cfg", "Skipped: jobs.cfg not present in staging directory")
+ return nil
+ }
+ return fmt.Errorf("read staged jobs.cfg: %w", err)
+ }
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ logging.DebugStep(logger, "pve staged apply jobs.cfg", "Staged jobs.cfg is empty; skipping apply")
+ return nil
+ }
+
+ sections, err := parseProxmoxNotificationSections(raw)
+ if err != nil {
+ return fmt.Errorf("parse jobs.cfg: %w", err)
+ }
+
+ var jobs []proxmoxNotificationSection
+ for _, s := range sections {
+ if strings.EqualFold(strings.TrimSpace(s.Type), "vzdump") && strings.TrimSpace(s.Name) != "" {
+ jobs = append(jobs, s)
+ }
+ }
+ if len(jobs) == 0 {
+ logging.DebugStep(logger, "pve staged apply jobs.cfg", "No vzdump jobs detected; skipping")
+ return nil
+ }
+
+ applied := 0
+ failed := 0
+ for _, job := range jobs {
+ jobID := strings.TrimSpace(job.Name)
+ if jobID == "" {
+ continue
+ }
+
+ args := []string{"create", "/cluster/backup", "--id", jobID}
+ for _, kv := range job.Entries {
+ key := strings.TrimSpace(kv.Key)
+ value := strings.TrimSpace(kv.Value)
+ if key == "" || value == "" {
+ continue
+ }
+ args = append(args, "--"+key, value)
+ }
+
+ if err := runPvesh(ctx, logger, args); err != nil {
+ // Fallback: if job exists, try updating it.
+ updateArgs := []string{"set", fmt.Sprintf("/cluster/backup/%s", jobID)}
+ for _, kv := range job.Entries {
+ key := strings.TrimSpace(kv.Key)
+ value := strings.TrimSpace(kv.Value)
+ if key == "" || value == "" {
+ continue
+ }
+ updateArgs = append(updateArgs, "--"+key, value)
+ }
+ if err2 := runPvesh(ctx, logger, updateArgs); err2 != nil {
+ logger.Warning("Failed to apply PVE backup job %s: %v", jobID, err2)
+ failed++
+ continue
+ }
+ }
+
+ applied++
+ logger.Info("Applied PVE backup job %s", jobID)
+ }
+
+ if failed > 0 {
+ return fmt.Errorf("applied=%d failed=%d", applied, failed)
+ }
+ return nil
+}
+
+func maybeApplyPVEStorageMountGuardsFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot, destRoot string) error {
+ if plan == nil || plan.SystemType != SystemTypePVE || !plan.HasCategoryID("storage_pve") {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ return nil
+ }
+ if filepath.Clean(strings.TrimSpace(destRoot)) != string(os.PathSeparator) {
+ return nil
+ }
+ if !isRealRestoreFS(restoreFS) || os.Geteuid() != 0 {
+ return nil
+ }
+
+ stagePath := filepath.Join(stageRoot, "etc/pve/storage.cfg")
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read staged storage.cfg: %w", err)
+ }
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ return nil
+ }
+
+ sections, err := parseProxmoxNotificationSections(raw)
+ if err != nil {
+ return fmt.Errorf("parse storage.cfg: %w", err)
+ }
+
+ candidates := pveStorageMountGuardCandidatesFromSections(sections)
+ if len(candidates) == 0 {
+ return nil
+ }
+
+ currentFstab := filepath.Join(destRoot, "etc", "fstab")
+ mounts, err := fstabMountpointsSet(currentFstab)
+ if err != nil {
+ if logger != nil {
+ logger.Warning("PVE mount guard: unable to parse current fstab %s: %v (continuing without fstab cross-check)", currentFstab, err)
+ }
+ }
+ var mountCandidates []string
+ if len(mounts) > 0 {
+ for mp := range mounts {
+ if mp == "" || mp == "." || mp == string(os.PathSeparator) {
+ continue
+ }
+ mountCandidates = append(mountCandidates, mp)
+ }
+ sortByLengthDesc(mountCandidates)
+ }
+
+ pvesmAvailable := false
+ if _, err := restoreCmd.Run(ctx, "which", "pvesm"); err == nil {
+ pvesmAvailable = true
+ }
+
+ protected := make(map[string]struct{})
+ for _, item := range pveStorageMountGuardItems(candidates, mountCandidates, mounts) {
+ guardTarget := filepath.Clean(strings.TrimSpace(item.GuardTarget))
+ if guardTarget == "" || guardTarget == "." || guardTarget == string(os.PathSeparator) {
+ continue
+ }
+ if _, ok := protected[guardTarget]; ok {
+ continue
+ }
+ protected[guardTarget] = struct{}{}
+
+ // Safety: only guard typical mount roots (prevent accidental rootfs directory shadowing).
+ if !isConfirmableDatastoreMountRoot(guardTarget) {
+ if logger != nil {
+ logger.Debug("PVE mount guard: skip unsafe mount root %s (storage=%s type=%s)", guardTarget, item.StorageID, item.StorageType)
+ }
+ continue
+ }
+
+ if err := os.MkdirAll(guardTarget, 0o755); err != nil {
+ if logger != nil {
+ logger.Warning("PVE mount guard: unable to create mountpoint directory %s: %v", guardTarget, err)
+ }
+ continue
+ }
+
+ onRootFS, _, devErr := isPathOnRootFilesystem(guardTarget)
+ if devErr != nil {
+ if logger != nil {
+ logger.Warning("PVE mount guard: unable to determine filesystem device for %s: %v", guardTarget, devErr)
+ }
+ continue
+ }
+ if !onRootFS {
+ continue
+ }
+
+ mounted, mountErr := isMounted(guardTarget)
+ if mountErr == nil && mounted {
+ continue
+ }
+
+ // Best-effort mount/activate attempt (avoid guarding mountpoints that would mount cleanly).
+ mountCtx, cancel := context.WithTimeout(ctx, mountGuardMountAttemptTimeout)
+ var attemptErr error
+ if item.IsNetwork && pvesmAvailable && item.StorageID != "" {
+ _, attemptErr = restoreCmd.Run(mountCtx, "pvesm", "activate", item.StorageID)
+ } else {
+ _, attemptErr = restoreCmd.Run(mountCtx, "mount", guardTarget)
+ }
+ cancel()
+
+ if attemptErr == nil {
+ onRootFSNow, _, devErrNow := isPathOnRootFilesystem(guardTarget)
+ if devErrNow == nil && !onRootFSNow {
+ if logger != nil {
+ logger.Info("PVE mount guard: mountpoint %s is now mounted (activation/mount attempt succeeded)", guardTarget)
+ }
+ continue
+ }
+ if mountedNow, mountErrNow := isMounted(guardTarget); mountErrNow == nil && mountedNow {
+ if logger != nil {
+ logger.Info("PVE mount guard: mountpoint %s is now mounted (activation/mount attempt succeeded)", guardTarget)
+ }
+ continue
+ }
+ }
+
+ if logger != nil {
+ if item.IsNetwork {
+ logger.Info("PVE mount guard: storage %s (%s) offline, applying guard bind mount on %s", item.StorageID, item.StorageType, guardTarget)
+ } else {
+ logger.Info("PVE mount guard: mountpoint %s offline, applying guard bind mount", guardTarget)
+ }
+ }
+
+ if err := guardMountPoint(ctx, guardTarget); err != nil {
+ if logger != nil {
+ logger.Warning("PVE mount guard: failed to bind-mount guard on %s: %v; falling back to chattr +i", guardTarget, err)
+ }
+ if _, fallbackErr := restoreCmd.Run(ctx, "chattr", "+i", guardTarget); fallbackErr != nil {
+ if logger != nil {
+ logger.Warning("PVE mount guard: failed to set immutable attribute on %s: %v", guardTarget, fallbackErr)
+ }
+ continue
+ }
+ if logger != nil {
+ logger.Warning("PVE mount guard: %s resolves to root filesystem (mount missing?) — marked immutable (chattr +i) to prevent writes until storage is available", guardTarget)
+ }
+ continue
+ }
+ if logger != nil {
+ logger.Warning("PVE mount guard: %s resolves to root filesystem (mount missing?) — bind-mounted a read-only guard to prevent writes until storage is available", guardTarget)
+ }
+ }
+
+ return nil
+}
+
+type pveStorageMountGuardCandidate struct {
+ StorageID string
+ StorageType string
+ Path string
+}
+
+func pveStorageMountGuardCandidatesFromSections(sections []proxmoxNotificationSection) []pveStorageMountGuardCandidate {
+ out := make([]pveStorageMountGuardCandidate, 0, len(sections))
+ for _, s := range sections {
+ storageType := strings.ToLower(strings.TrimSpace(s.Type))
+ storageID := strings.TrimSpace(s.Name)
+ if storageType == "" || storageID == "" {
+ continue
+ }
+
+ c := pveStorageMountGuardCandidate{
+ StorageID: storageID,
+ StorageType: storageType,
+ }
+ if storageType == "dir" {
+ for _, kv := range s.Entries {
+ if strings.EqualFold(strings.TrimSpace(kv.Key), "path") {
+ c.Path = filepath.Clean(strings.TrimSpace(kv.Value))
+ break
+ }
+ }
+ }
+ out = append(out, c)
+ }
+ return out
+}
+
+type pveStorageMountGuardItem struct {
+ GuardTarget string
+ StorageID string
+ StorageType string
+ IsNetwork bool
+ RequiresFstab bool
+}
+
+func pveStorageMountGuardItems(candidates []pveStorageMountGuardCandidate, mountCandidates []string, fstabMounts map[string]struct{}) []pveStorageMountGuardItem {
+ out := make([]pveStorageMountGuardItem, 0, len(candidates))
+ for _, c := range candidates {
+ storageType := strings.ToLower(strings.TrimSpace(c.StorageType))
+ storageID := strings.TrimSpace(c.StorageID)
+ if storageType == "" || storageID == "" {
+ continue
+ }
+
+ switch storageType {
+ case "nfs", "cifs", "cephfs", "glusterfs":
+ out = append(out, pveStorageMountGuardItem{
+ GuardTarget: filepath.Join("/mnt/pve", storageID),
+ StorageID: storageID,
+ StorageType: storageType,
+ IsNetwork: true,
+ RequiresFstab: false,
+ })
+
+ case "dir":
+ path := filepath.Clean(strings.TrimSpace(c.Path))
+ if path == "" || path == "." || path == string(os.PathSeparator) {
+ continue
+ }
+ target := firstFstabMountpointMatch(path, mountCandidates)
+ if target == "" {
+ target = pbsMountGuardRootForDatastorePath(path)
+ }
+ target = filepath.Clean(strings.TrimSpace(target))
+ if target == "" || target == "." || target == string(os.PathSeparator) {
+ continue
+ }
+ // Only guard dir-backed storage if the mountpoint is present in fstab (avoid making rootfs dirs immutable).
+ if fstabMounts == nil {
+ continue
+ }
+ if _, ok := fstabMounts[target]; !ok {
+ continue
+ }
+ out = append(out, pveStorageMountGuardItem{
+ GuardTarget: target,
+ StorageID: storageID,
+ StorageType: storageType,
+ IsNetwork: false,
+ RequiresFstab: true,
+ })
+ }
+ }
+
+ sort.Slice(out, func(i, j int) bool {
+ return len(out[i].GuardTarget) > len(out[j].GuardTarget)
+ })
+ return out
+}
diff --git a/internal/orchestrator/pve_staged_apply_test.go b/internal/orchestrator/pve_staged_apply_test.go
new file mode 100644
index 0000000..6015116
--- /dev/null
+++ b/internal/orchestrator/pve_staged_apply_test.go
@@ -0,0 +1,87 @@
+package orchestrator
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestPVEStorageMountGuardItems_BuildsExpectedTargets(t *testing.T) {
+ t.Parallel()
+
+ candidates := []pveStorageMountGuardCandidate{
+ {StorageID: "Data1", StorageType: "dir", Path: "/mnt/datastore/Data1"},
+ {StorageID: "Synology-Archive", StorageType: "dir", Path: "/mnt/Synology_NFS/PBS_Backup"},
+ {StorageID: "local", StorageType: "dir", Path: "/var/lib/vz"},
+ {StorageID: "nfs-backup", StorageType: "nfs"},
+ }
+ mountCandidates := []string{"/mnt/datastore", "/mnt/Synology_NFS", "/"}
+ fstabMounts := map[string]struct{}{
+ "/mnt/datastore": {},
+ "/mnt/Synology_NFS": {},
+ "/": {},
+ }
+
+ items := pveStorageMountGuardItems(candidates, mountCandidates, fstabMounts)
+ got := make(map[string]pveStorageMountGuardItem, len(items))
+ for _, item := range items {
+ got[item.GuardTarget] = item
+ }
+
+ wantTargets := []string{"/mnt/datastore", "/mnt/Synology_NFS", "/mnt/pve/nfs-backup"}
+ for _, target := range wantTargets {
+ if _, ok := got[target]; !ok {
+ t.Fatalf("missing guard target %s; got=%v", target, got)
+ }
+ }
+ if len(got) != len(wantTargets) {
+ t.Fatalf("unexpected number of guard targets: got=%v want=%v", got, wantTargets)
+ }
+}
+
+func TestApplyPVEBackupJobsFromStage_CreatesJobsViaPvesh(t *testing.T) {
+ t.Parallel()
+
+ origFS := restoreFS
+ origCmd := restoreCmd
+ t.Cleanup(func() {
+ restoreFS = origFS
+ restoreCmd = origCmd
+ })
+
+ fakeFS := NewFakeFS()
+ restoreFS = fakeFS
+
+ fakeCmd := &FakeCommandRunner{}
+ restoreCmd = fakeCmd
+
+ stageRoot := "/stage"
+ cfg := strings.Join([]string{
+ "vzdump: job1",
+ " node pve1",
+ " storage local",
+ "",
+ "vzdump: job2",
+ " node pve1",
+ " storage backup",
+ "",
+ }, "\n")
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/jobs.cfg", []byte(cfg)); err != nil {
+ t.Fatalf("add jobs.cfg: %v", err)
+ }
+
+ if err := applyPVEBackupJobsFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPVEBackupJobsFromStage error: %v", err)
+ }
+
+ calls := strings.Join(fakeCmd.CallsList(), "\n")
+ if !strings.Contains(calls, "which pvesh") {
+ t.Fatalf("expected which pvesh call; calls=%v", fakeCmd.CallsList())
+ }
+ if !strings.Contains(calls, "pvesh create /cluster/backup --id job1 --node pve1 --storage local") {
+ t.Fatalf("expected create job1 call; calls=%v", fakeCmd.CallsList())
+ }
+ if !strings.Contains(calls, "pvesh create /cluster/backup --id job2 --node pve1 --storage backup") {
+ t.Fatalf("expected create job2 call; calls=%v", fakeCmd.CallsList())
+ }
+}
diff --git a/internal/orchestrator/pvesh_sensitive.go b/internal/orchestrator/pvesh_sensitive.go
new file mode 100644
index 0000000..bc07e6f
--- /dev/null
+++ b/internal/orchestrator/pvesh_sensitive.go
@@ -0,0 +1,38 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+func runPveshSensitive(ctx context.Context, _ *logging.Logger, args []string, redactFlags ...string) ([]byte, error) {
+ output, err := restoreCmd.Run(ctx, "pvesh", args...)
+ if err != nil {
+ redacted := redactCLIArgs(args, redactFlags)
+ return output, fmt.Errorf("pvesh %s failed: %w", strings.Join(redacted, " "), err)
+ }
+ return output, nil
+}
+
+func redactCLIArgs(args []string, redactFlags []string) []string {
+ if len(args) == 0 || len(redactFlags) == 0 {
+ return append([]string(nil), args...)
+ }
+ redact := make(map[string]struct{}, len(redactFlags))
+ for _, flag := range redactFlags {
+ redact[strings.TrimSpace(flag)] = struct{}{}
+ }
+ out := make([]string, 0, len(args))
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ out = append(out, arg)
+ if _, ok := redact[arg]; ok && i+1 < len(args) {
+ i++
+ out = append(out, "")
+ }
+ }
+ return out
+}
diff --git a/internal/orchestrator/resolv_conf_repair.go b/internal/orchestrator/resolv_conf_repair.go
index 3c967c2..396e641 100644
--- a/internal/orchestrator/resolv_conf_repair.go
+++ b/internal/orchestrator/resolv_conf_repair.go
@@ -77,17 +77,23 @@ func maybeRepairResolvConfAfterRestore(ctx context.Context, logger *logging.Logg
}
if strings.TrimSpace(archivePath) != "" {
- data, err := readTarEntry(ctx, archivePath, "commands/resolv_conf.txt", maxResolvConfSize)
- if err == nil && hasNameserverEntries(string(data)) {
- logging.DebugStep(logger, "resolv.conf repair", "Using DNS resolver content from archive commands/resolv_conf.txt")
- if err := restoreFS.WriteFile(resolvConfPath, normalizeResolvConf(data), 0o644); err != nil {
- return fmt.Errorf("write %s: %w", resolvConfPath, err)
+ candidates := []string{
+ "var/lib/proxsave-info/commands/system/resolv_conf.txt",
+ "commands/resolv_conf.txt",
+ }
+ for _, candidate := range candidates {
+ data, err := readTarEntry(ctx, archivePath, candidate, maxResolvConfSize)
+ if err == nil && hasNameserverEntries(string(data)) {
+ logging.DebugStep(logger, "resolv.conf repair", "Using DNS resolver content from archive %s", candidate)
+ if err := restoreFS.WriteFile(resolvConfPath, normalizeResolvConf(data), 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", resolvConfPath, err)
+ }
+ logger.Info("DNS resolver repaired: restored %s from archive diagnostics", resolvConfPath)
+ return nil
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ logger.Debug("DNS resolver repair: could not read %s from archive: %v", candidate, err)
}
- logger.Info("DNS resolver repaired: restored %s from archive diagnostics", resolvConfPath)
- return nil
- }
- if err != nil && !errors.Is(err, os.ErrNotExist) {
- logger.Debug("DNS resolver repair: could not read commands/resolv_conf.txt from archive: %v", err)
}
}
@@ -102,7 +108,8 @@ func maybeRepairResolvConfAfterRestore(ctx context.Context, logger *logging.Logg
func isProxsaveCommandsSymlink(target string) bool {
target = filepath.ToSlash(strings.TrimSpace(target))
- return strings.Contains(target, "commands/resolv_conf.txt")
+ return strings.Contains(target, "var/lib/proxsave-info/commands/system/resolv_conf.txt") ||
+ strings.Contains(target, "commands/resolv_conf.txt")
}
func removeResolvConfIfPresent() error {
diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go
index d50d267..0369703 100644
--- a/internal/orchestrator/restore.go
+++ b/internal/orchestrator/restore.go
@@ -64,545 +64,15 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging
if cfg == nil {
return fmt.Errorf("configuration not available")
}
- done := logging.DebugStart(logger, "restore workflow (cli)", "version=%s", version)
- defer func() { done(err) }()
-
- restoreHadWarnings := false
- defer func() {
- if err == nil {
- return
- }
- if errors.Is(err, input.ErrInputAborted) ||
- errors.Is(err, ErrDecryptAborted) ||
- errors.Is(err, ErrAgeRecipientSetupAborted) ||
- errors.Is(err, context.Canceled) ||
- (ctx != nil && ctx.Err() != nil) {
- err = ErrRestoreAborted
- }
- }()
-
- reader := bufio.NewReader(os.Stdin)
- candidate, prepared, err := prepareDecryptedBackupFunc(ctx, reader, cfg, logger, version, false)
- if err != nil {
- return err
- }
- defer prepared.Cleanup()
-
- destRoot := "/"
- logger.Info("Restore target: system root (/) — files will be written back to their original paths")
-
- // Detect system type
- systemType := restoreSystem.DetectCurrentSystem()
- logger.Info("Detected system type: %s", GetSystemTypeString(systemType))
-
- // Validate compatibility
- if err := ValidateCompatibility(candidate.Manifest); err != nil {
- logger.Warning("Compatibility check: %v", err)
- fmt.Println()
- fmt.Printf("⚠ %v\n", err)
- fmt.Println()
- fmt.Print("Do you want to continue anyway? This may cause system instability. (yes/no): ")
-
- response, err := input.ReadLineWithContext(ctx, reader)
- if err != nil {
- return err
- }
- if strings.TrimSpace(strings.ToLower(response)) != "yes" {
- return fmt.Errorf("restore aborted due to incompatibility")
- }
- }
-
- // Analyze available categories in the backup
- logger.Info("Analyzing backup contents...")
- availableCategories, err := AnalyzeBackupCategories(prepared.ArchivePath, logger)
- if err != nil {
- logger.Warning("Could not analyze categories: %v", err)
- logger.Info("Falling back to full restore mode")
- return runFullRestore(ctx, reader, candidate, prepared, destRoot, logger, cfg.DryRun)
- }
-
- // Show restore mode selection menu
- mode, err := restorePrompter.SelectRestoreMode(ctx, logger, systemType)
- if err != nil {
- if errors.Is(err, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- return err
- }
- // Determine selected categories based on mode
- var selectedCategories []Category
- if mode == RestoreModeCustom {
- // Interactive category selection
- selectedCategories, err = restorePrompter.SelectCategories(ctx, logger, availableCategories, systemType)
- if err != nil {
- if errors.Is(err, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- return err
- }
- } else {
- // Pre-defined mode (Full, Storage, Base)
- selectedCategories = GetCategoriesForMode(mode, systemType, availableCategories)
- }
-
- plan := PlanRestore(candidate.Manifest, selectedCategories, systemType, mode)
-
- // Cluster safety prompt: if backup proviene da cluster e vogliamo ripristinare pve_cluster, chiedi come procedere.
- clusterBackup := strings.EqualFold(strings.TrimSpace(candidate.Manifest.ClusterMode), "cluster")
- if plan.NeedsClusterRestore && clusterBackup {
- logger.Info("Backup marked as cluster node; enabling guarded restore options for pve_cluster")
- choice, promptErr := promptClusterRestoreMode(ctx, reader)
- if promptErr != nil {
- return promptErr
- }
- if choice == 0 {
- return ErrRestoreAborted
- }
- if choice == 1 {
- plan.ApplyClusterSafeMode(true)
- logger.Info("Selected SAFE cluster restore: /var/lib/pve-cluster will be exported only, not written to system")
- } else {
- plan.ApplyClusterSafeMode(false)
- logger.Warning("Selected RECOVERY cluster restore: full cluster database will be restored; ensure other nodes are isolated")
- }
- }
-
- // Staging is designed to protect live systems. In test runs (fake filesystem) or non-root targets,
- // extract staged categories directly to the destination to keep restore semantics predictable.
- if destRoot != "/" || !isRealRestoreFS(restoreFS) {
- if len(plan.StagedCategories) > 0 {
- logging.DebugStep(logger, "restore", "Staging disabled (destRoot=%s realFS=%v): extracting %d staged category(ies) directly", destRoot, isRealRestoreFS(restoreFS), len(plan.StagedCategories))
- plan.NormalCategories = append(plan.NormalCategories, plan.StagedCategories...)
- plan.StagedCategories = nil
- }
- }
-
- // Create restore configuration
- restoreConfig := &SelectiveRestoreConfig{
- Mode: mode,
- SystemType: systemType,
- Metadata: candidate.Manifest,
- }
- restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.NormalCategories...)
- restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.StagedCategories...)
- restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.ExportCategories...)
-
- // Show detailed restore plan
- ShowRestorePlan(logger, restoreConfig)
-
- // Confirm operation
- confirmed, err := restorePrompter.ConfirmRestore(ctx, logger)
- if err != nil {
- if errors.Is(err, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- return err
- }
- if !confirmed {
- logger.Info("Restore operation cancelled by user")
- return ErrRestoreAborted
- }
-
- // Create safety backup of current configuration (only for categories that will write to system paths)
- var safetyBackup *SafetyBackupResult
- var networkRollbackBackup *SafetyBackupResult
- systemWriteCategories := append([]Category{}, plan.NormalCategories...)
- systemWriteCategories = append(systemWriteCategories, plan.StagedCategories...)
- if len(systemWriteCategories) > 0 {
- logger.Info("")
- safetyBackup, err = CreateSafetyBackup(logger, systemWriteCategories, destRoot)
- if err != nil {
- logger.Warning("Failed to create safety backup: %v", err)
- fmt.Println()
- fmt.Print("Continue without safety backup? (yes/no): ")
- response, err := input.ReadLineWithContext(ctx, reader)
- if err != nil {
- return err
- }
- if strings.TrimSpace(strings.ToLower(response)) != "yes" {
- return fmt.Errorf("restore aborted: safety backup failed")
- }
- } else {
- logger.Info("Safety backup location: %s", safetyBackup.BackupPath)
- logger.Info("You can restore from this backup if needed using: tar -xzf %s -C /", safetyBackup.BackupPath)
- }
- }
-
- if plan.HasCategoryID("network") {
- logger.Info("")
- logging.DebugStep(logger, "restore", "Create network-only rollback backup for transactional network apply")
- networkRollbackBackup, err = CreateNetworkRollbackBackup(logger, systemWriteCategories, destRoot)
- if err != nil {
- logger.Warning("Failed to create network rollback backup: %v", err)
- } else if networkRollbackBackup != nil && strings.TrimSpace(networkRollbackBackup.BackupPath) != "" {
- logger.Info("Network rollback backup location: %s", networkRollbackBackup.BackupPath)
- logger.Info("This backup is used for the %ds network rollback timer and only includes network paths.", int(defaultNetworkRollbackTimeout.Seconds()))
- }
- }
-
- // If we are restoring cluster database, stop PVE services and unmount /etc/pve before writing
- needsClusterRestore := plan.NeedsClusterRestore
- clusterServicesStopped := false
- pbsServicesStopped := false
- needsPBSServices := plan.NeedsPBSServices
- if needsClusterRestore {
- logger.Info("")
- logger.Info("Preparing system for cluster database restore: stopping PVE services and unmounting /etc/pve")
- if err := stopPVEClusterServices(ctx, logger); err != nil {
- return err
- }
- clusterServicesStopped = true
- defer func() {
- restartCtx, cancel := context.WithTimeout(context.Background(), 2*serviceStartTimeout+2*serviceVerifyTimeout+10*time.Second)
- defer cancel()
- if err := startPVEClusterServices(restartCtx, logger); err != nil {
- logger.Warning("Failed to restart PVE services after restore: %v", err)
- }
- }()
-
- if err := unmountEtcPVE(ctx, logger); err != nil {
- logger.Warning("Could not unmount /etc/pve: %v", err)
- }
- }
-
- // For PBS restores, stop PBS services before applying configuration/datastore changes if relevant categories are selected
- if needsPBSServices {
- logger.Info("")
- logger.Info("Preparing PBS system for restore: stopping proxmox-backup services")
- if err := stopPBSServices(ctx, logger); err != nil {
- logger.Warning("Unable to stop PBS services automatically: %v", err)
- fmt.Println()
- fmt.Println("⚠ PBS services are still running. Continuing restore may leave proxmox-backup processes active.")
- logger.Info("Continuing restore without stopping PBS services")
- } else {
- pbsServicesStopped = true
- defer func() {
- restartCtx, cancel := context.WithTimeout(context.Background(), 2*serviceStartTimeout+2*serviceVerifyTimeout+10*time.Second)
- defer cancel()
- if err := startPBSServices(restartCtx, logger); err != nil {
- logger.Warning("Failed to restart PBS services after restore: %v", err)
- }
- }()
- }
- }
-
- // Perform selective extraction for normal categories
- var detailedLogPath string
-
- // Intercept filesystem category to handle it via Smart Merge
- needsFilesystemRestore := false
- if plan.HasCategoryID("filesystem") {
- needsFilesystemRestore = true
- // Filter it out from normal categories to prevent blind overwrite
- var filtered []Category
- for _, cat := range plan.NormalCategories {
- if cat.ID != "filesystem" {
- filtered = append(filtered, cat)
- }
- }
- plan.NormalCategories = filtered
- logging.DebugStep(logger, "restore", "Filesystem category intercepted: enabling Smart Merge workflow (skipping generic extraction)")
- }
-
- if len(plan.NormalCategories) > 0 {
- logger.Info("")
- categoriesForExtraction := plan.NormalCategories
- if needsClusterRestore {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: sanitize categories to avoid /etc/pve shadow writes")
- sanitized, removed := sanitizeCategoriesForClusterRecovery(categoriesForExtraction)
- removedPaths := 0
- for _, paths := range removed {
- removedPaths += len(paths)
- }
- logging.DebugStep(
- logger,
- "restore",
- "Cluster RECOVERY shadow-guard: categories_before=%d categories_after=%d removed_categories=%d removed_paths=%d",
- len(categoriesForExtraction),
- len(sanitized),
- len(removed),
- removedPaths,
- )
- if len(removed) > 0 {
- logger.Warning("Cluster RECOVERY restore: skipping direct restore of /etc/pve paths to prevent shadowing while pmxcfs is stopped/unmounted")
- for _, cat := range categoriesForExtraction {
- if paths, ok := removed[cat.ID]; ok && len(paths) > 0 {
- logger.Warning(" - %s (%s): %s", cat.Name, cat.ID, strings.Join(paths, ", "))
- }
- }
- logger.Info("These paths are expected to be restored from config.db and become visible after /etc/pve is remounted.")
- } else {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: no /etc/pve paths detected in selected categories")
- }
- categoriesForExtraction = sanitized
- var extractionIDs []string
- for _, cat := range categoriesForExtraction {
- if id := strings.TrimSpace(cat.ID); id != "" {
- extractionIDs = append(extractionIDs, id)
- }
- }
- if len(extractionIDs) > 0 {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: extraction_categories=%s", strings.Join(extractionIDs, ","))
- } else {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: extraction_categories=")
- }
- }
-
- if len(categoriesForExtraction) == 0 {
- logging.DebugStep(logger, "restore", "Skip system-path extraction: no categories remain after shadow-guard")
- logger.Info("No system-path categories remain after cluster shadow-guard; skipping system-path extraction.")
- } else {
- detailedLogPath, err = extractSelectiveArchive(ctx, prepared.ArchivePath, destRoot, categoriesForExtraction, mode, logger)
- if err != nil {
- logger.Error("Restore failed: %v", err)
- if safetyBackup != nil {
- logger.Info("You can rollback using the safety backup at: %s", safetyBackup.BackupPath)
- }
- return err
- }
- }
- } else {
- logger.Info("")
- logger.Info("No system-path categories selected for restore (only export categories will be processed).")
- }
-
- // Handle export-only categories by extracting them to a separate directory
- exportLogPath := ""
- exportRoot := ""
- if len(plan.ExportCategories) > 0 {
- exportRoot = exportDestRoot(cfg.BaseDir)
- logger.Info("")
- logger.Info("Exporting %d export-only category(ies) to: %s", len(plan.ExportCategories), exportRoot)
- if err := restoreFS.MkdirAll(exportRoot, 0o755); err != nil {
- return fmt.Errorf("failed to create export directory %s: %w", exportRoot, err)
- }
-
- if exportLog, err := extractSelectiveArchive(ctx, prepared.ArchivePath, exportRoot, plan.ExportCategories, RestoreModeCustom, logger); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Export completed with errors: %v", err)
- } else {
- exportLogPath = exportLog
- }
- }
-
- // SAFE cluster mode: offer applying configs via pvesh without touching config.db
- if plan.ClusterSafeMode {
- if exportRoot == "" {
- logger.Warning("Cluster SAFE mode selected but export directory not available; skipping automatic pvesh apply")
- } else if err := runSafeClusterApply(ctx, reader, exportRoot, logger); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Cluster SAFE apply completed with errors: %v", err)
- }
- }
-
- // Stage sensitive categories (network, PBS datastore/jobs) to a temporary directory and apply them safely later.
- stageLogPath := ""
- stageRoot := ""
- if len(plan.StagedCategories) > 0 {
- stageRoot = stageDestRoot()
- logger.Info("")
- logger.Info("Staging %d sensitive category(ies) to: %s", len(plan.StagedCategories), stageRoot)
- if err := restoreFS.MkdirAll(stageRoot, 0o755); err != nil {
- return fmt.Errorf("failed to create staging directory %s: %w", stageRoot, err)
- }
-
- if stageLog, err := extractSelectiveArchive(ctx, prepared.ArchivePath, stageRoot, plan.StagedCategories, RestoreModeCustom, logger); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Staging completed with errors: %v", err)
- } else {
- stageLogPath = stageLog
- }
-
- logger.Info("")
- if err := maybeApplyPBSConfigsFromStage(ctx, logger, plan, stageRoot, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("PBS staged config apply: %v", err)
- }
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
}
+ done := logging.DebugStart(logger, "restore workflow (cli)", "version=%s", version)
+ defer func() { done(err) }()
- stageRootForNetworkApply := stageRoot
- if installed, err := maybeInstallNetworkConfigFromStage(ctx, logger, plan, stageRoot, prepared.ArchivePath, networkRollbackBackup, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Network staged install: %v", err)
- } else if installed {
- stageRootForNetworkApply = ""
- logging.DebugStep(logger, "restore", "Network staged install completed: configuration written to /etc (no reload); live apply will use system paths")
- }
-
- // Recreate directory structures from configuration files if relevant categories were restored
- logger.Info("")
- categoriesForDirRecreate := append([]Category{}, plan.NormalCategories...)
- categoriesForDirRecreate = append(categoriesForDirRecreate, plan.StagedCategories...)
- if shouldRecreateDirectories(systemType, categoriesForDirRecreate) {
- if err := RecreateDirectoriesFromConfig(systemType, logger); err != nil {
- logger.Warning("Failed to recreate directory structures: %v", err)
- logger.Warning("You may need to manually create storage/datastore directories")
- }
- } else {
- logger.Debug("Skipping datastore/storage directory recreation (category not selected)")
- }
-
- // Smart Filesystem Merge
- if needsFilesystemRestore {
- logger.Info("")
- // Extract fstab to a temporary location
- fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-")
- if err != nil {
- logger.Warning("Failed to create temp dir for fstab merge: %v", err)
- } else {
- defer restoreFS.RemoveAll(fsTempDir)
- // Construct a temporary category for extraction
- fsCat := GetCategoryByID("filesystem", availableCategories)
- if fsCat == nil {
- logger.Warning("Filesystem category not available in analyzed backup contents; skipping fstab merge")
- } else {
- fsCategory := []Category{*fsCat}
- if _, err := extractSelectiveArchive(ctx, prepared.ArchivePath, fsTempDir, fsCategory, RestoreModeCustom, logger); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Failed to extract filesystem config for merge: %v", err)
- } else {
- // Perform Smart Merge
- currentFstab := filepath.Join(destRoot, "etc", "fstab")
- backupFstab := filepath.Join(fsTempDir, "etc", "fstab")
- if err := SmartMergeFstab(ctx, logger, reader, currentFstab, backupFstab, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.")
- return err
- }
- restoreHadWarnings = true
- logger.Warning("Smart Fstab Merge failed: %v", err)
- }
- }
- }
- }
- }
-
- logger.Info("")
- if plan.HasCategoryID("network") {
- logger.Info("")
- if err := maybeRepairResolvConfAfterRestore(ctx, logger, prepared.ArchivePath, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- restoreHadWarnings = true
- logger.Warning("DNS resolver repair: %v", err)
- }
- }
-
- logger.Info("")
- if err := maybeApplyNetworkConfigCLI(ctx, reader, logger, plan, safetyBackup, networkRollbackBackup, stageRootForNetworkApply, prepared.ArchivePath, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- logger.Info("Restore aborted by user during network apply prompt.")
- return err
- }
- restoreHadWarnings = true
- if errors.Is(err, ErrNetworkApplyNotCommitted) {
- var notCommitted *NetworkApplyNotCommittedError
- observedIP := "unknown"
- rollbackLog := ""
- rollbackArmed := false
- if errors.As(err, ¬Committed) && notCommitted != nil {
- if strings.TrimSpace(notCommitted.RestoredIP) != "" {
- observedIP = strings.TrimSpace(notCommitted.RestoredIP)
- }
- rollbackLog = strings.TrimSpace(notCommitted.RollbackLog)
- rollbackArmed = notCommitted.RollbackArmed
- // Save abort info for footer display
- lastRestoreAbortInfo = &RestoreAbortInfo{
- NetworkRollbackArmed: rollbackArmed,
- NetworkRollbackLog: rollbackLog,
- NetworkRollbackMarker: strings.TrimSpace(notCommitted.RollbackMarker),
- OriginalIP: notCommitted.OriginalIP,
- CurrentIP: observedIP,
- RollbackDeadline: notCommitted.RollbackDeadline,
- }
- }
- if rollbackArmed {
- logger.Warning("Network apply not committed; rollback is ARMED and will run automatically. Current IP: %s", observedIP)
- } else {
- logger.Warning("Network apply not committed; rollback has executed (or marker cleared). Current IP: %s", observedIP)
- }
- if rollbackLog != "" {
- logger.Info("Rollback log: %s", rollbackLog)
- }
- } else {
- logger.Warning("Network apply step skipped or failed: %v", err)
- }
- }
-
- logger.Info("")
- if restoreHadWarnings {
- logger.Warning("Restore completed with warnings.")
- } else {
- logger.Info("Restore completed successfully.")
- }
- logger.Info("Temporary decrypted bundle removed.")
-
- if detailedLogPath != "" {
- logger.Info("Detailed restore log: %s", detailedLogPath)
- }
- if exportRoot != "" {
- logger.Info("Export directory: %s", exportRoot)
- }
- if exportLogPath != "" {
- logger.Info("Export detailed log: %s", exportLogPath)
- }
- if stageRoot != "" {
- logger.Info("Staging directory: %s", stageRoot)
- }
- if stageLogPath != "" {
- logger.Info("Staging detailed log: %s", stageLogPath)
- }
-
- if safetyBackup != nil {
- logger.Info("Safety backup preserved at: %s", safetyBackup.BackupPath)
- logger.Info("Remove it manually if restore was successful: rm %s", safetyBackup.BackupPath)
- }
-
- logger.Info("")
- logger.Info("IMPORTANT: You may need to restart services for changes to take effect.")
- if systemType == SystemTypePVE {
- if needsClusterRestore && clusterServicesStopped {
- logger.Info(" PVE services were stopped/restarted during restore; verify status with: pvecm status")
- } else {
- logger.Info(" PVE services: systemctl restart pve-cluster pvedaemon pveproxy")
- }
- } else if systemType == SystemTypePBS {
- if pbsServicesStopped {
- logger.Info(" PBS services were stopped/restarted during restore; verify status with: systemctl status proxmox-backup proxmox-backup-proxy")
- } else {
- logger.Info(" PBS services: systemctl restart proxmox-backup-proxy proxmox-backup")
- }
-
- // Check ZFS pool status for PBS systems only when ZFS category was restored
- if hasCategoryID(plan.NormalCategories, "zfs") {
- logger.Info("")
- if err := checkZFSPoolsAfterRestore(logger); err != nil {
- logger.Warning("ZFS pool check: %v", err)
- }
- } else {
- logger.Debug("Skipping ZFS pool verification (ZFS category not selected)")
- }
- }
-
- logger.Info("")
- logger.Warning("⚠ SYSTEM REBOOT RECOMMENDED")
- logger.Info("Reboot the node (or at least restart networking and system services) to ensure all restored configurations take effect cleanly.")
-
- return nil
+ ui := newCLIWorkflowUI(bufio.NewReader(os.Stdin), logger)
+ return runRestoreWorkflowWithUI(ctx, cfg, logger, version, ui)
}
// checkZFSPoolsAfterRestore checks if ZFS pools need to be imported after restore
@@ -1173,6 +643,13 @@ func shouldStopPBSServices(categories []Category) bool {
if cat.Type == CategoryTypePBS {
return true
}
+ // Some common categories (e.g. SSL) include PBS paths that require restarting PBS services.
+ for _, p := range cat.Paths {
+ p = strings.TrimSpace(p)
+ if strings.HasPrefix(p, "./etc/proxmox-backup/") || strings.HasPrefix(p, "./var/lib/proxmox-backup/") {
+ return true
+ }
+ }
}
return false
}
@@ -1250,6 +727,21 @@ func runFullRestore(ctx context.Context, reader *bufio.Reader, candidate *decryp
if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil {
logger.Warning("Failed to extract filesystem config for merge: %v", err)
} else {
+ // Best-effort: extract ProxSave inventory files used for stable fstab device remapping.
+ invCategory := []Category{{
+ ID: "fstab_inventory",
+ Name: "Fstab inventory (device mapping)",
+ Paths: []string{
+ "./var/lib/proxsave-info/commands/system/blkid.txt",
+ "./var/lib/proxsave-info/commands/system/lsblk_json.json",
+ "./var/lib/proxsave-info/commands/system/lsblk.txt",
+ "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json",
+ },
+ }}
+ if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, invCategory, RestoreModeCustom, nil, "", nil); err != nil {
+ logger.Debug("Failed to extract fstab inventory data (continuing): %v", err)
+ }
+
currentFstab := filepath.Join(destRoot, "etc", "fstab")
backupFstab := filepath.Join(fsTempDir, "etc", "fstab")
if err := SmartMergeFstab(ctx, logger, reader, currentFstab, backupFstab, dryRun); err != nil {
@@ -1315,176 +807,11 @@ func extractPlainArchive(ctx context.Context, archivePath, destRoot string, logg
// runSafeClusterApply applies selected cluster configs via pvesh without touching config.db.
// It operates on files extracted to exportRoot (e.g. exportDestRoot).
func runSafeClusterApply(ctx context.Context, reader *bufio.Reader, exportRoot string, logger *logging.Logger) (err error) {
- done := logging.DebugStart(logger, "safe cluster apply", "export_root=%s", exportRoot)
- defer func() { done(err) }()
-
- if err := ctx.Err(); err != nil {
- return err
- }
-
- pveshPath, lookErr := exec.LookPath("pvesh")
- if lookErr != nil {
- logger.Warning("pvesh not found in PATH; skipping SAFE cluster apply")
- return nil
- }
- logging.DebugStep(logger, "safe cluster apply", "pvesh=%s", pveshPath)
-
- currentNode, _ := os.Hostname()
- currentNode = shortHost(currentNode)
- if strings.TrimSpace(currentNode) == "" {
- currentNode = "localhost"
- }
- logging.DebugStep(logger, "safe cluster apply", "current_node=%s", currentNode)
-
- logger.Info("")
- logger.Info("SAFE cluster restore: applying configs via pvesh (node=%s)", currentNode)
-
- sourceNode := currentNode
- logging.DebugStep(logger, "safe cluster apply", "List exported node directories under %s", filepath.Join(exportRoot, "etc/pve/nodes"))
- exportNodes, nodesErr := listExportNodeDirs(exportRoot)
- if nodesErr != nil {
- logger.Warning("Failed to inspect exported node directories: %v", nodesErr)
- } else if len(exportNodes) > 0 {
- logging.DebugStep(logger, "safe cluster apply", "export_nodes=%s", strings.Join(exportNodes, ","))
- } else {
- logging.DebugStep(logger, "safe cluster apply", "No exported node directories found")
- }
-
- if len(exportNodes) > 0 && !stringSliceContains(exportNodes, sourceNode) {
- logging.DebugStep(logger, "safe cluster apply", "Node mismatch: current_node=%s export_nodes=%s", currentNode, strings.Join(exportNodes, ","))
- logger.Warning("SAFE cluster restore: VM/CT configs not found for current node %s in export; available nodes: %s", currentNode, strings.Join(exportNodes, ", "))
- if len(exportNodes) == 1 {
- sourceNode = exportNodes[0]
- logging.DebugStep(logger, "safe cluster apply", "Auto-select source node: %s", sourceNode)
- logger.Info("SAFE cluster restore: using exported node %s as VM/CT source, applying to current node %s", sourceNode, currentNode)
- } else {
- for _, node := range exportNodes {
- qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node)
- logging.DebugStep(logger, "safe cluster apply", "Export node candidate: %s (qemu=%d, lxc=%d)", node, qemuCount, lxcCount)
- }
- selected, selErr := promptExportNodeSelection(ctx, reader, exportRoot, currentNode, exportNodes)
- if selErr != nil {
- return selErr
- }
- if strings.TrimSpace(selected) == "" {
- logging.DebugStep(logger, "safe cluster apply", "User selected: skip VM/CT apply (no source node)")
- logger.Info("Skipping VM/CT apply (no source node selected)")
- sourceNode = ""
- } else {
- sourceNode = selected
- logging.DebugStep(logger, "safe cluster apply", "User selected source node: %s", sourceNode)
- logger.Info("SAFE cluster restore: selected exported node %s as VM/CT source, applying to current node %s", sourceNode, currentNode)
- }
- }
- }
- logging.DebugStep(logger, "safe cluster apply", "Selected VM/CT source node: %q (current_node=%q)", sourceNode, currentNode)
-
- var vmEntries []vmEntry
- if strings.TrimSpace(sourceNode) != "" {
- logging.DebugStep(logger, "safe cluster apply", "Scan VM/CT configs in export (source_node=%s)", sourceNode)
- var vmErr error
- vmEntries, vmErr = scanVMConfigs(exportRoot, sourceNode)
- if vmErr != nil {
- logger.Warning("Failed to scan VM configs: %v", vmErr)
- } else {
- logging.DebugStep(logger, "safe cluster apply", "VM/CT configs found=%d (source_node=%s)", len(vmEntries), sourceNode)
- qemuCount := 0
- lxcCount := 0
- for _, entry := range vmEntries {
- switch entry.Kind {
- case "qemu":
- qemuCount++
- case "lxc":
- lxcCount++
- }
- }
- logging.DebugStep(logger, "safe cluster apply", "VM/CT breakdown: qemu=%d lxc=%d", qemuCount, lxcCount)
- }
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
}
- if len(vmEntries) > 0 {
- fmt.Println()
- if sourceNode == currentNode {
- fmt.Printf("Found %d VM/CT configs for node %s\n", len(vmEntries), currentNode)
- } else {
- fmt.Printf("Found %d VM/CT configs for exported node %s (will apply to current node %s)\n", len(vmEntries), sourceNode, currentNode)
- }
- applyVMs, promptErr := promptYesNo(ctx, reader, "Apply all VM/CT configs via pvesh? ")
- if promptErr != nil {
- return promptErr
- }
- logging.DebugStep(logger, "safe cluster apply", "User choice: apply_vms=%v (entries=%d)", applyVMs, len(vmEntries))
- if applyVMs {
- applied, failed := applyVMConfigs(ctx, vmEntries, logger)
- logger.Info("VM/CT apply completed: ok=%d failed=%d", applied, failed)
- } else {
- logger.Info("Skipping VM/CT apply")
- }
- } else {
- if strings.TrimSpace(sourceNode) == "" {
- logger.Info("No VM/CT configs applied (no source node selected)")
- } else {
- logger.Info("No VM/CT configs found for node %s in export", sourceNode)
- }
- }
-
- // Storage configuration
- storageCfg := filepath.Join(exportRoot, "etc/pve/storage.cfg")
- logging.DebugStep(logger, "safe cluster apply", "Check export: storage.cfg (%s)", storageCfg)
- storageInfo, storageErr := restoreFS.Stat(storageCfg)
- if storageErr == nil && !storageInfo.IsDir() {
- logging.DebugStep(logger, "safe cluster apply", "storage.cfg found (size=%d)", storageInfo.Size())
- fmt.Println()
- fmt.Printf("Storage configuration found: %s\n", storageCfg)
- applyStorage, err := promptYesNo(ctx, reader, "Apply storage.cfg via pvesh?")
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "safe cluster apply", "User choice: apply_storage=%v", applyStorage)
- if applyStorage {
- logging.DebugStep(logger, "safe cluster apply", "Apply storage.cfg via pvesh")
- applied, failed, err := applyStorageCfg(ctx, storageCfg, logger)
- logging.DebugStep(logger, "safe cluster apply", "Storage apply result: ok=%d failed=%d err=%v", applied, failed, err)
- if err != nil {
- logger.Warning("Storage apply encountered errors: %v", err)
- }
- logger.Info("Storage apply completed: ok=%d failed=%d", applied, failed)
- } else {
- logger.Info("Skipping storage.cfg apply")
- }
- } else {
- logging.DebugStep(logger, "safe cluster apply", "storage.cfg not found (err=%v)", storageErr)
- logger.Info("No storage.cfg found in export")
- }
-
- // Datacenter configuration
- dcCfg := filepath.Join(exportRoot, "etc/pve/datacenter.cfg")
- logging.DebugStep(logger, "safe cluster apply", "Check export: datacenter.cfg (%s)", dcCfg)
- dcInfo, dcErr := restoreFS.Stat(dcCfg)
- if dcErr == nil && !dcInfo.IsDir() {
- logging.DebugStep(logger, "safe cluster apply", "datacenter.cfg found (size=%d)", dcInfo.Size())
- fmt.Println()
- fmt.Printf("Datacenter configuration found: %s\n", dcCfg)
- applyDC, err := promptYesNo(ctx, reader, "Apply datacenter.cfg via pvesh?")
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "safe cluster apply", "User choice: apply_datacenter=%v", applyDC)
- if applyDC {
- logging.DebugStep(logger, "safe cluster apply", "Apply datacenter.cfg via pvesh")
- if err := runPvesh(ctx, logger, []string{"set", "/cluster/config", "-conf", dcCfg}); err != nil {
- logger.Warning("Failed to apply datacenter.cfg: %v", err)
- } else {
- logger.Info("datacenter.cfg applied successfully")
- }
- } else {
- logger.Info("Skipping datacenter.cfg apply")
- }
- } else {
- logging.DebugStep(logger, "safe cluster apply", "datacenter.cfg not found (err=%v)", dcErr)
- logger.Info("No datacenter.cfg found in export")
- }
-
- return nil
+ ui := newCLIWorkflowUI(reader, logger)
+ return runSafeClusterApplyWithUI(ctx, ui, exportRoot, logger, nil)
}
type vmEntry struct {
@@ -1748,10 +1075,13 @@ func parseStorageBlocks(cfgPath string) ([]storageBlock, error) {
flush()
continue
}
- if strings.HasPrefix(trimmed, "storage:") {
+
+ // storage.cfg blocks use `: ` (e.g. `dir: local`, `nfs: backup`).
+ // Older exports may still use `storage: ` blocks.
+ _, name, ok := parseProxmoxNotificationHeader(trimmed)
+ if ok {
flush()
- id := strings.TrimSpace(strings.TrimPrefix(trimmed, "storage:"))
- current = &storageBlock{ID: id, data: []string{line}}
+ current = &storageBlock{ID: name, data: []string{line}}
continue
}
if current != nil {
@@ -2186,17 +1516,10 @@ func shouldSkipProxmoxSystemRestore(relTarget string) (bool, string) {
return true, "PBS users must be recreated (user.cfg should not be restored raw)"
case "etc/proxmox-backup/acl.cfg":
return true, "PBS permissions must be recreated (acl.cfg should not be restored raw)"
- case "etc/proxmox-backup/proxy.cfg":
- return true, "PBS proxy configuration should be recreated (proxy.cfg should not be restored raw)"
- case "etc/proxmox-backup/proxy.pem":
- return true, "PBS certificates should be regenerated (proxy.pem should not be restored raw)"
case "var/lib/proxmox-backup/.clusterlock":
return true, "PBS runtime lock files must not be restored"
}
- if strings.HasPrefix(rel, "etc/proxmox-backup/ssl/") {
- return true, "PBS certificates should be regenerated (ssl/* should not be restored raw)"
- }
if strings.HasPrefix(rel, "var/lib/proxmox-backup/lock/") {
return true, "PBS runtime lock files must not be restored"
}
diff --git a/internal/orchestrator/restore_access_control.go b/internal/orchestrator/restore_access_control.go
new file mode 100644
index 0000000..5556e21
--- /dev/null
+++ b/internal/orchestrator/restore_access_control.go
@@ -0,0 +1,1225 @@
+package orchestrator
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+const (
+ pveUserCfgPath = "/etc/pve/user.cfg"
+ pveDomainsCfgPath = "/etc/pve/domains.cfg"
+ pveShadowCfgPath = "/etc/pve/priv/shadow.cfg"
+ pveTokenCfgPath = "/etc/pve/priv/token.cfg"
+ pveTFACfgPath = "/etc/pve/priv/tfa.cfg"
+
+ pbsUserCfgPath = "/etc/proxmox-backup/user.cfg"
+ pbsDomainsCfgPath = "/etc/proxmox-backup/domains.cfg"
+ pbsACLCfgPath = "/etc/proxmox-backup/acl.cfg"
+ pbsTokenCfgPath = "/etc/proxmox-backup/token.cfg"
+ pbsShadowJSONPath = "/etc/proxmox-backup/shadow.json"
+ pbsTokenShadowPath = "/etc/proxmox-backup/token.shadow"
+ pbsTFAJSONPath = "/etc/proxmox-backup/tfa.json"
+)
+
+func maybeApplyAccessControlFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot string, dryRun bool) (err error) {
+ if plan == nil {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "access control staged apply", "Skipped: staging directory not available")
+ return nil
+ }
+ if !plan.HasCategoryID("pve_access_control") && !plan.HasCategoryID("pbs_access_control") {
+ return nil
+ }
+
+ // Cluster backups: avoid applying PVE access control in SAFE mode.
+ // Full-fidelity access control + secrets restore requires cluster RECOVERY (config.db) on an isolated/offline cluster.
+ if plan.SystemType == SystemTypePVE &&
+ plan.HasCategoryID("pve_access_control") &&
+ plan.ClusterBackup &&
+ !plan.NeedsClusterRestore {
+ logger.Warning("PVE access control: cluster backup detected; skipping 1:1 access control apply in SAFE mode (use cluster RECOVERY for full fidelity)")
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "access control staged apply", "dryRun=%v stage=%s", dryRun, stageRoot)
+ defer func() { done(err) }()
+
+ if dryRun {
+ logger.Info("Dry run enabled: skipping staged access control apply")
+ return nil
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping staged access control apply: non-system filesystem in use")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping staged access control apply: requires root privileges")
+ return nil
+ }
+
+ switch plan.SystemType {
+ case SystemTypePBS:
+ if !plan.HasCategoryID("pbs_access_control") {
+ return nil
+ }
+ return applyPBSAccessControlFromStage(ctx, logger, stageRoot)
+ case SystemTypePVE:
+ if !plan.HasCategoryID("pve_access_control") {
+ return nil
+ }
+
+ // In cluster RECOVERY mode, config.db restoration owns access control state (including secrets).
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "access control staged apply", "Skip PVE access control apply: cluster RECOVERY restores config.db")
+ return nil
+ }
+
+ return applyPVEAccessControlFromStage(ctx, logger, stageRoot)
+ default:
+ return nil
+ }
+}
+
+func applyPBSAccessControlFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) (err error) {
+ done := logging.DebugStart(logger, "pbs access control apply", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ stagedUserRaw, userPresent, err := readStageFileOptional(stageRoot, "etc/proxmox-backup/user.cfg")
+ if err != nil {
+ return err
+ }
+ stagedDomainsRaw, domainsPresent, err := readStageFileOptional(stageRoot, "etc/proxmox-backup/domains.cfg")
+ if err != nil {
+ return err
+ }
+ stagedACLRaw, aclPresent, err := readStageFileOptional(stageRoot, "etc/proxmox-backup/acl.cfg")
+ if err != nil {
+ return err
+ }
+ stagedTokenCfgRaw, tokenCfgPresent, err := readStageFileOptional(stageRoot, "etc/proxmox-backup/token.cfg")
+ if err != nil {
+ return err
+ }
+
+ if userPresent || domainsPresent || aclPresent || tokenCfgPresent {
+ currentUserSections, _ := readProxmoxConfigSectionsOptional(pbsUserCfgPath)
+ currentDomainSections, _ := readProxmoxConfigSectionsOptional(pbsDomainsCfgPath)
+ currentTokenCfgSections, _ := readProxmoxConfigSectionsOptional(pbsTokenCfgPath)
+
+ backupUserSections, err := parseProxmoxNotificationSections(stagedUserRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged user.cfg: %w", err)
+ }
+ backupDomainSections, err := parseProxmoxNotificationSections(stagedDomainsRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged domains.cfg: %w", err)
+ }
+ backupTokenCfgSections, err := parseProxmoxNotificationSections(stagedTokenCfgRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged token.cfg: %w", err)
+ }
+
+ rootUser := findPBSRootUserSection(currentUserSections)
+ if rootUser == nil {
+ rootUser = &proxmoxNotificationSection{
+ Type: "user",
+ Name: "root@pam",
+ Entries: []proxmoxNotificationEntry{
+ {Key: "enable", Value: "1"},
+ },
+ }
+ }
+ rootTokens := findPBSRootTokenSections(currentUserSections)
+
+ // Merge user.cfg: restore 1:1 except root users/tokens (preserve from fresh install).
+ mergedUser := make([]proxmoxNotificationSection, 0, len(backupUserSections)+1+len(rootTokens))
+ for _, s := range backupUserSections {
+ if strings.EqualFold(strings.TrimSpace(s.Type), "user") && isRootPBSUserID(strings.TrimSpace(s.Name)) {
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(s.Type), "token") && isRootPBSAuthID(strings.TrimSpace(s.Name)) {
+ continue
+ }
+ mergedUser = append(mergedUser, s)
+ }
+ mergedUser = append(mergedUser, *rootUser)
+ mergedUser = append(mergedUser, rootTokens...)
+
+ needsPBSRealm := anyUserInRealm(mergedUser, "pbs")
+ requiredRealms := []string{"pam"}
+ if needsPBSRealm {
+ requiredRealms = append(requiredRealms, "pbs")
+ }
+ mergedDomains := mergeRequiredRealms(backupDomainSections, currentDomainSections, requiredRealms)
+
+ // Merge token.cfg (if present): restore 1:1 except root-bound entries (preserve from fresh install).
+ mergedTokenCfg := mergeUserBoundSectionsExcludeRoot(backupTokenCfgSections, currentTokenCfgSections, tokenSectionUserID, "root@pam")
+
+ if domainsPresent {
+ if err := writeFileAtomic(pbsDomainsCfgPath, []byte(renderProxmoxConfig(mergedDomains)), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pbsDomainsCfgPath, err)
+ }
+ }
+ if userPresent {
+ if err := writeFileAtomic(pbsUserCfgPath, []byte(renderProxmoxConfig(mergedUser)), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pbsUserCfgPath, err)
+ }
+ }
+ if tokenCfgPresent {
+ if err := writeFileAtomic(pbsTokenCfgPath, []byte(renderProxmoxConfig(mergedTokenCfg)), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pbsTokenCfgPath, err)
+ }
+ }
+
+ if aclPresent {
+ if err := applyPBSACLFromStage(logger, stagedACLRaw); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Restore secrets 1:1 except root@pam (preserve from fresh install).
+ if err := applyPBSShadowJSONFromStage(logger, stageRoot); err != nil {
+ return err
+ }
+ if err := applyPBSTokenShadowFromStage(logger, stageRoot); err != nil {
+ return err
+ }
+ if err := applyPBSTFAJSONFromStage(logger, stageRoot); err != nil {
+ return err
+ }
+
+ logger.Warning("PBS access control: restored 1:1 from backup; root@pam preserved from fresh install and kept Admin on /")
+ logger.Warning("PBS access control: TFA was restored 1:1; users with WebAuthn may require re-enrollment if origin/hostname changed (for best compatibility, keep the same FQDN/origin and restore network+ssl; default behavior is warn, not disable)")
+
+ return nil
+}
+
+func applySensitiveFileFromStage(logger *logging.Logger, stageRoot, relPath, destPath string, perm os.FileMode) error {
+ stagePath := filepath.Join(stageRoot, relPath)
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "access control staged apply file", "Skip %s: not present in staging directory", relPath)
+ return nil
+ }
+ return fmt.Errorf("read staged %s: %w", relPath, err)
+ }
+
+ trimmed := strings.TrimSpace(string(data))
+ if trimmed == "" {
+ logger.Warning("Access control staged apply: %s is empty; removing %s", relPath, destPath)
+ return removeIfExists(destPath)
+ }
+
+ return writeFileAtomic(destPath, []byte(trimmed+"\n"), perm)
+}
+
+func isRootPBSUserID(userID string) bool {
+ trimmed := strings.TrimSpace(userID)
+ if trimmed == "" {
+ return false
+ }
+ if idx := strings.Index(trimmed, "@"); idx >= 0 {
+ trimmed = trimmed[:idx]
+ }
+ return strings.TrimSpace(trimmed) == "root"
+}
+
+func isRootPBSAuthID(authID string) bool {
+ trimmed := strings.TrimSpace(authID)
+ if trimmed == "" {
+ return false
+ }
+ if idx := strings.Index(trimmed, "!"); idx >= 0 {
+ trimmed = trimmed[:idx]
+ }
+ return isRootPBSUserID(trimmed)
+}
+
+func findPBSRootUserSection(sections []proxmoxNotificationSection) *proxmoxNotificationSection {
+ for i := range sections {
+ if !strings.EqualFold(strings.TrimSpace(sections[i].Type), "user") {
+ continue
+ }
+ if isRootPBSUserID(strings.TrimSpace(sections[i].Name)) {
+ return §ions[i]
+ }
+ }
+ return nil
+}
+
+func findPBSRootTokenSections(sections []proxmoxNotificationSection) []proxmoxNotificationSection {
+ var out []proxmoxNotificationSection
+ for _, s := range sections {
+ if !strings.EqualFold(strings.TrimSpace(s.Type), "token") {
+ continue
+ }
+ if isRootPBSAuthID(strings.TrimSpace(s.Name)) {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func applyPBSACLFromStage(logger *logging.Logger, stagedACL string) error {
+ raw := strings.TrimSpace(stagedACL)
+ if raw == "" {
+ logger.Warning("PBS access control: staged acl.cfg is empty; removing %s", pbsACLCfgPath)
+ return removeIfExists(pbsACLCfgPath)
+ }
+
+ // PBS supports two ACL formats across versions:
+ // - header-style (section + indented keys)
+ // - colon-delimited line format (acl::::)
+ if pbsConfigHasHeader(raw) {
+ return applyPBSACLSectionFormat(logger, raw)
+ }
+ if isPBSACLLineFormat(raw) {
+ return applyPBSACLLineFormat(logger, raw)
+ }
+
+ logger.Warning("PBS access control: staged acl.cfg has unknown format; skipping apply")
+ return nil
+}
+
+func applyPBSACLSectionFormat(logger *logging.Logger, raw string) error {
+ backupSections, err := parseProxmoxNotificationSections(raw)
+ if err != nil {
+ return fmt.Errorf("parse staged acl.cfg: %w", err)
+ }
+
+ var merged []proxmoxNotificationSection
+ for _, s := range backupSections {
+ if !strings.EqualFold(strings.TrimSpace(s.Type), "acl") {
+ merged = append(merged, s)
+ continue
+ }
+ users := findSectionEntryValue(s.Entries, "users")
+ filtered, ok := filterPBSACLUsers(users)
+ if !ok {
+ continue
+ }
+ s.Entries = setSectionEntryValue(s.Entries, "users", filtered)
+ merged = append(merged, s)
+ }
+
+ if !hasPBSRootAdminOnRootSectionFormat(merged) {
+ merged = append(merged, proxsavePBSRootAdminACLSection())
+ }
+
+ if err := writeFileAtomic(pbsACLCfgPath, []byte(renderProxmoxConfig(merged)), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pbsACLCfgPath, err)
+ }
+ return nil
+}
+
+type pbsACLLine struct {
+ Propagate string
+ Path string
+ UserList string
+ Roles string
+ Raw string
+}
+
+func isPBSACLLineFormat(content string) bool {
+ for _, line := range strings.Split(content, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ if !strings.HasPrefix(trimmed, "acl:") {
+ return false
+ }
+ parts := strings.SplitN(trimmed, ":", 5)
+ return len(parts) == 5
+ }
+ return false
+}
+
+func parsePBSACLLine(line string) (pbsACLLine, bool) {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return pbsACLLine{}, false
+ }
+ if !strings.HasPrefix(trimmed, "acl:") {
+ return pbsACLLine{Raw: trimmed}, true
+ }
+
+ parts := strings.SplitN(trimmed, ":", 5)
+ if len(parts) != 5 || strings.TrimSpace(parts[0]) != "acl" {
+ return pbsACLLine{Raw: trimmed}, true
+ }
+ return pbsACLLine{
+ Propagate: strings.TrimSpace(parts[1]),
+ Path: strings.TrimSpace(parts[2]),
+ UserList: strings.TrimSpace(parts[3]),
+ Roles: strings.TrimSpace(parts[4]),
+ Raw: trimmed,
+ }, true
+}
+
+func applyPBSACLLineFormat(logger *logging.Logger, raw string) error {
+ var outLines []string
+ var hasRootAdmin bool
+
+ for _, line := range strings.Split(raw, "\n") {
+ entry, ok := parsePBSACLLine(line)
+ if !ok {
+ continue
+ }
+ if strings.TrimSpace(entry.Raw) != "" && !strings.HasPrefix(entry.Raw, "acl:") {
+ // Preserve unknown non-empty lines verbatim.
+ outLines = append(outLines, entry.Raw)
+ continue
+ }
+
+ filteredUsers, ok := filterPBSACLUsers(entry.UserList)
+ if !ok {
+ continue
+ }
+ entry.UserList = filteredUsers
+
+ if entry.Path == "/" && entry.Propagate == "1" && listContains(entry.UserList, "root@pam") && listContains(entry.Roles, "Admin") {
+ hasRootAdmin = true
+ }
+
+ outLines = append(outLines, fmt.Sprintf("acl:%s:%s:%s:%s", entry.Propagate, entry.Path, entry.UserList, entry.Roles))
+ }
+
+ if !hasRootAdmin {
+ outLines = append(outLines, "acl:1:/:root@pam:Admin")
+ }
+
+ content := strings.TrimSpace(strings.Join(outLines, "\n"))
+ if content != "" && !strings.HasSuffix(content, "\n") {
+ content += "\n"
+ }
+ if err := writeFileAtomic(pbsACLCfgPath, []byte(content), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pbsACLCfgPath, err)
+ }
+ return nil
+}
+
+func filterPBSACLUsers(raw string) (string, bool) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return "", false
+ }
+
+ trimmed = strings.ReplaceAll(trimmed, ",", " ")
+ var kept []string
+ for _, field := range strings.Fields(trimmed) {
+ field = strings.TrimSpace(field)
+ if field == "" {
+ continue
+ }
+ if isRootPBSAuthID(field) {
+ continue
+ }
+ kept = append(kept, field)
+ }
+ if len(kept) == 0 {
+ return "", false
+ }
+ return strings.Join(kept, ","), true
+}
+
+func setSectionEntryValue(entries []proxmoxNotificationEntry, key, value string) []proxmoxNotificationEntry {
+ match := strings.TrimSpace(key)
+ if match == "" {
+ return entries
+ }
+ out := make([]proxmoxNotificationEntry, 0, len(entries)+1)
+ found := false
+ for _, e := range entries {
+ if strings.TrimSpace(e.Key) == match {
+ if !found {
+ out = append(out, proxmoxNotificationEntry{Key: match, Value: strings.TrimSpace(value)})
+ found = true
+ }
+ continue
+ }
+ out = append(out, e)
+ }
+ if !found {
+ out = append(out, proxmoxNotificationEntry{Key: match, Value: strings.TrimSpace(value)})
+ }
+ return out
+}
+
+func hasPBSRootAdminOnRootSectionFormat(sections []proxmoxNotificationSection) bool {
+ for _, s := range sections {
+ if strings.TrimSpace(s.Type) != "acl" {
+ continue
+ }
+ path := strings.TrimSpace(findSectionEntryValue(s.Entries, "path"))
+ if path == "" {
+ path = aclPathFromSectionName(s.Name)
+ }
+ if path != "/" {
+ continue
+ }
+
+ users := strings.TrimSpace(findSectionEntryValue(s.Entries, "users"))
+ roles := strings.TrimSpace(findSectionEntryValue(s.Entries, "roles"))
+ if listContains(users, "root@pam") && listContains(roles, "Admin") {
+ return true
+ }
+ }
+ return false
+}
+
+func proxsavePBSRootAdminACLSection() proxmoxNotificationSection {
+ return proxmoxNotificationSection{
+ Type: "acl",
+ Name: "proxsave-root-admin",
+ Entries: []proxmoxNotificationEntry{
+ {Key: "path", Value: "/"},
+ {Key: "users", Value: "root@pam"},
+ {Key: "roles", Value: "Admin"},
+ {Key: "propagate", Value: "1"},
+ },
+ }
+}
+
+func applyPBSShadowJSONFromStage(logger *logging.Logger, stageRoot string) error {
+ stagePath := filepath.Join(stageRoot, "etc/proxmox-backup/shadow.json")
+ backupBytes, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read staged shadow.json: %w", err)
+ }
+ trimmed := strings.TrimSpace(string(backupBytes))
+ if trimmed == "" {
+ logger.Warning("PBS access control: staged shadow.json is empty; removing %s", pbsShadowJSONPath)
+ return removeIfExists(pbsShadowJSONPath)
+ }
+
+ var backup map[string]string
+ if err := json.Unmarshal([]byte(trimmed), &backup); err != nil {
+ return fmt.Errorf("parse staged shadow.json: %w", err)
+ }
+
+ currentBytes, err := restoreFS.ReadFile(pbsShadowJSONPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("read current shadow.json: %w", err)
+ }
+ var current map[string]string
+ if len(currentBytes) > 0 {
+ _ = json.Unmarshal(currentBytes, ¤t)
+ }
+
+ for userID := range backup {
+ if isRootPBSUserID(userID) {
+ delete(backup, userID)
+ }
+ }
+ for userID, hash := range current {
+ if isRootPBSUserID(userID) {
+ backup[userID] = hash
+ }
+ }
+
+ out, err := json.Marshal(backup)
+ if err != nil {
+ return fmt.Errorf("marshal shadow.json: %w", err)
+ }
+ return writeFileAtomic(pbsShadowJSONPath, append(out, '\n'), 0o600)
+}
+
+func applyPBSTokenShadowFromStage(logger *logging.Logger, stageRoot string) error {
+ stagePath := filepath.Join(stageRoot, "etc/proxmox-backup/token.shadow")
+ backupBytes, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read staged token.shadow: %w", err)
+ }
+ trimmed := strings.TrimSpace(string(backupBytes))
+ if trimmed == "" {
+ logger.Warning("PBS access control: staged token.shadow is empty; removing %s", pbsTokenShadowPath)
+ return removeIfExists(pbsTokenShadowPath)
+ }
+
+ var backup map[string]string
+ if err := json.Unmarshal([]byte(trimmed), &backup); err != nil {
+ return fmt.Errorf("parse staged token.shadow: %w", err)
+ }
+
+ currentBytes, err := restoreFS.ReadFile(pbsTokenShadowPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("read current token.shadow: %w", err)
+ }
+ var current map[string]string
+ if len(currentBytes) > 0 {
+ _ = json.Unmarshal(currentBytes, ¤t)
+ }
+
+ for tokenID := range backup {
+ if isRootPBSAuthID(tokenID) {
+ delete(backup, tokenID)
+ }
+ }
+ for tokenID, secret := range current {
+ if isRootPBSAuthID(tokenID) {
+ backup[tokenID] = secret
+ }
+ }
+
+ out, err := json.Marshal(backup)
+ if err != nil {
+ return fmt.Errorf("marshal token.shadow: %w", err)
+ }
+ return writeFileAtomic(pbsTokenShadowPath, append(out, '\n'), 0o600)
+}
+
+func applyPBSTFAJSONFromStage(logger *logging.Logger, stageRoot string) error {
+ stagePath := filepath.Join(stageRoot, "etc/proxmox-backup/tfa.json")
+ backupBytes, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read staged tfa.json: %w", err)
+ }
+ trimmed := strings.TrimSpace(string(backupBytes))
+ if trimmed == "" {
+ logger.Warning("PBS access control: staged tfa.json is empty; removing %s", pbsTFAJSONPath)
+ return removeIfExists(pbsTFAJSONPath)
+ }
+
+ var backup map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(trimmed), &backup); err != nil {
+ return fmt.Errorf("parse staged tfa.json: %w", err)
+ }
+
+ currentBytes, err := restoreFS.ReadFile(pbsTFAJSONPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("read current tfa.json: %w", err)
+ }
+ var current map[string]json.RawMessage
+ if len(currentBytes) > 0 {
+ _ = json.Unmarshal(currentBytes, ¤t)
+ }
+
+ backupUsers := parseTFAUsersMap(backup)
+ currentUsers := parseTFAUsersMap(current)
+
+ for userID := range backupUsers {
+ if isRootPBSUserID(userID) {
+ delete(backupUsers, userID)
+ }
+ }
+ for userID, payload := range currentUsers {
+ if isRootPBSUserID(userID) {
+ backupUsers[userID] = payload
+ }
+ }
+
+ webauthnUsers := extractWebAuthnUsersFromPBSTFAUsers(backupUsers)
+ if len(webauthnUsers) > 0 {
+ logger.Warning("PBS TFA/WebAuthn: detected %d enrolled user(s): %s", len(webauthnUsers), summarizeUserIDs(webauthnUsers, 8))
+ }
+ backup["users"] = mustMarshalRaw(backupUsers)
+
+ out, err := json.Marshal(backup)
+ if err != nil {
+ return fmt.Errorf("marshal tfa.json: %w", err)
+ }
+ return writeFileAtomic(pbsTFAJSONPath, append(out, '\n'), 0o600)
+}
+
+func parseTFAUsersMap(obj map[string]json.RawMessage) map[string]json.RawMessage {
+ if obj == nil {
+ return map[string]json.RawMessage{}
+ }
+ raw, ok := obj["users"]
+ if !ok || len(raw) == 0 {
+ return map[string]json.RawMessage{}
+ }
+ var users map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &users); err != nil || users == nil {
+ return map[string]json.RawMessage{}
+ }
+ return users
+}
+
+func mustMarshalRaw(v any) json.RawMessage {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return json.RawMessage([]byte("{}"))
+ }
+ return json.RawMessage(b)
+}
+
+func applyPVEAccessControlFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) (err error) {
+ done := logging.DebugStart(logger, "pve access control apply", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ // Safety: only apply to the real pmxcfs mount. If /etc/pve is not mounted, writing here would
+ // shadow files on the root filesystem and can break a fresh install.
+ if isRealRestoreFS(restoreFS) {
+ mounted, mountErr := isMounted("/etc/pve")
+ if mountErr != nil {
+ logger.Warning("PVE access control: unable to check pmxcfs mount status for /etc/pve: %v", mountErr)
+ } else if !mounted {
+ return fmt.Errorf("refusing PVE access control apply: /etc/pve is not mounted (pmxcfs not available)")
+ }
+ }
+
+ userCfgRaw, userCfgPresent, err := readStageFileOptional(stageRoot, "etc/pve/user.cfg")
+ if err != nil {
+ return err
+ }
+ domainsCfgRaw, domainsCfgPresent, err := readStageFileOptional(stageRoot, "etc/pve/domains.cfg")
+ if err != nil {
+ return err
+ }
+ shadowRaw, shadowPresent, err := readStageFileOptional(stageRoot, "etc/pve/priv/shadow.cfg")
+ if err != nil {
+ return err
+ }
+ tokenRaw, tokenPresent, err := readStageFileOptional(stageRoot, "etc/pve/priv/token.cfg")
+ if err != nil {
+ return err
+ }
+ tfaRaw, tfaPresent, err := readStageFileOptional(stageRoot, "etc/pve/priv/tfa.cfg")
+ if err != nil {
+ return err
+ }
+
+ if !userCfgPresent && !domainsCfgPresent && !shadowPresent && !tokenPresent && !tfaPresent {
+ logging.DebugStep(logger, "pve access control apply", "No PVE access control files found in staging; skipping")
+ return nil
+ }
+
+ backupUserSections, err := parseProxmoxNotificationSections(userCfgRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged user.cfg: %w", err)
+ }
+ backupDomainSections, err := parseProxmoxNotificationSections(domainsCfgRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged domains.cfg: %w", err)
+ }
+ backupShadowSections, err := parseProxmoxNotificationSections(shadowRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged priv/shadow.cfg: %w", err)
+ }
+ backupTokenSections, err := parseProxmoxNotificationSections(tokenRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged priv/token.cfg: %w", err)
+ }
+ backupTFASections, err := parseProxmoxNotificationSections(tfaRaw)
+ if err != nil {
+ return fmt.Errorf("parse staged priv/tfa.cfg: %w", err)
+ }
+
+ currentUserSections, _ := readProxmoxConfigSectionsOptional(pveUserCfgPath)
+ currentDomainSections, _ := readProxmoxConfigSectionsOptional(pveDomainsCfgPath)
+ currentShadowSections, _ := readProxmoxConfigSectionsOptional(pveShadowCfgPath)
+ currentTokenSections, _ := readProxmoxConfigSectionsOptional(pveTokenCfgPath)
+ currentTFASections, _ := readProxmoxConfigSectionsOptional(pveTFACfgPath)
+
+ rootUser := findSection(currentUserSections, "user", "root@pam")
+ if rootUser == nil {
+ rootUser = &proxmoxNotificationSection{
+ Type: "user",
+ Name: "root@pam",
+ Entries: []proxmoxNotificationEntry{
+ {Key: "enable", Value: "1"},
+ },
+ }
+ }
+
+ // Merge user.cfg: restore 1:1 except root@pam (preserve from fresh install), and ensure root has Administrator on "/".
+ mergedUser := make([]proxmoxNotificationSection, 0, len(backupUserSections)+2)
+ for _, s := range backupUserSections {
+ if strings.EqualFold(strings.TrimSpace(s.Type), "user") && strings.TrimSpace(s.Name) == "root@pam" {
+ continue
+ }
+ mergedUser = append(mergedUser, s)
+ }
+ mergedUser = append(mergedUser, *rootUser)
+ if !hasRootAdminOnRoot(mergedUser) {
+ mergedUser = append(mergedUser, proxsaveRootAdminACLSection())
+ }
+
+ needsPVERealm := anyUserInRealm(mergedUser, "pve")
+ requiredRealms := []string{"pam"}
+ if needsPVERealm {
+ requiredRealms = append(requiredRealms, "pve")
+ }
+ mergedDomains := mergeRequiredRealms(backupDomainSections, currentDomainSections, requiredRealms)
+
+ // Merge secrets: restore 1:1 except root@pam token/TFA entries (preserve from fresh install).
+ mergedTokens := mergeUserBoundSectionsExcludeRoot(backupTokenSections, currentTokenSections, tokenSectionUserID, "root@pam")
+ mergedTFA := mergeUserBoundSectionsExcludeRoot(backupTFASections, currentTFASections, tfaSectionUserID, "root@pam")
+ mergedShadow := mergeUserBoundSectionsExcludeRoot(backupShadowSections, currentShadowSections, shadowSectionUserID, "root@pam")
+
+ if domainsCfgPresent {
+ if err := writeFileAtomic(pveDomainsCfgPath, []byte(renderProxmoxConfig(mergedDomains)), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pveDomainsCfgPath, err)
+ }
+ }
+ if userCfgPresent {
+ if err := writeFileAtomic(pveUserCfgPath, []byte(renderProxmoxConfig(mergedUser)), 0o640); err != nil {
+ return fmt.Errorf("write %s: %w", pveUserCfgPath, err)
+ }
+ }
+ if shadowPresent {
+ if err := writeFileAtomic(pveShadowCfgPath, []byte(renderProxmoxConfig(mergedShadow)), 0o600); err != nil {
+ return fmt.Errorf("write %s: %w", pveShadowCfgPath, err)
+ }
+ }
+ if tokenPresent {
+ if err := writeFileAtomic(pveTokenCfgPath, []byte(renderProxmoxConfig(mergedTokens)), 0o600); err != nil {
+ return fmt.Errorf("write %s: %w", pveTokenCfgPath, err)
+ }
+ }
+ if tfaPresent {
+ if err := writeFileAtomic(pveTFACfgPath, []byte(renderProxmoxConfig(mergedTFA)), 0o600); err != nil {
+ return fmt.Errorf("write %s: %w", pveTFACfgPath, err)
+ }
+ }
+
+ logger.Warning("PVE access control: restored 1:1 from backup via pmxcfs; root@pam preserved from fresh install and kept Administrator on /")
+ logger.Warning("PVE access control: TFA was restored 1:1; users with WebAuthn may require re-enrollment if origin/hostname changed (for best compatibility, keep the same FQDN/origin and restore network+ssl; default behavior is warn, not disable)")
+ if tfaPresent {
+ webauthnUsers := extractWebAuthnUsersFromPVETFA(mergedTFA)
+ if len(webauthnUsers) > 0 {
+ logger.Warning("PVE TFA/WebAuthn: detected %d enrolled user(s): %s", len(webauthnUsers), summarizeUserIDs(webauthnUsers, 8))
+ }
+ }
+ return nil
+}
+
+func readStageFileOptional(stageRoot, relPath string) (content string, present bool, err error) {
+ stagePath := filepath.Join(stageRoot, relPath)
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", false, nil
+ }
+ return "", false, fmt.Errorf("read staged %s: %w", relPath, err)
+ }
+ trimmed := strings.TrimSpace(string(data))
+ if trimmed == "" {
+ return "", true, nil
+ }
+ return trimmed, true, nil
+}
+
+func readProxmoxConfigSectionsOptional(path string) ([]proxmoxNotificationSection, error) {
+ data, err := restoreFS.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ return nil, nil
+ }
+ return parseProxmoxNotificationSections(raw)
+}
+
+func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ if err := restoreFS.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+
+ tmpPath := fmt.Sprintf("%s.proxsave.tmp.%d", path, nowRestore().UnixNano())
+ f, err := restoreFS.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL|os.O_TRUNC, perm)
+ if err != nil {
+ return err
+ }
+
+ writeErr := func() error {
+ if len(data) == 0 {
+ return nil
+ }
+ _, err := f.Write(data)
+ return err
+ }()
+ closeErr := f.Close()
+ if writeErr != nil {
+ _ = restoreFS.Remove(tmpPath)
+ return writeErr
+ }
+ if closeErr != nil {
+ _ = restoreFS.Remove(tmpPath)
+ return closeErr
+ }
+
+ if err := restoreFS.Rename(tmpPath, path); err != nil {
+ _ = restoreFS.Remove(tmpPath)
+ return err
+ }
+ return nil
+}
+
+func renderProxmoxConfig(sections []proxmoxNotificationSection) string {
+ if len(sections) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ for i, s := range sections {
+ typ := strings.TrimSpace(s.Type)
+ name := strings.TrimSpace(s.Name)
+ if typ == "" || name == "" {
+ continue
+ }
+ if i > 0 && b.Len() > 0 {
+ b.WriteString("\n")
+ }
+ fmt.Fprintf(&b, "%s: %s\n", typ, name)
+ for _, kv := range s.Entries {
+ key := strings.TrimSpace(kv.Key)
+ if key == "" {
+ continue
+ }
+ val := strings.TrimSpace(kv.Value)
+ if val == "" {
+ fmt.Fprintf(&b, " %s\n", key)
+ continue
+ }
+ fmt.Fprintf(&b, " %s %s\n", key, val)
+ }
+ }
+ out := b.String()
+ if strings.TrimSpace(out) == "" {
+ return ""
+ }
+ if !strings.HasSuffix(out, "\n") {
+ out += "\n"
+ }
+ return out
+}
+
+func findSection(sections []proxmoxNotificationSection, typ, name string) *proxmoxNotificationSection {
+ typ = strings.TrimSpace(typ)
+ name = strings.TrimSpace(name)
+ for i := range sections {
+ if strings.EqualFold(strings.TrimSpace(sections[i].Type), typ) && strings.TrimSpace(sections[i].Name) == name {
+ return §ions[i]
+ }
+ }
+ return nil
+}
+
+func anyUserInRealm(sections []proxmoxNotificationSection, realm string) bool {
+ realm = strings.TrimSpace(realm)
+ if realm == "" {
+ return false
+ }
+ for _, s := range sections {
+ if !strings.EqualFold(strings.TrimSpace(s.Type), "user") {
+ continue
+ }
+ if strings.EqualFold(userRealm(strings.TrimSpace(s.Name)), realm) {
+ return true
+ }
+ }
+ return false
+}
+
+func userRealm(userID string) string {
+ userID = strings.TrimSpace(userID)
+ idx := strings.LastIndex(userID, "@")
+ if idx < 0 || idx+1 >= len(userID) {
+ return ""
+ }
+ return strings.TrimSpace(userID[idx+1:])
+}
+
+func mergeRequiredRealms(backup, current []proxmoxNotificationSection, required []string) []proxmoxNotificationSection {
+ type key struct {
+ typ string
+ name string
+ }
+ seen := make(map[key]struct{})
+ out := make([]proxmoxNotificationSection, 0, len(backup)+2)
+
+ appendUnique := func(s proxmoxNotificationSection) {
+ k := key{typ: strings.ToLower(strings.TrimSpace(s.Type)), name: strings.TrimSpace(s.Name)}
+ if k.typ == "" || k.name == "" {
+ return
+ }
+ if _, ok := seen[k]; ok {
+ return
+ }
+ seen[k] = struct{}{}
+ out = append(out, s)
+ }
+
+ // Start from backup (1:1).
+ for _, s := range backup {
+ appendUnique(s)
+ }
+
+ for _, realm := range required {
+ realm = strings.TrimSpace(realm)
+ if realm == "" {
+ continue
+ }
+
+ // Domain section keys are `: ` (e.g. `pam: pam`, `pve: pve`, `ldap: myldap`).
+ cur := findSection(current, realm, realm)
+ if cur == nil {
+ appendUnique(proxmoxNotificationSection{Type: realm, Name: realm})
+ continue
+ }
+
+ // Override backup with the live realm definition for safety rail.
+ k := key{typ: strings.ToLower(strings.TrimSpace(cur.Type)), name: strings.TrimSpace(cur.Name)}
+ if k.typ == "" || k.name == "" {
+ continue
+ }
+ if _, ok := seen[k]; ok {
+ for i := range out {
+ if strings.EqualFold(strings.TrimSpace(out[i].Type), realm) && strings.TrimSpace(out[i].Name) == realm {
+ out[i] = *cur
+ break
+ }
+ }
+ continue
+ }
+ seen[k] = struct{}{}
+ out = append(out, *cur)
+ }
+
+ return out
+}
+
+func mergeUserBoundSectionsExcludeRoot(backup, current []proxmoxNotificationSection, userIDFn func(proxmoxNotificationSection) string, rootUserID string) []proxmoxNotificationSection {
+ rootUserID = strings.TrimSpace(rootUserID)
+ out := make([]proxmoxNotificationSection, 0, len(backup))
+ for _, s := range backup {
+ if strings.TrimSpace(rootUserID) != "" && strings.TrimSpace(userIDFn(s)) == rootUserID {
+ continue
+ }
+ out = append(out, s)
+ }
+ for _, s := range current {
+ if strings.TrimSpace(rootUserID) != "" && strings.TrimSpace(userIDFn(s)) == rootUserID {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func tokenSectionUserID(s proxmoxNotificationSection) string {
+ if strings.TrimSpace(s.Type) != "token" {
+ return ""
+ }
+ userID, _, ok := splitPVETokenSectionName(strings.TrimSpace(s.Name))
+ if !ok {
+ return ""
+ }
+ return userID
+}
+
+func tfaSectionUserID(s proxmoxNotificationSection) string {
+ name := strings.TrimSpace(s.Name)
+ if strings.Contains(name, "@") {
+ return name
+ }
+ for _, kv := range s.Entries {
+ if strings.EqualFold(strings.TrimSpace(kv.Key), "user") && strings.Contains(strings.TrimSpace(kv.Value), "@") {
+ return strings.TrimSpace(kv.Value)
+ }
+ }
+ return ""
+}
+
+func shadowSectionUserID(s proxmoxNotificationSection) string {
+ if strings.EqualFold(strings.TrimSpace(s.Type), "user") && strings.Contains(strings.TrimSpace(s.Name), "@") {
+ return strings.TrimSpace(s.Name)
+ }
+ if strings.Contains(strings.TrimSpace(s.Name), "@") {
+ return strings.TrimSpace(s.Name)
+ }
+ for _, kv := range s.Entries {
+ if strings.EqualFold(strings.TrimSpace(kv.Key), "userid") && strings.Contains(strings.TrimSpace(kv.Value), "@") {
+ return strings.TrimSpace(kv.Value)
+ }
+ }
+ return ""
+}
+
+func splitPVETokenSectionName(name string) (userID, tokenID string, ok bool) {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ return "", "", false
+ }
+ idx := strings.LastIndex(trimmed, "!")
+ if idx <= 0 || idx+1 >= len(trimmed) {
+ return "", "", false
+ }
+ userID = strings.TrimSpace(trimmed[:idx])
+ tokenID = strings.TrimSpace(trimmed[idx+1:])
+ if userID == "" || tokenID == "" {
+ return "", "", false
+ }
+ return userID, tokenID, true
+}
+
+func hasRootAdminOnRoot(sections []proxmoxNotificationSection) bool {
+ for _, s := range sections {
+ if strings.TrimSpace(s.Type) != "acl" {
+ continue
+ }
+ path := strings.TrimSpace(findSectionEntryValue(s.Entries, "path"))
+ if path == "" {
+ path = aclPathFromSectionName(s.Name)
+ }
+ if path != "/" {
+ continue
+ }
+
+ users := strings.TrimSpace(findSectionEntryValue(s.Entries, "users"))
+ roles := strings.TrimSpace(findSectionEntryValue(s.Entries, "roles"))
+ if listContains(users, "root@pam") && listContains(roles, "Administrator") {
+ return true
+ }
+ }
+ return false
+}
+
+func proxsaveRootAdminACLSection() proxmoxNotificationSection {
+ return proxmoxNotificationSection{
+ Type: "acl",
+ Name: "proxsave-root-admin",
+ Entries: []proxmoxNotificationEntry{
+ {Key: "path", Value: "/"},
+ {Key: "users", Value: "root@pam"},
+ {Key: "roles", Value: "Administrator"},
+ {Key: "propagate", Value: "1"},
+ },
+ }
+}
+
+func findSectionEntryValue(entries []proxmoxNotificationEntry, key string) string {
+ match := strings.TrimSpace(key)
+ for _, e := range entries {
+ if strings.TrimSpace(e.Key) == match {
+ return strings.TrimSpace(e.Value)
+ }
+ }
+ return ""
+}
+
+func listContains(raw, item string) bool {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ return false
+ }
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return false
+ }
+ trimmed = strings.ReplaceAll(trimmed, ",", " ")
+ for _, field := range strings.Fields(trimmed) {
+ if strings.TrimSpace(field) == item {
+ return true
+ }
+ }
+ return false
+}
+
+func aclPathFromSectionName(name string) string {
+ for _, field := range strings.Fields(strings.TrimSpace(name)) {
+ if strings.HasPrefix(field, "/") {
+ return field
+ }
+ }
+ return ""
+}
+
+func extractWebAuthnUsersFromPVETFA(sections []proxmoxNotificationSection) []string {
+ if len(sections) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ out := make([]string, 0, 8)
+ for _, s := range sections {
+ typ := strings.ToLower(strings.TrimSpace(s.Type))
+ if typ != "webauthn" && typ != "u2f" {
+ continue
+ }
+ userID := strings.TrimSpace(tfaSectionUserID(s))
+ if userID == "" || userID == "root@pam" {
+ continue
+ }
+ if _, ok := seen[userID]; ok {
+ continue
+ }
+ seen[userID] = struct{}{}
+ out = append(out, userID)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func extractWebAuthnUsersFromPBSTFAUsers(users map[string]json.RawMessage) []string {
+ if len(users) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ out := make([]string, 0, 8)
+ for userID, payload := range users {
+ if isRootPBSUserID(userID) {
+ continue
+ }
+ var methods map[string]json.RawMessage
+ if err := json.Unmarshal(payload, &methods); err != nil {
+ continue
+ }
+ if jsonRawNonNull(methods["webauthn"]) || jsonRawNonNull(methods["u2f"]) {
+ if _, ok := seen[userID]; ok {
+ continue
+ }
+ seen[userID] = struct{}{}
+ out = append(out, userID)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func jsonRawNonNull(raw json.RawMessage) bool {
+ trimmed := strings.TrimSpace(string(raw))
+ if trimmed == "" || trimmed == "null" || trimmed == "{}" || trimmed == "[]" {
+ return false
+ }
+ return true
+}
+
+func summarizeUserIDs(userIDs []string, max int) string {
+ if len(userIDs) == 0 {
+ return ""
+ }
+ if max <= 0 {
+ max = 10
+ }
+ if len(userIDs) <= max {
+ return strings.Join(userIDs, ", ")
+ }
+ return fmt.Sprintf("%s (+%d more)", strings.Join(userIDs[:max], ", "), len(userIDs)-max)
+}
diff --git a/internal/orchestrator/restore_access_control_test.go b/internal/orchestrator/restore_access_control_test.go
new file mode 100644
index 0000000..62060af
--- /dev/null
+++ b/internal/orchestrator/restore_access_control_test.go
@@ -0,0 +1,453 @@
+package orchestrator
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestApplyPVEAccessControlFromStage_Restores1To1ExceptRoot(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+
+ // Fresh-install baseline (must be preserved for root@pam safety rail).
+ currentDomains := `
+pam: pam
+ comment freshpam
+
+pve: pve
+ comment freshpve
+`
+ currentUser := `
+user: root@pam
+ comment FreshRoot
+ enable 1
+`
+ currentToken := `
+token: root@pam!fresh
+ comment fresh-token
+`
+ currentTFA := `
+totp: root@pam
+ user root@pam
+ comment fresh-tfa
+`
+ currentShadow := `
+user: root@pam
+ hash fresh-hash
+`
+
+ if err := fakeFS.WriteFile(pveDomainsCfgPath, []byte(currentDomains), 0o640); err != nil {
+ t.Fatalf("write current domains.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(pveUserCfgPath, []byte(currentUser), 0o640); err != nil {
+ t.Fatalf("write current user.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(pveTokenCfgPath, []byte(currentToken), 0o600); err != nil {
+ t.Fatalf("write current token.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(pveTFACfgPath, []byte(currentTFA), 0o600); err != nil {
+ t.Fatalf("write current tfa.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(pveShadowCfgPath, []byte(currentShadow), 0o600); err != nil {
+ t.Fatalf("write current shadow.cfg: %v", err)
+ }
+
+ // Backup/stage includes a conflicting root@pam definition (must NOT be applied), plus a @pve user.
+ stagedDomains := `
+pam: pam
+ comment backup-pam-should-not-win
+
+ldap: myldap
+ base_dn dc=example,dc=com
+`
+ stagedUser := `
+role: MyRole
+ privs VM.Audit
+
+user: root@pam
+ enable 0
+
+user: alice@pve
+ enable 1
+
+acl: 1
+ path /
+ roles MyRole
+ users alice@pve
+ propagate 1
+`
+ stagedToken := `
+token: root@pam!old
+ comment old-token-should-not-win
+
+token: alice@pve!mytoken
+ comment alice-token
+`
+ stagedTFA := `
+totp: root@pam
+ user root@pam
+ comment old-tfa-should-not-win
+
+totp: alice@pve
+ user alice@pve
+`
+ stagedShadow := `
+user: root@pam
+ hash old-hash-should-not-win
+
+user: alice@pve
+ hash alice-hash
+`
+
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/domains.cfg", []byte(stagedDomains), 0o640); err != nil {
+ t.Fatalf("write staged domains.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/user.cfg", []byte(stagedUser), 0o640); err != nil {
+ t.Fatalf("write staged user.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/priv/token.cfg", []byte(stagedToken), 0o600); err != nil {
+ t.Fatalf("write staged token.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/priv/tfa.cfg", []byte(stagedTFA), 0o600); err != nil {
+ t.Fatalf("write staged tfa.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/priv/shadow.cfg", []byte(stagedShadow), 0o600); err != nil {
+ t.Fatalf("write staged shadow.cfg: %v", err)
+ }
+
+ if err := applyPVEAccessControlFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPVEAccessControlFromStage error: %v", err)
+ }
+
+ // Root realm safety: pam realm must be preserved from fresh install (comment should match currentDomains).
+ gotDomains, err := fakeFS.ReadFile(pveDomainsCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pveDomainsCfgPath, err)
+ }
+ if !strings.Contains(string(gotDomains), "comment freshpam") {
+ t.Fatalf("expected fresh pam realm to be preserved, got:\n%s", string(gotDomains))
+ }
+ // pve realm should be present because alice@pve exists.
+ if !strings.Contains(string(gotDomains), "pve: pve") {
+ t.Fatalf("expected pve realm to be present, got:\n%s", string(gotDomains))
+ }
+
+ // Root user safety: root@pam must be preserved from currentUser and not disabled.
+ gotUser, err := fakeFS.ReadFile(pveUserCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pveUserCfgPath, err)
+ }
+ if !strings.Contains(string(gotUser), "comment FreshRoot") {
+ t.Fatalf("expected root@pam section preserved from fresh install, got:\n%s", string(gotUser))
+ }
+ if strings.Contains(string(gotUser), "user: root@pam\n enable 0") {
+ t.Fatalf("expected staged root@pam not to be applied, got:\n%s", string(gotUser))
+ }
+ // Root admin ACL safety rail should be present.
+ if !strings.Contains(string(gotUser), "acl: proxsave-root-admin") ||
+ !strings.Contains(string(gotUser), "users root@pam") ||
+ !strings.Contains(string(gotUser), "roles Administrator") ||
+ !strings.Contains(string(gotUser), "path /") {
+ t.Fatalf("expected proxsave root admin ACL to be injected, got:\n%s", string(gotUser))
+ }
+
+ // Token safety rail: keep fresh root token, do not import staged root token.
+ gotToken, err := fakeFS.ReadFile(pveTokenCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pveTokenCfgPath, err)
+ }
+ if !strings.Contains(string(gotToken), "token: root@pam!fresh") {
+ t.Fatalf("expected fresh root token to be preserved, got:\n%s", string(gotToken))
+ }
+ if strings.Contains(string(gotToken), "token: root@pam!old") {
+ t.Fatalf("expected staged root token to be excluded, got:\n%s", string(gotToken))
+ }
+ if !strings.Contains(string(gotToken), "token: alice@pve!mytoken") {
+ t.Fatalf("expected alice token to be restored, got:\n%s", string(gotToken))
+ }
+
+ // TFA safety rail: keep fresh root TFA, restore alice TFA.
+ gotTFA, err := fakeFS.ReadFile(pveTFACfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pveTFACfgPath, err)
+ }
+ if !strings.Contains(string(gotTFA), "comment fresh-tfa") {
+ t.Fatalf("expected fresh root TFA to be preserved, got:\n%s", string(gotTFA))
+ }
+ if strings.Contains(string(gotTFA), "comment old-tfa-should-not-win") {
+ t.Fatalf("expected staged root TFA to be excluded, got:\n%s", string(gotTFA))
+ }
+ if !strings.Contains(string(gotTFA), "totp: alice@pve") {
+ t.Fatalf("expected alice TFA to be restored, got:\n%s", string(gotTFA))
+ }
+
+ // Shadow safety rail: keep fresh root hash, restore alice hash.
+ gotShadow, err := fakeFS.ReadFile(pveShadowCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pveShadowCfgPath, err)
+ }
+ if !strings.Contains(string(gotShadow), "hash fresh-hash") {
+ t.Fatalf("expected fresh root shadow to be preserved, got:\n%s", string(gotShadow))
+ }
+ if strings.Contains(string(gotShadow), "hash old-hash-should-not-win") {
+ t.Fatalf("expected staged root shadow to be excluded, got:\n%s", string(gotShadow))
+ }
+ if !strings.Contains(string(gotShadow), "hash alice-hash") {
+ t.Fatalf("expected alice shadow to be restored, got:\n%s", string(gotShadow))
+ }
+}
+
+func TestMaybeApplyAccessControlFromStage_SkipsClusterBackupInSafeMode(t *testing.T) {
+ plan := &RestorePlan{
+ SystemType: SystemTypePVE,
+ ClusterBackup: true,
+ }
+ plan.StagedCategories = []Category{{ID: "pve_access_control"}}
+
+ if err := maybeApplyAccessControlFromStage(context.Background(), newTestLogger(), plan, "/stage", false); err != nil {
+ t.Fatalf("expected nil, got %v", err)
+ }
+}
+
+func TestApplyPBSAccessControlFromStage_Restores1To1ExceptRoot(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+
+ // Fresh-install baseline (must be preserved for root@pam safety rail).
+ currentDomains := `
+pam: pam
+ comment freshpam
+
+pbs: pbs
+ comment freshpbs
+`
+ currentUser := `
+user: root@pam
+ comment FreshRoot
+ enable 1
+
+token: root@pam!fresh
+ comment fresh-token
+
+user: keepme@pbs
+ enable 1
+`
+ currentTokenShadow := `{"root@pam!fresh":"fresh-secret"}`
+ currentTFA := `{"users":{"root@pam":{"totp":[9]}},"webauthn":{"rp":"fresh"}}`
+
+ if err := fakeFS.WriteFile(pbsDomainsCfgPath, []byte(currentDomains), 0o640); err != nil {
+ t.Fatalf("write current domains.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(pbsUserCfgPath, []byte(currentUser), 0o640); err != nil {
+ t.Fatalf("write current user.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(pbsTokenShadowPath, []byte(currentTokenShadow), 0o600); err != nil {
+ t.Fatalf("write current token.shadow: %v", err)
+ }
+ if err := fakeFS.WriteFile(pbsTFAJSONPath, []byte(currentTFA), 0o600); err != nil {
+ t.Fatalf("write current tfa.json: %v", err)
+ }
+
+ // Backup/stage includes a conflicting root@pam definition (must NOT be applied), plus a @pbs user.
+ stagedDomains := `
+pam: pam
+ comment backup-pam-should-not-win
+
+ldap: myldap
+ base_dn dc=example,dc=com
+`
+ stagedUser := `
+user: root@pam
+ enable 0
+
+token: root@pam!old
+ comment old-token-should-not-win
+
+user: alice@pbs
+ enable 1
+`
+ stagedACL := `
+acl:1:/:root@pam:Admin
+acl:1:/:root@pam!old:Admin
+acl:1:/:alice@pbs:Admin
+`
+ stagedShadow := `{"root@pbs":"old-root-hash","alice@pbs":"alice-hash"}`
+ stagedTokenShadow := `{"root@pam!old":"old-secret","alice@pbs!tok":"alice-secret"}`
+ stagedTFA := `{"users":{"root@pam":{"totp":[1]},"alice@pbs":{"totp":[2]}},"webauthn":{"rp":"backup"}}`
+
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/domains.cfg", []byte(stagedDomains), 0o640); err != nil {
+ t.Fatalf("write staged domains.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/user.cfg", []byte(stagedUser), 0o640); err != nil {
+ t.Fatalf("write staged user.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/acl.cfg", []byte(stagedACL), 0o640); err != nil {
+ t.Fatalf("write staged acl.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/shadow.json", []byte(stagedShadow), 0o600); err != nil {
+ t.Fatalf("write staged shadow.json: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/token.shadow", []byte(stagedTokenShadow), 0o600); err != nil {
+ t.Fatalf("write staged token.shadow: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/tfa.json", []byte(stagedTFA), 0o600); err != nil {
+ t.Fatalf("write staged tfa.json: %v", err)
+ }
+
+ if err := applyPBSAccessControlFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPBSAccessControlFromStage error: %v", err)
+ }
+
+ // Root realm safety: pam realm must be preserved from fresh install.
+ gotDomains, err := fakeFS.ReadFile(pbsDomainsCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pbsDomainsCfgPath, err)
+ }
+ if !strings.Contains(string(gotDomains), "comment freshpam") {
+ t.Fatalf("expected fresh pam realm to be preserved, got:\n%s", string(gotDomains))
+ }
+ if !strings.Contains(string(gotDomains), "ldap: myldap") {
+ t.Fatalf("expected ldap realm restored, got:\n%s", string(gotDomains))
+ }
+ // pbs realm should be present because alice@pbs exists.
+ if !strings.Contains(string(gotDomains), "pbs: pbs") {
+ t.Fatalf("expected pbs realm to be present, got:\n%s", string(gotDomains))
+ }
+
+ // Root user safety: root@pam must be preserved from currentUser and not disabled.
+ gotUser, err := fakeFS.ReadFile(pbsUserCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pbsUserCfgPath, err)
+ }
+ if !strings.Contains(string(gotUser), "comment FreshRoot") {
+ t.Fatalf("expected root@pam section preserved from fresh install, got:\n%s", string(gotUser))
+ }
+ if strings.Contains(string(gotUser), "user: root@pam\n enable 0") {
+ t.Fatalf("expected staged root@pam not to be applied, got:\n%s", string(gotUser))
+ }
+ // Root token safety: keep fresh root token, do not import staged root token.
+ if !strings.Contains(string(gotUser), "token: root@pam!fresh") {
+ t.Fatalf("expected fresh root token to be preserved, got:\n%s", string(gotUser))
+ }
+ if strings.Contains(string(gotUser), "token: root@pam!old") {
+ t.Fatalf("expected staged root token to be excluded, got:\n%s", string(gotUser))
+ }
+ if !strings.Contains(string(gotUser), "user: alice@pbs") {
+ t.Fatalf("expected alice user to be restored, got:\n%s", string(gotUser))
+ }
+
+ // ACL safety rail: ensure root has Admin on / and root token ACLs from backup are excluded.
+ gotACL, err := fakeFS.ReadFile(pbsACLCfgPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pbsACLCfgPath, err)
+ }
+ if !strings.Contains(string(gotACL), "acl:1:/:root@pam:Admin") {
+ t.Fatalf("expected root admin ACL to be present, got:\n%s", string(gotACL))
+ }
+ if strings.Contains(string(gotACL), "root@pam!old") {
+ t.Fatalf("expected staged root token ACL to be excluded, got:\n%s", string(gotACL))
+ }
+ if !strings.Contains(string(gotACL), "alice@pbs") {
+ t.Fatalf("expected alice ACL to be restored, got:\n%s", string(gotACL))
+ }
+
+ // token.shadow safety rail: keep fresh root token secret, restore alice token secret, exclude staged root token secret.
+ gotTokenShadow, err := fakeFS.ReadFile(pbsTokenShadowPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pbsTokenShadowPath, err)
+ }
+ if strings.Contains(string(gotTokenShadow), "root@pam!old") {
+ t.Fatalf("expected staged root token secret to be excluded, got:\n%s", string(gotTokenShadow))
+ }
+ if !strings.Contains(string(gotTokenShadow), "root@pam!fresh") {
+ t.Fatalf("expected fresh root token secret to be preserved, got:\n%s", string(gotTokenShadow))
+ }
+ if !strings.Contains(string(gotTokenShadow), "alice@pbs!tok") {
+ t.Fatalf("expected alice token secret to be restored, got:\n%s", string(gotTokenShadow))
+ }
+
+ // tfa.json safety rail: keep fresh root TFA, restore alice TFA, preserve backup webauthn config.
+ gotTFA, err := fakeFS.ReadFile(pbsTFAJSONPath)
+ if err != nil {
+ t.Fatalf("read %s: %v", pbsTFAJSONPath, err)
+ }
+ if !strings.Contains(string(gotTFA), "\"root@pam\"") || strings.Contains(string(gotTFA), "\"totp\":[1]") {
+ t.Fatalf("expected staged root TFA to be excluded and fresh root TFA preserved, got:\n%s", string(gotTFA))
+ }
+ if !strings.Contains(string(gotTFA), "\"alice@pbs\"") {
+ t.Fatalf("expected alice TFA to be restored, got:\n%s", string(gotTFA))
+ }
+ if !strings.Contains(string(gotTFA), "\"rp\":\"backup\"") {
+ t.Fatalf("expected backup webauthn config to be preserved, got:\n%s", string(gotTFA))
+ }
+}
+
+func TestApplyPBSAccessControlFromStage_WritesFilesWithPermissions(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+ userCfg := "user: root@pam\n enable 1\n"
+ domainsCfg := "pam: pam\n comment builtin\n"
+ aclCfg := "acl: 1\n path /\n users root@pam\n roles Admin\n"
+
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/user.cfg", []byte(userCfg), 0o640); err != nil {
+ t.Fatalf("write staged user.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/domains.cfg", []byte(domainsCfg), 0o640); err != nil {
+ t.Fatalf("write staged domains.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/acl.cfg", []byte(aclCfg), 0o640); err != nil {
+ t.Fatalf("write staged acl.cfg: %v", err)
+ }
+
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/shadow.json", []byte("{}"), 0o600); err != nil {
+ t.Fatalf("write staged shadow.json: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/token.shadow", []byte("{}"), 0o600); err != nil {
+ t.Fatalf("write staged token.shadow: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/tfa.json", []byte("{}"), 0o600); err != nil {
+ t.Fatalf("write staged tfa.json: %v", err)
+ }
+
+ if err := applyPBSAccessControlFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPBSAccessControlFromStage error: %v", err)
+ }
+
+ expectPerm := func(path string, perm os.FileMode) {
+ t.Helper()
+ info, err := fakeFS.Stat(path)
+ if err != nil {
+ t.Fatalf("stat %s: %v", path, err)
+ }
+ if info.Mode().Perm() != perm {
+ t.Fatalf("%s mode=%#o want %#o", path, info.Mode().Perm(), perm)
+ }
+ }
+
+ expectPerm("/etc/proxmox-backup/user.cfg", 0o640)
+ expectPerm("/etc/proxmox-backup/domains.cfg", 0o640)
+ expectPerm("/etc/proxmox-backup/acl.cfg", 0o640)
+ expectPerm("/etc/proxmox-backup/shadow.json", 0o600)
+ expectPerm("/etc/proxmox-backup/token.shadow", 0o600)
+ expectPerm("/etc/proxmox-backup/tfa.json", 0o600)
+}
diff --git a/internal/orchestrator/restore_access_control_ui.go b/internal/orchestrator/restore_access_control_ui.go
new file mode 100644
index 0000000..2fecd71
--- /dev/null
+++ b/internal/orchestrator/restore_access_control_ui.go
@@ -0,0 +1,491 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+const defaultAccessControlRollbackTimeout = 180 * time.Second
+
+var ErrAccessControlApplyNotCommitted = errors.New("access control changes not committed")
+
+type AccessControlApplyNotCommittedError struct {
+ RollbackLog string
+ RollbackMarker string
+ RollbackArmed bool
+ RollbackDeadline time.Time
+}
+
+func (e *AccessControlApplyNotCommittedError) Error() string {
+ if e == nil {
+ return ErrAccessControlApplyNotCommitted.Error()
+ }
+ return ErrAccessControlApplyNotCommitted.Error()
+}
+
+func (e *AccessControlApplyNotCommittedError) Unwrap() error {
+ return ErrAccessControlApplyNotCommitted
+}
+
+type accessControlRollbackHandle struct {
+ workDir string
+ markerPath string
+ unitName string
+ scriptPath string
+ logPath string
+ armedAt time.Time
+ timeout time.Duration
+}
+
+func (h *accessControlRollbackHandle) remaining(now time.Time) time.Duration {
+ if h == nil {
+ return 0
+ }
+ rem := h.timeout - now.Sub(h.armedAt)
+ if rem < 0 {
+ return 0
+ }
+ return rem
+}
+
+func buildAccessControlApplyNotCommittedError(handle *accessControlRollbackHandle) *AccessControlApplyNotCommittedError {
+ rollbackArmed := false
+ rollbackMarker := ""
+ rollbackLog := ""
+ var rollbackDeadline time.Time
+ if handle != nil {
+ rollbackMarker = strings.TrimSpace(handle.markerPath)
+ rollbackLog = strings.TrimSpace(handle.logPath)
+ if rollbackMarker != "" {
+ if _, err := restoreFS.Stat(rollbackMarker); err == nil {
+ rollbackArmed = true
+ }
+ }
+ rollbackDeadline = handle.armedAt.Add(handle.timeout)
+ }
+
+ return &AccessControlApplyNotCommittedError{
+ RollbackLog: rollbackLog,
+ RollbackMarker: rollbackMarker,
+ RollbackArmed: rollbackArmed,
+ RollbackDeadline: rollbackDeadline,
+ }
+}
+
+func stageHasPVEAccessControlConfig(stageRoot string) (bool, error) {
+ stageRoot = strings.TrimSpace(stageRoot)
+ if stageRoot == "" {
+ return false, nil
+ }
+
+ candidates := []string{
+ filepath.Join(stageRoot, "etc", "pve", "user.cfg"),
+ filepath.Join(stageRoot, "etc", "pve", "domains.cfg"),
+ filepath.Join(stageRoot, "etc", "pve", "priv", "shadow.cfg"),
+ filepath.Join(stageRoot, "etc", "pve", "priv", "token.cfg"),
+ filepath.Join(stageRoot, "etc", "pve", "priv", "tfa.cfg"),
+ }
+
+ for _, candidate := range candidates {
+ info, err := restoreFS.Stat(candidate)
+ if err == nil && info != nil && !info.IsDir() {
+ return true, nil
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return false, fmt.Errorf("stat %s: %w", candidate, err)
+ }
+ }
+
+ return false, nil
+}
+
+func maybeApplyAccessControlWithUI(
+ ctx context.Context,
+ ui RestoreWorkflowUI,
+ logger *logging.Logger,
+ plan *RestorePlan,
+ safetyBackup, accessControlRollbackBackup *SafetyBackupResult,
+ stageRoot string,
+ dryRun bool,
+) (err error) {
+ if plan == nil {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "access control staged apply (ui)", "Skipped: staging directory not available")
+ return nil
+ }
+ if !plan.HasCategoryID("pve_access_control") && !plan.HasCategoryID("pbs_access_control") {
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "access control staged apply (ui)", "dryRun=%v stage=%s", dryRun, strings.TrimSpace(stageRoot))
+ defer func() { done(err) }()
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+
+ // Cluster backups: PVE access control is cluster-wide. In SAFE (no config.db restore) this must be opt-in.
+ if plan.SystemType == SystemTypePVE &&
+ plan.HasCategoryID("pve_access_control") &&
+ plan.ClusterBackup &&
+ !plan.NeedsClusterRestore {
+ return maybeApplyPVEAccessControlFromClusterBackupWithUI(ctx, ui, logger, plan, safetyBackup, accessControlRollbackBackup, stageRoot, dryRun)
+ }
+
+ // Default behavior for all other cases (PBS, standalone PVE, cluster RECOVERY).
+ return maybeApplyAccessControlFromStage(ctx, logger, plan, stageRoot, dryRun)
+}
+
+func maybeApplyPVEAccessControlFromClusterBackupWithUI(
+ ctx context.Context,
+ ui RestoreWorkflowUI,
+ logger *logging.Logger,
+ plan *RestorePlan,
+ safetyBackup, accessControlRollbackBackup *SafetyBackupResult,
+ stageRoot string,
+ dryRun bool,
+) (err error) {
+ if plan == nil || plan.SystemType != SystemTypePVE || !plan.HasCategoryID("pve_access_control") || !plan.ClusterBackup || plan.NeedsClusterRestore {
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "pve access control restore (cluster backup, ui)", "dryRun=%v stage=%s", dryRun, strings.TrimSpace(stageRoot))
+ defer func() { done(err) }()
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping PVE access control apply (cluster backup): non-system filesystem in use")
+ return nil
+ }
+ if dryRun {
+ logger.Info("Dry run enabled: skipping PVE access control apply (cluster backup)")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping PVE access control apply (cluster backup): requires root privileges")
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "pve access control restore (cluster backup, ui)", "Skipped: staging directory not available")
+ return nil
+ }
+
+ // In cluster RECOVERY mode, config.db restoration owns /etc/pve state and /etc/pve is unmounted during restore.
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "pve access control restore (cluster backup, ui)", "Skip: cluster RECOVERY restores config.db")
+ return nil
+ }
+
+ stageHasAC, err := stageHasPVEAccessControlConfig(stageRoot)
+ if err != nil {
+ return err
+ }
+ if !stageHasAC {
+ logging.DebugStep(logger, "pve access control restore (cluster backup, ui)", "Skipped: no access control files in stage directory")
+ return nil
+ }
+
+ etcPVE := "/etc/pve"
+ mounted, mountErr := isMounted(etcPVE)
+ if mountErr != nil {
+ logger.Warning("PVE access control apply: unable to check pmxcfs mount (%s): %v", etcPVE, mountErr)
+ }
+ if !mounted {
+ logger.Warning("PVE access control apply: %s is not mounted; skipping apply to avoid shadow writes on root filesystem", etcPVE)
+ return nil
+ }
+
+ logger.Info("")
+ message := fmt.Sprintf(
+ "Cluster backup detected.\n\n"+
+ "Applying PVE access control will modify users/roles/groups/ACLs and secrets cluster-wide.\n\n"+
+ "WARNING: This may lock you out or break API tokens/automation.\n\n"+
+ "Safety rail: root@pam is preserved from the current system and kept Administrator on /.\n\n"+
+ "Recommendation: do this from local console/IPMI, not over SSH.\n\n"+
+ "Apply 1:1 PVE access control now?",
+ )
+ applyNow, err := ui.ConfirmAction(ctx, "Apply PVE access control (cluster-wide)", message, "Apply 1:1 (expert)", "Skip apply", 90*time.Second, false)
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "pve access control restore (cluster backup, ui)", "User choice: applyNow=%v", applyNow)
+ if !applyNow {
+ logger.Info("Skipping PVE access control apply (cluster backup).")
+ return nil
+ }
+
+ rollbackPath := ""
+ if accessControlRollbackBackup != nil {
+ rollbackPath = strings.TrimSpace(accessControlRollbackBackup.BackupPath)
+ }
+ fullRollbackPath := ""
+ if safetyBackup != nil {
+ fullRollbackPath = strings.TrimSpace(safetyBackup.BackupPath)
+ }
+
+ if rollbackPath == "" && fullRollbackPath != "" {
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "Access control rollback backup not available",
+ "Access control rollback backup is not available.\n\nIf you proceed, the rollback timer will use the full safety backup, which may revert other restored categories.\n\nProceed anyway?",
+ "Proceed with full rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "pve access control restore (cluster backup, ui)", "User choice: allowFullRollback=%v", ok)
+ if !ok {
+ logger.Info("Skipping PVE access control apply (rollback backup not available).")
+ return nil
+ }
+ rollbackPath = fullRollbackPath
+ }
+
+ if rollbackPath == "" {
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "No rollback available",
+ "No rollback backup is available.\n\nIf you proceed and you get locked out, ProxSave cannot roll back automatically.\n\nProceed anyway?",
+ "Proceed without rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ if !ok {
+ logger.Info("Skipping PVE access control apply (no rollback available).")
+ return nil
+ }
+ }
+
+ var rollbackHandle *accessControlRollbackHandle
+ if rollbackPath != "" {
+ logger.Info("")
+ logger.Info("Arming access control rollback timer (%ds)...", int(defaultAccessControlRollbackTimeout.Seconds()))
+ rollbackHandle, err = armAccessControlRollback(ctx, logger, rollbackPath, defaultAccessControlRollbackTimeout, "/tmp/proxsave")
+ if err != nil {
+ return fmt.Errorf("arm access control rollback: %w", err)
+ }
+ logger.Info("Access control rollback log: %s", rollbackHandle.logPath)
+ }
+
+ if err := applyPVEAccessControlFromStage(ctx, logger, stageRoot); err != nil {
+ return err
+ }
+
+ if rollbackHandle == nil {
+ logger.Info("PVE access control applied (no rollback timer armed).")
+ return nil
+ }
+
+ remaining := rollbackHandle.remaining(time.Now())
+ if remaining <= 0 {
+ return buildAccessControlApplyNotCommittedError(rollbackHandle)
+ }
+
+ logger.Info("")
+ commitMessage := fmt.Sprintf(
+ "PVE access control has been applied cluster-wide.\n\n"+
+ "If needed, ProxSave will roll back automatically in %ds.\n\n"+
+ "Keep access control changes?",
+ int(remaining.Seconds()),
+ )
+ commit, err := ui.ConfirmAction(ctx, "Commit access control changes", commitMessage, "Keep", "Rollback", remaining, false)
+ if err != nil {
+ if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) {
+ return err
+ }
+ logger.Warning("Access control commit prompt failed: %v", err)
+ return buildAccessControlApplyNotCommittedError(rollbackHandle)
+ }
+
+ if commit {
+ disarmAccessControlRollback(ctx, logger, rollbackHandle)
+ logger.Info("Access control changes committed.")
+ return nil
+ }
+
+ return buildAccessControlApplyNotCommittedError(rollbackHandle)
+}
+
+func armAccessControlRollback(ctx context.Context, logger *logging.Logger, backupPath string, timeout time.Duration, workDir string) (handle *accessControlRollbackHandle, err error) {
+ done := logging.DebugStart(logger, "arm access control rollback", "backup=%s timeout=%s workDir=%s", strings.TrimSpace(backupPath), timeout, strings.TrimSpace(workDir))
+ defer func() { done(err) }()
+
+ if strings.TrimSpace(backupPath) == "" {
+ return nil, fmt.Errorf("empty safety backup path")
+ }
+ if timeout <= 0 {
+ return nil, fmt.Errorf("invalid rollback timeout")
+ }
+
+ baseDir := strings.TrimSpace(workDir)
+ perm := os.FileMode(0o755)
+ if baseDir == "" {
+ baseDir = "/tmp/proxsave"
+ } else {
+ perm = 0o700
+ }
+ if err := restoreFS.MkdirAll(baseDir, perm); err != nil {
+ return nil, fmt.Errorf("create rollback directory: %w", err)
+ }
+
+ timestamp := nowRestore().Format("20060102_150405")
+ handle = &accessControlRollbackHandle{
+ workDir: baseDir,
+ markerPath: filepath.Join(baseDir, fmt.Sprintf("access_control_rollback_pending_%s", timestamp)),
+ scriptPath: filepath.Join(baseDir, fmt.Sprintf("access_control_rollback_%s.sh", timestamp)),
+ logPath: filepath.Join(baseDir, fmt.Sprintf("access_control_rollback_%s.log", timestamp)),
+ armedAt: time.Now(),
+ timeout: timeout,
+ }
+
+ if err := restoreFS.WriteFile(handle.markerPath, []byte("pending\n"), 0o640); err != nil {
+ return nil, fmt.Errorf("write rollback marker: %w", err)
+ }
+
+ script := buildAccessControlRollbackScript(handle.markerPath, backupPath, handle.logPath)
+ if err := restoreFS.WriteFile(handle.scriptPath, []byte(script), 0o640); err != nil {
+ return nil, fmt.Errorf("write rollback script: %w", err)
+ }
+
+ timeoutSeconds := int(timeout.Seconds())
+ if timeoutSeconds < 1 {
+ timeoutSeconds = 1
+ }
+
+ if commandAvailable("systemd-run") {
+ handle.unitName = fmt.Sprintf("proxsave-access-control-rollback-%s", timestamp)
+ args := []string{
+ "--unit=" + handle.unitName,
+ "--on-active=" + fmt.Sprintf("%ds", timeoutSeconds),
+ "/bin/sh",
+ handle.scriptPath,
+ }
+ if output, err := restoreCmd.Run(ctx, "systemd-run", args...); err != nil {
+ logger.Warning("systemd-run failed, falling back to background timer: %v", err)
+ logger.Debug("systemd-run output: %s", strings.TrimSpace(string(output)))
+ handle.unitName = ""
+ }
+ }
+
+ if handle.unitName == "" {
+ cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath)
+ if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil {
+ logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output)))
+ return nil, fmt.Errorf("failed to schedule rollback timer: %w", err)
+ }
+ }
+
+ return handle, nil
+}
+
+func disarmAccessControlRollback(ctx context.Context, logger *logging.Logger, handle *accessControlRollbackHandle) {
+ if handle == nil {
+ return
+ }
+ if strings.TrimSpace(handle.markerPath) != "" {
+ _ = restoreFS.Remove(handle.markerPath)
+ }
+ if strings.TrimSpace(handle.unitName) != "" && commandAvailable("systemctl") {
+ timerUnit := strings.TrimSpace(handle.unitName) + ".timer"
+ _, _ = restoreCmd.Run(ctx, "systemctl", "stop", timerUnit)
+ _, _ = restoreCmd.Run(ctx, "systemctl", "reset-failed", strings.TrimSpace(handle.unitName)+".service", timerUnit)
+ }
+ if strings.TrimSpace(handle.scriptPath) != "" {
+ _ = restoreFS.Remove(handle.scriptPath)
+ }
+ if strings.TrimSpace(handle.logPath) != "" && logger != nil {
+ logger.Debug("Access control rollback disarmed (log=%s)", strings.TrimSpace(handle.logPath))
+ }
+}
+
+func buildAccessControlRollbackScript(markerPath, backupPath, logPath string) string {
+ targets := []string{
+ "/etc/pve/user.cfg",
+ "/etc/pve/domains.cfg",
+ "/etc/pve/priv/shadow.cfg",
+ "/etc/pve/priv/token.cfg",
+ "/etc/pve/priv/tfa.cfg",
+ }
+
+ lines := []string{
+ "#!/bin/sh",
+ "set -eu",
+ fmt.Sprintf("LOG=%s", shellQuote(logPath)),
+ fmt.Sprintf("MARKER=%s", shellQuote(markerPath)),
+ fmt.Sprintf("BACKUP=%s", shellQuote(backupPath)),
+ `echo "[INFO] ========================================" >> "$LOG"`,
+ `echo "[INFO] ACCESS CONTROL ROLLBACK SCRIPT STARTED" >> "$LOG"`,
+ `echo "[INFO] Timestamp: $(date -Is)" >> "$LOG"`,
+ `echo "[INFO] Marker: $MARKER" >> "$LOG"`,
+ `echo "[INFO] Backup: $BACKUP" >> "$LOG"`,
+ `echo "[INFO] ========================================" >> "$LOG"`,
+ `if [ ! -f "$MARKER" ]; then`,
+ ` echo "[INFO] Marker not found - rollback cancelled (already disarmed)" >> "$LOG"`,
+ ` exit 0`,
+ `fi`,
+ `echo "[INFO] --- EXTRACT PHASE ---" >> "$LOG"`,
+ `TAR_OK=0`,
+ `if tar -xzf "$BACKUP" -C / >> "$LOG" 2>&1; then`,
+ ` TAR_OK=1`,
+ ` echo "[OK] Extract phase completed successfully" >> "$LOG"`,
+ `else`,
+ ` RC=$?`,
+ ` echo "[ERROR] Extract phase failed (exit=$RC) - skipping prune phase" >> "$LOG"`,
+ `fi`,
+ `if [ "$TAR_OK" -eq 1 ]; then`,
+ ` echo "[INFO] --- PRUNE PHASE ---" >> "$LOG"`,
+ ` (`,
+ ` set +e`,
+ ` MANIFEST_ALL=$(mktemp /tmp/proxsave/access_control_rollback_manifest_all_XXXXXX 2>/dev/null)`,
+ ` MANIFEST=$(mktemp /tmp/proxsave/access_control_rollback_manifest_XXXXXX 2>/dev/null)`,
+ ` if [ -z "$MANIFEST_ALL" ] || [ -z "$MANIFEST" ]; then`,
+ ` echo "[WARN] mktemp failed - skipping prune phase"`,
+ ` exit 0`,
+ ` fi`,
+ ` if ! tar -tzf "$BACKUP" > "$MANIFEST_ALL"; then`,
+ ` echo "[WARN] Failed to list rollback archive - skipping prune phase"`,
+ ` rm -f "$MANIFEST_ALL" "$MANIFEST"`,
+ ` exit 0`,
+ ` fi`,
+ ` sed 's#^\\./##' "$MANIFEST_ALL" > "$MANIFEST"`,
+ }
+
+ for _, path := range targets {
+ rel := strings.TrimPrefix(path, "/")
+ lines = append(lines,
+ fmt.Sprintf(" if [ -e %s ]; then", shellQuote(path)),
+ fmt.Sprintf(" if ! grep -Fxq %s \"$MANIFEST\"; then", shellQuote(rel)),
+ fmt.Sprintf(" rm -f -- %s || true", shellQuote(path)),
+ ` fi`,
+ ` fi`,
+ )
+ }
+
+ lines = append(lines,
+ ` rm -f "$MANIFEST_ALL" "$MANIFEST"`,
+ ` ) >> "$LOG" 2>&1 || true`,
+ `fi`,
+ `rm -f "$MARKER" 2>/dev/null || true`,
+ )
+ return strings.Join(lines, "\n") + "\n"
+}
+
diff --git a/internal/orchestrator/restore_coverage_extra_test.go b/internal/orchestrator/restore_coverage_extra_test.go
index 3334729..922a1a6 100644
--- a/internal/orchestrator/restore_coverage_extra_test.go
+++ b/internal/orchestrator/restore_coverage_extra_test.go
@@ -159,7 +159,9 @@ func TestCheckZFSPoolsAfterRestore_ReportsImportablePools(t *testing.T) {
restoreGlob = origGlob
})
- restoreFS = NewFakeFS()
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
restoreGlob = func(pattern string) ([]string, error) { return nil, nil }
fake := &FakeCommandRunner{
@@ -331,6 +333,118 @@ func TestRunSafeClusterApply_AppliesVMStorageAndDatacenterConfigs(t *testing.T)
}
}
+func TestRunSafeClusterApply_AppliesPoolsFromUserCfg(t *testing.T) {
+ origCmd := restoreCmd
+ origFS := restoreFS
+ t.Cleanup(func() {
+ restoreCmd = origCmd
+ restoreFS = origFS
+ })
+ restoreFS = osFS{}
+
+ pathDir := t.TempDir()
+ for _, name := range []string{"pvesh", "pveum"} {
+ binPath := filepath.Join(pathDir, name)
+ if err := os.WriteFile(binPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+ }
+ t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ runner := &recordingRunner{}
+ restoreCmd = runner
+
+ exportRoot := t.TempDir()
+ userCfgPath := filepath.Join(exportRoot, "etc", "pve", "user.cfg")
+ if err := os.MkdirAll(filepath.Dir(userCfgPath), 0o755); err != nil {
+ t.Fatalf("mkdir user.cfg dir: %v", err)
+ }
+ userCfg := strings.Join([]string{
+ "pool: dev",
+ " comment Dev pool",
+ " vms 100,101",
+ " storage local,backup_ext",
+ "",
+ }, "\n")
+ if err := os.WriteFile(userCfgPath, []byte(userCfg), 0o640); err != nil {
+ t.Fatalf("write user.cfg: %v", err)
+ }
+
+ // Prompts:
+ // - Apply pools? yes
+ // - Allow move? no
+ reader := bufio.NewReader(strings.NewReader("yes\nno\n"))
+ if err := runSafeClusterApply(context.Background(), reader, exportRoot, newTestLogger()); err != nil {
+ t.Fatalf("runSafeClusterApply error: %v", err)
+ }
+
+ wantPrefixes := []string{
+ "pveum pool add dev",
+ "pveum pool modify dev --comment Dev pool",
+ "pveum pool modify dev --vms 100,101 --storage backup_ext,local",
+ }
+ for _, prefix := range wantPrefixes {
+ found := false
+ for _, call := range runner.calls {
+ if strings.HasPrefix(call, prefix) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected a call with prefix %q; calls=%#v", prefix, runner.calls)
+ }
+ }
+}
+
+func TestRunSafeClusterApply_AppliesResourceMappingsFromProxsaveInfo(t *testing.T) {
+ origCmd := restoreCmd
+ origFS := restoreFS
+ t.Cleanup(func() {
+ restoreCmd = origCmd
+ restoreFS = origFS
+ })
+ restoreFS = osFS{}
+
+ pathDir := t.TempDir()
+ pveshPath := filepath.Join(pathDir, "pvesh")
+ if err := os.WriteFile(pveshPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write pvesh: %v", err)
+ }
+ t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ runner := &recordingRunner{}
+ restoreCmd = runner
+
+ exportRoot := t.TempDir()
+ mappingPath := filepath.Join(exportRoot, "var", "lib", "proxsave-info", "commands", "pve", "mapping_pci.json")
+ if err := os.MkdirAll(filepath.Dir(mappingPath), 0o755); err != nil {
+ t.Fatalf("mkdir mapping dir: %v", err)
+ }
+ if err := os.WriteFile(mappingPath, []byte(strings.TrimSpace(`[
+ {"id":"device1","comment":"GPU","map":[{"node":"pve01","path":"0000:01:00.0"}]}
+]`)), 0o640); err != nil {
+ t.Fatalf("write mapping_pci.json: %v", err)
+ }
+
+ reader := bufio.NewReader(strings.NewReader("yes\n"))
+ if err := runSafeClusterApply(context.Background(), reader, exportRoot, newTestLogger()); err != nil {
+ t.Fatalf("runSafeClusterApply error: %v", err)
+ }
+
+ wantPrefix := "pvesh create /cluster/mapping/pci --id device1 --comment GPU --map node=pve01,path=0000:01:00.0"
+ found := false
+ for _, call := range runner.calls {
+ if strings.HasPrefix(call, wantPrefix) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected a call with prefix %q; calls=%#v", wantPrefix, runner.calls)
+ }
+}
+
func TestRunSafeClusterApply_UsesSingleExportedNodeWhenHostnameMismatch(t *testing.T) {
origCmd := restoreCmd
origFS := restoreFS
diff --git a/internal/orchestrator/restore_errors_test.go b/internal/orchestrator/restore_errors_test.go
index 20d0c69..d324e4c 100644
--- a/internal/orchestrator/restore_errors_test.go
+++ b/internal/orchestrator/restore_errors_test.go
@@ -21,7 +21,9 @@ import (
func TestAnalyzeBackupCategories_OpenError(t *testing.T) {
orig := restoreFS
defer func() { restoreFS = orig }()
- restoreFS = NewFakeFS()
+ fakeFS := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fakeFS.Root) }()
+ restoreFS = fakeFS
logger := logging.New(logging.GetDefaultLogger().GetLevel(), false)
_, err := AnalyzeBackupCategories("/missing/archive.tar", logger)
diff --git a/internal/orchestrator/restore_filesystem.go b/internal/orchestrator/restore_filesystem.go
index 020a8c6..bb27ce9 100644
--- a/internal/orchestrator/restore_filesystem.go
+++ b/internal/orchestrator/restore_filesystem.go
@@ -4,9 +4,11 @@ import (
"bufio"
"bytes"
"context"
+ "encoding/json"
"fmt"
"os"
"path/filepath"
+ "regexp"
"strings"
"time"
@@ -55,6 +57,16 @@ func SmartMergeFstab(ctx context.Context, logger *logging.Logger, reader *bufio.
return fmt.Errorf("failed to parse backup fstab: %w", err)
}
+ backupRoot := fstabBackupRootFromPath(backupFstabPath)
+ if backupRoot != "" {
+ if remapped, count := remapFstabDevicesFromInventory(logger, backupEntries, backupRoot); count > 0 {
+ backupEntries = remapped
+ logger.Info("Fstab device remap: converted %d entry(ies) from /dev/* to stable UUID/PARTUUID/LABEL based on ProxSave inventory", count)
+ } else {
+ backupEntries = remapped
+ }
+ }
+
// 2. Analysis
analysis := analyzeFstabMerge(logger, currentEntries, backupEntries)
@@ -82,6 +94,368 @@ func SmartMergeFstab(ctx context.Context, logger *logging.Logger, reader *bufio.
return applyFstabMerge(ctx, logger, currentRaw, currentFstabPath, analysis.ProposedMounts, dryRun)
}
+type fstabDeviceIdentity struct {
+ UUID string
+ PartUUID string
+ Label string
+}
+
+type pbsDatastoreInventoryLite struct {
+ Commands map[string]struct {
+ Output string `json:"output"`
+ } `json:"commands"`
+}
+
+type lsblkReport struct {
+ BlockDevices []lsblkDevice `json:"blockdevices"`
+}
+
+type lsblkDevice struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ UUID string `json:"uuid"`
+ PartUUID string `json:"partuuid"`
+ Label string `json:"label"`
+ Children []lsblkDevice `json:"children"`
+}
+
+func fstabBackupRootFromPath(backupFstabPath string) string {
+ p := filepath.Clean(strings.TrimSpace(backupFstabPath))
+ if p == "" || p == "." || p == string(os.PathSeparator) {
+ return ""
+ }
+ return filepath.Dir(filepath.Dir(p))
+}
+
+func remapFstabDevicesFromInventory(logger *logging.Logger, entries []FstabEntry, backupRoot string) ([]FstabEntry, int) {
+ inventory := loadFstabDeviceInventory(logger, backupRoot)
+ if len(inventory) == 0 || len(entries) == 0 {
+ return entries, 0
+ }
+
+ out := make([]FstabEntry, len(entries))
+ copy(out, entries)
+
+ remapped := 0
+ for i := range out {
+ device := strings.TrimSpace(out[i].Device)
+ if !isLikelyUnstableDevicePath(device) {
+ continue
+ }
+
+ id, ok := inventory[filepath.Clean(device)]
+ if !ok {
+ continue
+ }
+
+ for _, candidate := range []struct {
+ prefix string
+ value string
+ }{
+ {prefix: "UUID=", value: id.UUID},
+ {prefix: "PARTUUID=", value: id.PartUUID},
+ {prefix: "LABEL=", value: id.Label},
+ } {
+ if strings.TrimSpace(candidate.value) == "" {
+ continue
+ }
+ newRef := candidate.prefix + strings.TrimSpace(candidate.value)
+ if isVerifiedStableDeviceRef(newRef) {
+ if logger != nil {
+ logger.Debug("[FSTAB_MERGE] Remap device %s -> %s", device, newRef)
+ }
+ out[i].Device = newRef
+ out[i].RawLine = ""
+ remapped++
+ break
+ }
+ }
+ }
+
+ return out, remapped
+}
+
+func loadFstabDeviceInventory(logger *logging.Logger, backupRoot string) map[string]fstabDeviceIdentity {
+ root := filepath.Clean(strings.TrimSpace(backupRoot))
+ if root == "" || root == "." {
+ return nil
+ }
+
+ out := make(map[string]fstabDeviceIdentity)
+
+ merge := func(src map[string]fstabDeviceIdentity) {
+ for dev, id := range src {
+ dev = filepath.Clean(strings.TrimSpace(dev))
+ if dev == "" || dev == "." {
+ continue
+ }
+ existing := out[dev]
+ if existing.UUID == "" {
+ existing.UUID = id.UUID
+ }
+ if existing.PartUUID == "" {
+ existing.PartUUID = id.PartUUID
+ }
+ if existing.Label == "" {
+ existing.Label = id.Label
+ }
+ out[dev] = existing
+ }
+ }
+
+ // Prefer structured lsblk JSON if available.
+ if data, err := restoreFS.ReadFile(filepath.Join(root, "var/lib/proxsave-info/commands/system/lsblk_json.json")); err == nil && len(data) > 0 {
+ merge(parseLsblkJSONInventory(string(data)))
+ }
+ // Then blkid output from system collection.
+ if data, err := restoreFS.ReadFile(filepath.Join(root, "var/lib/proxsave-info/commands/system/blkid.txt")); err == nil && len(data) > 0 {
+ merge(parseBlkidInventory(string(data)))
+ }
+ // Fallback for older PBS backups: datastore inventory embeds blkid/lsblk output.
+ if data, err := restoreFS.ReadFile(filepath.Join(root, "var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json")); err == nil && len(data) > 0 {
+ merge(parsePBSDatastoreInventoryForDevices(logger, string(data)))
+ }
+ // Last resort: plain-text lsblk -f (less reliable, but may still provide UUID/LABEL).
+ if data, err := restoreFS.ReadFile(filepath.Join(root, "var/lib/proxsave-info/commands/system/lsblk.txt")); err == nil && len(data) > 0 {
+ merge(parseLsblkTextInventory(string(data)))
+ }
+
+ return out
+}
+
+func parsePBSDatastoreInventoryForDevices(logger *logging.Logger, content string) map[string]fstabDeviceIdentity {
+ out := make(map[string]fstabDeviceIdentity)
+ trimmed := strings.TrimSpace(content)
+ if trimmed == "" {
+ return out
+ }
+
+ var report pbsDatastoreInventoryLite
+ if err := json.Unmarshal([]byte(trimmed), &report); err != nil {
+ if logger != nil {
+ logger.Debug("[FSTAB_MERGE] Unable to parse pbs_datastore_inventory.json: %v", err)
+ }
+ return out
+ }
+ if report.Commands == nil {
+ return out
+ }
+
+ if blkid := strings.TrimSpace(report.Commands["blkid"].Output); blkid != "" {
+ for dev, id := range parseBlkidInventory(blkid) {
+ out[dev] = id
+ }
+ }
+ if lsblk := strings.TrimSpace(report.Commands["lsblk_json"].Output); lsblk != "" {
+ for dev, id := range parseLsblkJSONInventory(lsblk) {
+ existing := out[dev]
+ if existing.UUID == "" {
+ existing.UUID = id.UUID
+ }
+ if existing.PartUUID == "" {
+ existing.PartUUID = id.PartUUID
+ }
+ if existing.Label == "" {
+ existing.Label = id.Label
+ }
+ out[dev] = existing
+ }
+ }
+
+ return out
+}
+
+func parseLsblkJSONInventory(content string) map[string]fstabDeviceIdentity {
+ out := make(map[string]fstabDeviceIdentity)
+ trimmed := strings.TrimSpace(content)
+ if trimmed == "" {
+ return out
+ }
+
+ var report lsblkReport
+ if err := json.Unmarshal([]byte(trimmed), &report); err != nil {
+ return out
+ }
+
+ var walk func(dev lsblkDevice)
+ walk = func(dev lsblkDevice) {
+ path := strings.TrimSpace(dev.Path)
+ if path == "" && strings.TrimSpace(dev.Name) != "" {
+ path = filepath.Join("/dev", strings.TrimSpace(dev.Name))
+ }
+ path = filepath.Clean(path)
+ if path != "" && path != "." {
+ out[path] = fstabDeviceIdentity{
+ UUID: strings.TrimSpace(dev.UUID),
+ PartUUID: strings.TrimSpace(dev.PartUUID),
+ Label: strings.TrimSpace(dev.Label),
+ }
+ }
+ for _, child := range dev.Children {
+ walk(child)
+ }
+ }
+
+ for _, dev := range report.BlockDevices {
+ walk(dev)
+ }
+
+ return out
+}
+
+var blkidKVRe = regexp.MustCompile(`([A-Za-z0-9_]+)=\"([^\"]*)\"`)
+
+func parseBlkidInventory(content string) map[string]fstabDeviceIdentity {
+ out := make(map[string]fstabDeviceIdentity)
+ for _, line := range strings.Split(content, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ // Example: /dev/sdb1: UUID="..." TYPE="ext4" PARTUUID="..."
+ colon := strings.Index(line, ":")
+ if colon <= 0 {
+ continue
+ }
+
+ device := strings.TrimSpace(line[:colon])
+ rest := strings.TrimSpace(line[colon+1:])
+ if device == "" || rest == "" {
+ continue
+ }
+
+ id := fstabDeviceIdentity{}
+ for _, match := range blkidKVRe.FindAllStringSubmatch(rest, -1) {
+ if len(match) != 3 {
+ continue
+ }
+ key := strings.ToUpper(strings.TrimSpace(match[1]))
+ val := strings.TrimSpace(match[2])
+ switch key {
+ case "UUID":
+ id.UUID = val
+ case "PARTUUID":
+ id.PartUUID = val
+ case "LABEL":
+ id.Label = val
+ }
+ }
+
+ if id.UUID == "" && id.PartUUID == "" && id.Label == "" {
+ continue
+ }
+
+ out[filepath.Clean(device)] = id
+ }
+ return out
+}
+
+func parseLsblkTextInventory(content string) map[string]fstabDeviceIdentity {
+ out := make(map[string]fstabDeviceIdentity)
+ lines := strings.Split(content, "\n")
+ headerIdx := -1
+ var headerFields []string
+ for i, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ headerFields = strings.Fields(line)
+ if len(headerFields) >= 2 && strings.EqualFold(headerFields[0], "NAME") {
+ headerIdx = i
+ break
+ }
+ }
+ if headerIdx == -1 || len(headerFields) == 0 {
+ return out
+ }
+
+ uuidCol := -1
+ labelCol := -1
+ for i, field := range headerFields {
+ switch strings.ToUpper(strings.TrimSpace(field)) {
+ case "UUID":
+ uuidCol = i
+ case "LABEL":
+ labelCol = i
+ }
+ }
+ if uuidCol == -1 && labelCol == -1 {
+ return out
+ }
+
+ for _, raw := range lines[headerIdx+1:] {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ fields := strings.Fields(raw)
+ if len(fields) == 0 {
+ continue
+ }
+
+ name := sanitizeLsblkName(fields[0])
+ if name == "" {
+ continue
+ }
+ path := filepath.Join("/dev", name)
+
+ id := fstabDeviceIdentity{}
+ if uuidCol >= 0 && uuidCol < len(fields) {
+ id.UUID = strings.TrimSpace(fields[uuidCol])
+ }
+ if labelCol >= 0 && labelCol < len(fields) {
+ id.Label = strings.TrimSpace(fields[labelCol])
+ }
+ if id.UUID == "" && id.Label == "" {
+ continue
+ }
+ out[filepath.Clean(path)] = id
+ }
+
+ return out
+}
+
+func sanitizeLsblkName(field string) string {
+ s := strings.TrimSpace(field)
+ if s == "" {
+ return ""
+ }
+
+ // Drop any tree prefix runes (├─, └─, │, etc.) by finding the first ASCII alnum.
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
+ return s[i:]
+ }
+ }
+ return ""
+}
+
+func isLikelyUnstableDevicePath(device string) bool {
+ dev := strings.TrimSpace(device)
+ if !strings.HasPrefix(dev, "/dev/") {
+ return false
+ }
+ if strings.HasPrefix(dev, "/dev/disk/by-") || strings.HasPrefix(dev, "/dev/mapper/") {
+ return false
+ }
+
+ base := filepath.Base(dev)
+ switch {
+ case strings.HasPrefix(base, "sd"),
+ strings.HasPrefix(base, "vd"),
+ strings.HasPrefix(base, "xvd"),
+ strings.HasPrefix(base, "hd"),
+ strings.HasPrefix(base, "nvme"),
+ strings.HasPrefix(base, "mmcblk"):
+ return true
+ default:
+ return false
+ }
+}
+
func parseFstab(path string) ([]FstabEntry, []string, error) {
content, err := restoreFS.ReadFile(path)
if err != nil {
@@ -282,6 +656,48 @@ func isSafeMountCandidate(e FstabEntry) bool {
return isVerifiedStableDeviceRef(e.Device)
}
+func normalizeFstabEntryForRestore(e FstabEntry) FstabEntry {
+ e.Options = normalizeFstabOptionsForRestore(e.Options)
+ if isNetworkMountEntry(e) {
+ e.Options = ensureFstabOption(e.Options, "_netdev")
+ }
+ e.Options = ensureFstabOption(e.Options, "nofail")
+
+ if strings.TrimSpace(e.Dump) == "" {
+ e.Dump = "0"
+ }
+ if strings.TrimSpace(e.Pass) == "" {
+ e.Pass = "0"
+ }
+ return e
+}
+
+func normalizeFstabOptionsForRestore(options string) string {
+ opts := strings.TrimSpace(options)
+ if opts == "" {
+ return "defaults"
+ }
+ return opts
+}
+
+func ensureFstabOption(options, option string) string {
+ opts := strings.TrimSpace(options)
+ opt := strings.TrimSpace(option)
+ if opt == "" {
+ return opts
+ }
+ if opts == "" {
+ return opt
+ }
+
+ for _, part := range strings.Split(opts, ",") {
+ if strings.TrimSpace(part) == opt {
+ return opts
+ }
+ }
+ return opts + "," + opt
+}
+
func printFstabAnalysis(logger *logging.Logger, res FstabAnalysisResult) {
fmt.Println()
logger.Info("fstab analysis:")
@@ -353,12 +769,9 @@ func applyFstabMerge(ctx context.Context, logger *logging.Logger, currentRaw []s
buffer.WriteString("\n# --- ProxSave Restore Merge ---\n")
for _, e := range newEntries {
- if e.RawLine != "" {
- buffer.WriteString(e.RawLine + "\n")
- } else {
- line := fmt.Sprintf("%-36s %-20s %-8s %-16s %s %s", e.Device, e.MountPoint, e.Type, e.Options, e.Dump, e.Pass)
- buffer.WriteString(line + "\n")
- }
+ e = normalizeFstabEntryForRestore(e)
+ line := fmt.Sprintf("%-36s %-20s %-8s %-16s %s %s", e.Device, e.MountPoint, e.Type, e.Options, e.Dump, e.Pass)
+ buffer.WriteString(line + "\n")
}
// 3. Atomic write (temp file + rename)
diff --git a/internal/orchestrator/restore_filesystem_test.go b/internal/orchestrator/restore_filesystem_test.go
index acf9702..97d8a44 100644
--- a/internal/orchestrator/restore_filesystem_test.go
+++ b/internal/orchestrator/restore_filesystem_test.go
@@ -133,7 +133,7 @@ func TestSmartMergeFstab_DefaultYesOnMatch_BlankApplies(t *testing.T) {
if err != nil {
t.Fatalf("ReadFile current: %v", err)
}
- if !strings.Contains(string(got), "ProxSave Restore Merge") || !strings.Contains(string(got), "server:/export /mnt/nas") {
+ if !strings.Contains(string(got), "ProxSave Restore Merge") || !strings.Contains(string(got), "server:/export") || !strings.Contains(string(got), "/mnt/nas") {
t.Fatalf("expected merged fstab to include marker and mount, got:\n%s", string(got))
}
@@ -198,6 +198,65 @@ func TestSmartMergeFstab_DryRunDoesNotWrite(t *testing.T) {
}
}
+func TestSmartMergeFstab_RemapsUnstableDeviceToUUIDWhenInventoryMatches(t *testing.T) {
+ origFS := restoreFS
+ origCmd := restoreCmd
+ origTime := restoreTime
+ t.Cleanup(func() {
+ restoreFS = origFS
+ restoreCmd = origCmd
+ restoreTime = origTime
+ })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+ restoreCmd = &FakeCommandRunner{}
+ restoreTime = &FakeTime{Current: time.Date(2026, 1, 20, 12, 34, 56, 0, time.UTC)}
+
+ // Simulate target device presence (stable ref).
+ if err := fakeFS.AddDir("/dev/disk/by-uuid"); err != nil {
+ t.Fatalf("AddDir: %v", err)
+ }
+ if err := fakeFS.AddFile("/dev/disk/by-uuid/data-uuid", []byte("")); err != nil {
+ t.Fatalf("AddFile: %v", err)
+ }
+
+ currentPath := "/etc/fstab"
+ backupPath := "/backup/etc/fstab"
+ if err := fakeFS.AddFile(currentPath, []byte("UUID=same-root / ext4 defaults 0 1\nUUID=same-swap none swap sw 0 0\n")); err != nil {
+ t.Fatalf("AddFile current: %v", err)
+ }
+ if err := fakeFS.AddFile(backupPath, []byte("UUID=same-root / ext4 defaults 0 1\nUUID=same-swap none swap sw 0 0\n/dev/sdb1 /mnt/data ext4 defaults 0 2\n")); err != nil {
+ t.Fatalf("AddFile backup: %v", err)
+ }
+
+ // Backup inventory maps /dev/sdb1 to UUID=data-uuid.
+ invPath := "/backup/var/lib/proxsave-info/commands/system/blkid.txt"
+ if err := fakeFS.AddFile(invPath, []byte("/dev/sdb1: UUID=\"data-uuid\" TYPE=\"ext4\"\n")); err != nil {
+ t.Fatalf("AddFile inventory: %v", err)
+ }
+
+ reader := bufio.NewReader(strings.NewReader("\n")) // blank -> defaultYes on match
+ if err := SmartMergeFstab(context.Background(), newTestLogger(), reader, currentPath, backupPath, false); err != nil {
+ t.Fatalf("SmartMergeFstab error: %v", err)
+ }
+
+ got, err := fakeFS.ReadFile(currentPath)
+ if err != nil {
+ t.Fatalf("ReadFile current: %v", err)
+ }
+ if strings.Contains(string(got), "/dev/sdb1") {
+ t.Fatalf("expected /dev/sdb1 to be remapped, got:\n%s", string(got))
+ }
+ if !strings.Contains(string(got), "UUID=data-uuid") || !strings.Contains(string(got), "/mnt/data") {
+ t.Fatalf("expected remapped mount entry to be present, got:\n%s", string(got))
+ }
+ if !strings.Contains(string(got), "nofail") {
+ t.Fatalf("expected nofail to be set on restored entry, got:\n%s", string(got))
+ }
+}
+
func TestExtractArchiveNative_SkipFnSkipsFstab(t *testing.T) {
origFS := restoreFS
t.Cleanup(func() { restoreFS = origFS })
diff --git a/internal/orchestrator/restore_firewall.go b/internal/orchestrator/restore_firewall.go
new file mode 100644
index 0000000..91b7f4d
--- /dev/null
+++ b/internal/orchestrator/restore_firewall.go
@@ -0,0 +1,760 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+const defaultFirewallRollbackTimeout = 180 * time.Second
+
+var ErrFirewallApplyNotCommitted = errors.New("firewall configuration not committed")
+
+type FirewallApplyNotCommittedError struct {
+ RollbackLog string
+ RollbackMarker string
+ RollbackArmed bool
+ RollbackDeadline time.Time
+}
+
+func (e *FirewallApplyNotCommittedError) Error() string {
+ if e == nil {
+ return ErrFirewallApplyNotCommitted.Error()
+ }
+ return ErrFirewallApplyNotCommitted.Error()
+}
+
+func (e *FirewallApplyNotCommittedError) Unwrap() error {
+ return ErrFirewallApplyNotCommitted
+}
+
+type firewallRollbackHandle struct {
+ workDir string
+ markerPath string
+ unitName string
+ scriptPath string
+ logPath string
+ armedAt time.Time
+ timeout time.Duration
+}
+
+func (h *firewallRollbackHandle) remaining(now time.Time) time.Duration {
+ if h == nil {
+ return 0
+ }
+ rem := h.timeout - now.Sub(h.armedAt)
+ if rem < 0 {
+ return 0
+ }
+ return rem
+}
+
+func buildFirewallApplyNotCommittedError(handle *firewallRollbackHandle) *FirewallApplyNotCommittedError {
+ rollbackArmed := false
+ rollbackMarker := ""
+ rollbackLog := ""
+ var rollbackDeadline time.Time
+ if handle != nil {
+ rollbackMarker = strings.TrimSpace(handle.markerPath)
+ rollbackLog = strings.TrimSpace(handle.logPath)
+ if rollbackMarker != "" {
+ if _, err := restoreFS.Stat(rollbackMarker); err == nil {
+ rollbackArmed = true
+ }
+ }
+ rollbackDeadline = handle.armedAt.Add(handle.timeout)
+ }
+
+ return &FirewallApplyNotCommittedError{
+ RollbackLog: rollbackLog,
+ RollbackMarker: rollbackMarker,
+ RollbackArmed: rollbackArmed,
+ RollbackDeadline: rollbackDeadline,
+ }
+}
+
+func maybeApplyPVEFirewallWithUI(
+ ctx context.Context,
+ ui RestoreWorkflowUI,
+ logger *logging.Logger,
+ plan *RestorePlan,
+ safetyBackup, firewallRollbackBackup *SafetyBackupResult,
+ stageRoot string,
+ dryRun bool,
+) (err error) {
+ if plan == nil || plan.SystemType != SystemTypePVE || !plan.HasCategoryID("pve_firewall") {
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "pve firewall restore (ui)", "dryRun=%v stage=%s", dryRun, strings.TrimSpace(stageRoot))
+ defer func() { done(err) }()
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping PVE firewall restore: non-system filesystem in use")
+ return nil
+ }
+ if dryRun {
+ logger.Info("Dry run enabled: skipping PVE firewall restore")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping PVE firewall restore: requires root privileges")
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "pve firewall restore (ui)", "Skipped: staging directory not available")
+ return nil
+ }
+
+ // In cluster RECOVERY mode, config.db restoration owns /etc/pve state and /etc/pve is unmounted during restore.
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "pve firewall restore (ui)", "Skip: cluster RECOVERY restores config.db")
+ return nil
+ }
+
+ etcPVE := "/etc/pve"
+ mounted, mountErr := isMounted(etcPVE)
+ if mountErr != nil {
+ logger.Warning("PVE firewall restore: unable to check pmxcfs mount (%s): %v", etcPVE, mountErr)
+ }
+ if !mounted {
+ logger.Warning("PVE firewall restore: %s is not mounted; skipping firewall apply to avoid shadow writes on root filesystem", etcPVE)
+ return nil
+ }
+
+ stageFirewall := filepath.Join(stageRoot, "etc", "pve", "firewall")
+ stageNodes := filepath.Join(stageRoot, "etc", "pve", "nodes")
+ if _, err := restoreFS.Stat(stageFirewall); err != nil && errors.Is(err, os.ErrNotExist) {
+ if _, err := restoreFS.Stat(stageNodes); err != nil && errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "pve firewall restore (ui)", "Skipped: no firewall data in stage directory")
+ return nil
+ }
+ }
+
+ rollbackPath := ""
+ if firewallRollbackBackup != nil {
+ rollbackPath = strings.TrimSpace(firewallRollbackBackup.BackupPath)
+ }
+ fullRollbackPath := ""
+ if safetyBackup != nil {
+ fullRollbackPath = strings.TrimSpace(safetyBackup.BackupPath)
+ }
+
+ logger.Info("")
+ message := fmt.Sprintf(
+ "PVE firewall restore: configuration is ready to apply.\nSource: %s\n\n"+
+ "WARNING: This may immediately change firewall rules and disconnect SSH/Web sessions.\n\n"+
+ "After applying, confirm within %ds or ProxSave will roll back automatically.\n\n"+
+ "Recommendation: run this step from the local console/IPMI, not over SSH.\n\n"+
+ "Apply PVE firewall configuration now?",
+ strings.TrimSpace(stageRoot),
+ int(defaultFirewallRollbackTimeout.Seconds()),
+ )
+ applyNow, err := ui.ConfirmAction(ctx, "Apply PVE firewall configuration", message, "Apply now", "Skip apply", 90*time.Second, false)
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "pve firewall restore (ui)", "User choice: applyNow=%v", applyNow)
+ if !applyNow {
+ logger.Info("Skipping PVE firewall apply (you can apply manually later).")
+ return nil
+ }
+
+ if rollbackPath == "" && fullRollbackPath != "" {
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "Firewall rollback backup not available",
+ "Firewall rollback backup is not available.\n\nIf you proceed, the rollback timer will use the full safety backup, which may revert other restored categories.\n\nProceed anyway?",
+ "Proceed with full rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "pve firewall restore (ui)", "User choice: allowFullRollback=%v", ok)
+ if !ok {
+ logger.Info("Skipping PVE firewall apply (rollback backup not available).")
+ return nil
+ }
+ rollbackPath = fullRollbackPath
+ }
+
+ if rollbackPath == "" {
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "No rollback available",
+ "No rollback backup is available.\n\nIf you proceed and the firewall locks you out, ProxSave cannot roll back automatically.\n\nProceed anyway?",
+ "Proceed without rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ if !ok {
+ logger.Info("Skipping PVE firewall apply (no rollback available).")
+ return nil
+ }
+ }
+
+ var rollbackHandle *firewallRollbackHandle
+ if rollbackPath != "" {
+ logger.Info("")
+ logger.Info("Arming firewall rollback timer (%ds)...", int(defaultFirewallRollbackTimeout.Seconds()))
+ rollbackHandle, err = armFirewallRollback(ctx, logger, rollbackPath, defaultFirewallRollbackTimeout, "/tmp/proxsave")
+ if err != nil {
+ return fmt.Errorf("arm firewall rollback: %w", err)
+ }
+ logger.Info("Firewall rollback log: %s", rollbackHandle.logPath)
+ }
+
+ applied, err := applyPVEFirewallFromStage(logger, stageRoot)
+ if err != nil {
+ return err
+ }
+ if len(applied) == 0 {
+ logger.Info("PVE firewall restore: no changes applied (stage contained no firewall entries)")
+ if rollbackHandle != nil {
+ disarmFirewallRollback(ctx, logger, rollbackHandle)
+ }
+ return nil
+ }
+
+ if err := restartPVEFirewallService(ctx, logger); err != nil {
+ logger.Warning("PVE firewall restore: reload/restart failed: %v", err)
+ }
+
+ if rollbackHandle == nil {
+ logger.Info("PVE firewall restore applied (no rollback timer armed).")
+ return nil
+ }
+
+ remaining := rollbackHandle.remaining(time.Now())
+ if remaining <= 0 {
+ return buildFirewallApplyNotCommittedError(rollbackHandle)
+ }
+
+ logger.Info("")
+ commitMessage := fmt.Sprintf(
+ "PVE firewall configuration has been applied.\n\n"+
+ "If you lose access, ProxSave will roll back automatically in %ds.\n\n"+
+ "Keep firewall changes?",
+ int(remaining.Seconds()),
+ )
+ commit, err := ui.ConfirmAction(ctx, "Commit firewall changes", commitMessage, "Keep", "Rollback", remaining, false)
+ if err != nil {
+ if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) {
+ return err
+ }
+ logger.Warning("Firewall commit prompt failed: %v", err)
+ return buildFirewallApplyNotCommittedError(rollbackHandle)
+ }
+
+ if commit {
+ disarmFirewallRollback(ctx, logger, rollbackHandle)
+ logger.Info("Firewall changes committed.")
+ return nil
+ }
+
+ return buildFirewallApplyNotCommittedError(rollbackHandle)
+}
+
+func applyPVEFirewallFromStage(logger *logging.Logger, stageRoot string) (applied []string, err error) {
+ stageRoot = strings.TrimSpace(stageRoot)
+ done := logging.DebugStart(logger, "pve firewall apply", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ if stageRoot == "" {
+ return nil, nil
+ }
+
+ stageFirewall := filepath.Join(stageRoot, "etc", "pve", "firewall")
+ destFirewall := "/etc/pve/firewall"
+
+ if info, err := restoreFS.Stat(stageFirewall); err == nil {
+ if info.IsDir() {
+ paths, err := syncDirExact(stageFirewall, destFirewall)
+ if err != nil {
+ return applied, err
+ }
+ applied = append(applied, paths...)
+ } else {
+ ok, err := copyFileExact(stageFirewall, destFirewall)
+ if err != nil {
+ return applied, err
+ }
+ if ok {
+ applied = append(applied, destFirewall)
+ }
+ }
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return applied, fmt.Errorf("stat staged firewall config %s: %w", stageFirewall, err)
+ }
+
+ srcHostFW, srcNode, ok, err := selectStageHostFirewall(logger, stageRoot)
+ if err != nil {
+ return applied, err
+ }
+ if ok {
+ currentNode, _ := os.Hostname()
+ currentNode = shortHost(currentNode)
+ if strings.TrimSpace(currentNode) == "" {
+ currentNode = "localhost"
+ }
+ destHostFW := filepath.Join("/etc/pve/nodes", currentNode, "host.fw")
+ ok, err := copyFileExact(srcHostFW, destHostFW)
+ if err != nil {
+ return applied, err
+ }
+ if ok {
+ applied = append(applied, destHostFW)
+ }
+ if srcNode != "" && !strings.EqualFold(srcNode, currentNode) && logger != nil {
+ logger.Warning("PVE firewall: applied host.fw from staged node %s onto current node %s", srcNode, currentNode)
+ }
+ }
+
+ return applied, nil
+}
+
+func selectStageHostFirewall(logger *logging.Logger, stageRoot string) (path string, sourceNode string, ok bool, err error) {
+ currentNode, _ := os.Hostname()
+ currentNode = shortHost(currentNode)
+ if strings.TrimSpace(currentNode) == "" {
+ currentNode = "localhost"
+ }
+
+ stageNodes := filepath.Join(stageRoot, "etc", "pve", "nodes")
+ entries, err := restoreFS.ReadDir(stageNodes)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", "", false, nil
+ }
+ return "", "", false, fmt.Errorf("readdir %s: %w", stageNodes, err)
+ }
+
+ var candidates []string
+ for _, entry := range entries {
+ if entry == nil || !entry.IsDir() {
+ continue
+ }
+ name := strings.TrimSpace(entry.Name())
+ if name == "" {
+ continue
+ }
+ hostFW := filepath.Join(stageNodes, name, "host.fw")
+ if info, err := restoreFS.Stat(hostFW); err == nil && !info.IsDir() {
+ candidates = append(candidates, name)
+ }
+ }
+
+ if len(candidates) == 0 {
+ return "", "", false, nil
+ }
+
+ for _, node := range candidates {
+ if strings.EqualFold(node, currentNode) {
+ return filepath.Join(stageNodes, node, "host.fw"), node, true, nil
+ }
+ }
+
+ if len(candidates) == 1 {
+ node := candidates[0]
+ return filepath.Join(stageNodes, node, "host.fw"), node, true, nil
+ }
+
+ if logger != nil {
+ logger.Warning("PVE firewall: multiple staged host.fw candidates found (%s) but none matches current node %s; skipping host.fw apply", strings.Join(candidates, ", "), currentNode)
+ }
+ return "", "", false, nil
+}
+
+func restartPVEFirewallService(ctx context.Context, logger *logging.Logger) error {
+ timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ if commandAvailable("systemctl") {
+ if _, err := restoreCmd.Run(timeoutCtx, "systemctl", "try-restart", "pve-firewall"); err == nil {
+ return nil
+ }
+ if _, err := restoreCmd.Run(timeoutCtx, "systemctl", "restart", "pve-firewall"); err == nil {
+ return nil
+ }
+ }
+ if commandAvailable("pve-firewall") {
+ if _, err := restoreCmd.Run(timeoutCtx, "pve-firewall", "restart"); err == nil {
+ return nil
+ }
+ }
+ return fmt.Errorf("pve-firewall reload not available")
+}
+
+func armFirewallRollback(ctx context.Context, logger *logging.Logger, backupPath string, timeout time.Duration, workDir string) (handle *firewallRollbackHandle, err error) {
+ done := logging.DebugStart(logger, "arm firewall rollback", "backup=%s timeout=%s workDir=%s", strings.TrimSpace(backupPath), timeout, strings.TrimSpace(workDir))
+ defer func() { done(err) }()
+
+ if strings.TrimSpace(backupPath) == "" {
+ return nil, fmt.Errorf("empty safety backup path")
+ }
+ if timeout <= 0 {
+ return nil, fmt.Errorf("invalid rollback timeout")
+ }
+
+ baseDir := strings.TrimSpace(workDir)
+ perm := os.FileMode(0o755)
+ if baseDir == "" {
+ baseDir = "/tmp/proxsave"
+ } else {
+ perm = 0o700
+ }
+ if err := restoreFS.MkdirAll(baseDir, perm); err != nil {
+ return nil, fmt.Errorf("create rollback directory: %w", err)
+ }
+
+ timestamp := nowRestore().Format("20060102_150405")
+ handle = &firewallRollbackHandle{
+ workDir: baseDir,
+ markerPath: filepath.Join(baseDir, fmt.Sprintf("firewall_rollback_pending_%s", timestamp)),
+ scriptPath: filepath.Join(baseDir, fmt.Sprintf("firewall_rollback_%s.sh", timestamp)),
+ logPath: filepath.Join(baseDir, fmt.Sprintf("firewall_rollback_%s.log", timestamp)),
+ armedAt: time.Now(),
+ timeout: timeout,
+ }
+
+ if err := restoreFS.WriteFile(handle.markerPath, []byte("pending\n"), 0o640); err != nil {
+ return nil, fmt.Errorf("write rollback marker: %w", err)
+ }
+
+ script := buildFirewallRollbackScript(handle.markerPath, backupPath, handle.logPath)
+ if err := restoreFS.WriteFile(handle.scriptPath, []byte(script), 0o640); err != nil {
+ return nil, fmt.Errorf("write rollback script: %w", err)
+ }
+
+ timeoutSeconds := int(timeout.Seconds())
+ if timeoutSeconds < 1 {
+ timeoutSeconds = 1
+ }
+
+ if commandAvailable("systemd-run") {
+ handle.unitName = fmt.Sprintf("proxsave-firewall-rollback-%s", timestamp)
+ args := []string{
+ "--unit=" + handle.unitName,
+ "--on-active=" + fmt.Sprintf("%ds", timeoutSeconds),
+ "/bin/sh",
+ handle.scriptPath,
+ }
+ if output, err := restoreCmd.Run(ctx, "systemd-run", args...); err != nil {
+ logger.Warning("systemd-run failed, falling back to background timer: %v", err)
+ logger.Debug("systemd-run output: %s", strings.TrimSpace(string(output)))
+ handle.unitName = ""
+ }
+ }
+
+ if handle.unitName == "" {
+ cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath)
+ if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil {
+ logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output)))
+ return nil, fmt.Errorf("failed to arm rollback timer: %w", err)
+ }
+ }
+
+ logger.Info("Firewall rollback timer armed (%ds). Work dir: %s (log: %s)", timeoutSeconds, baseDir, handle.logPath)
+ return handle, nil
+}
+
+func disarmFirewallRollback(ctx context.Context, logger *logging.Logger, handle *firewallRollbackHandle) {
+ if handle == nil {
+ return
+ }
+
+ if strings.TrimSpace(handle.markerPath) != "" {
+ if err := restoreFS.Remove(handle.markerPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ logger.Warning("Failed to remove firewall rollback marker %s: %v", handle.markerPath, err)
+ }
+ }
+
+ if strings.TrimSpace(handle.unitName) != "" && commandAvailable("systemctl") {
+ timerUnit := strings.TrimSpace(handle.unitName) + ".timer"
+ _, _ = restoreCmd.Run(ctx, "systemctl", "stop", timerUnit)
+ _, _ = restoreCmd.Run(ctx, "systemctl", "reset-failed", strings.TrimSpace(handle.unitName)+".service", timerUnit)
+ }
+}
+
+func buildFirewallRollbackScript(markerPath, backupPath, logPath string) string {
+ lines := []string{
+ "#!/bin/sh",
+ "set -eu",
+ fmt.Sprintf("LOG=%s", shellQuote(logPath)),
+ fmt.Sprintf("MARKER=%s", shellQuote(markerPath)),
+ fmt.Sprintf("BACKUP=%s", shellQuote(backupPath)),
+ `echo "[INFO] ========================================" >> "$LOG"`,
+ `echo "[INFO] FIREWALL ROLLBACK SCRIPT STARTED" >> "$LOG"`,
+ `echo "[INFO] Timestamp: $(date -Is)" >> "$LOG"`,
+ `echo "[INFO] Marker: $MARKER" >> "$LOG"`,
+ `echo "[INFO] Backup: $BACKUP" >> "$LOG"`,
+ `echo "[INFO] ========================================" >> "$LOG"`,
+ `if [ ! -f "$MARKER" ]; then`,
+ ` echo "[INFO] Marker not found - rollback cancelled (already disarmed)" >> "$LOG"`,
+ ` exit 0`,
+ `fi`,
+ `echo "[INFO] --- EXTRACT PHASE ---" >> "$LOG"`,
+ `TAR_OK=0`,
+ `if tar -xzf "$BACKUP" -C / >> "$LOG" 2>&1; then`,
+ ` TAR_OK=1`,
+ ` echo "[OK] Extract phase completed successfully" >> "$LOG"`,
+ `else`,
+ ` RC=$?`,
+ ` echo "[ERROR] Extract phase failed (exit=$RC) - skipping prune phase" >> "$LOG"`,
+ `fi`,
+ `if [ "$TAR_OK" -eq 1 ] && [ -d /etc/pve/firewall ]; then`,
+ ` echo "[INFO] --- PRUNE PHASE ---" >> "$LOG"`,
+ ` (`,
+ ` set +e`,
+ ` MANIFEST_ALL=$(mktemp /tmp/proxsave/firewall_rollback_manifest_all_XXXXXX 2>/dev/null)`,
+ ` MANIFEST=$(mktemp /tmp/proxsave/firewall_rollback_manifest_XXXXXX 2>/dev/null)`,
+ ` CANDIDATES=$(mktemp /tmp/proxsave/firewall_rollback_candidates_XXXXXX 2>/dev/null)`,
+ ` CLEANUP=$(mktemp /tmp/proxsave/firewall_rollback_cleanup_XXXXXX 2>/dev/null)`,
+ ` if [ -z "$MANIFEST_ALL" ] || [ -z "$MANIFEST" ] || [ -z "$CANDIDATES" ] || [ -z "$CLEANUP" ]; then`,
+ ` echo "[WARN] mktemp failed - skipping prune phase"`,
+ ` exit 0`,
+ ` fi`,
+ ` if ! tar -tzf "$BACKUP" > "$MANIFEST_ALL"; then`,
+ ` echo "[WARN] Failed to list rollback archive - skipping prune phase"`,
+ ` rm -f "$MANIFEST_ALL" "$MANIFEST" "$CANDIDATES" "$CLEANUP"`,
+ ` exit 0`,
+ ` fi`,
+ ` sed 's#^\\./##' "$MANIFEST_ALL" > "$MANIFEST"`,
+ ` find /etc/pve/firewall -mindepth 1 \( -type f -o -type l \) -print > "$CANDIDATES" 2>/dev/null || true`,
+ ` : > "$CLEANUP"`,
+ ` while IFS= read -r path; do`,
+ ` rel=${path#/}`,
+ ` if ! grep -Fxq "$rel" "$MANIFEST"; then`,
+ ` echo "$path" >> "$CLEANUP"`,
+ ` fi`,
+ ` done < "$CANDIDATES"`,
+ ` if [ -s "$CLEANUP" ]; then`,
+ ` while IFS= read -r rmPath; do`,
+ ` rm -f -- "$rmPath" || true`,
+ ` done < "$CLEANUP"`,
+ ` fi`,
+ ` find /etc/pve/nodes -maxdepth 2 -type f -name host.fw -print 2>/dev/null | while IFS= read -r hostfw; do`,
+ ` rel=${hostfw#/}`,
+ ` if ! grep -Fxq "$rel" "$MANIFEST"; then`,
+ ` rm -f -- "$hostfw" || true`,
+ ` fi`,
+ ` done`,
+ ` rm -f "$MANIFEST_ALL" "$MANIFEST" "$CANDIDATES" "$CLEANUP"`,
+ ` ) >> "$LOG" 2>&1 || true`,
+ `fi`,
+ `echo "[INFO] Restart firewall service after rollback" >> "$LOG"`,
+ `if command -v systemctl >/dev/null 2>&1; then`,
+ ` systemctl restart pve-firewall >> "$LOG" 2>&1 || true`,
+ `fi`,
+ `if command -v pve-firewall >/dev/null 2>&1; then`,
+ ` pve-firewall restart >> "$LOG" 2>&1 || true`,
+ `fi`,
+ `rm -f "$MARKER" 2>/dev/null || true`,
+ }
+ return strings.Join(lines, "\n") + "\n"
+}
+
+func copyFileExact(src, dest string) (bool, error) {
+ info, err := restoreFS.Stat(src)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, fmt.Errorf("stat %s: %w", src, err)
+ }
+ if info.IsDir() {
+ return false, nil
+ }
+
+ data, err := restoreFS.ReadFile(src)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, fmt.Errorf("read %s: %w", src, err)
+ }
+
+ mode := os.FileMode(0o644)
+ if info != nil {
+ mode = info.Mode().Perm()
+ }
+ if err := writeFileAtomic(dest, data, mode); err != nil {
+ return false, fmt.Errorf("write %s: %w", dest, err)
+ }
+ return true, nil
+}
+
+func syncDirExact(srcDir, destDir string) ([]string, error) {
+ info, err := restoreFS.Stat(srcDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("stat %s: %w", srcDir, err)
+ }
+ if !info.IsDir() {
+ return nil, nil
+ }
+
+ if err := restoreFS.MkdirAll(destDir, 0o755); err != nil {
+ return nil, fmt.Errorf("mkdir %s: %w", destDir, err)
+ }
+
+ stageFiles := make(map[string]struct{})
+ stageDirs := make(map[string]struct{})
+
+ var applied []string
+
+ var walkStage func(path string) error
+ walkStage = func(path string) error {
+ entries, err := restoreFS.ReadDir(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("readdir %s: %w", path, err)
+ }
+ for _, entry := range entries {
+ if entry == nil {
+ continue
+ }
+ name := strings.TrimSpace(entry.Name())
+ if name == "" {
+ continue
+ }
+ src := filepath.Join(path, name)
+ rel, relErr := filepath.Rel(srcDir, src)
+ if relErr != nil {
+ return fmt.Errorf("rel %s: %w", src, relErr)
+ }
+ rel = filepath.ToSlash(filepath.Clean(rel))
+ if rel == "." || strings.HasPrefix(rel, "../") {
+ continue
+ }
+ dest := filepath.Join(destDir, filepath.FromSlash(rel))
+
+ info, infoErr := entry.Info()
+ if infoErr != nil {
+ return fmt.Errorf("stat %s: %w", src, infoErr)
+ }
+
+ if info.Mode()&os.ModeSymlink != 0 {
+ stageFiles[rel] = struct{}{}
+ target, err := restoreFS.Readlink(src)
+ if err != nil {
+ return fmt.Errorf("readlink %s: %w", src, err)
+ }
+ if err := restoreFS.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err)
+ }
+ _ = restoreFS.Remove(dest)
+ if err := restoreFS.Symlink(target, dest); err != nil {
+ return fmt.Errorf("symlink %s -> %s: %w", dest, target, err)
+ }
+ applied = append(applied, dest)
+ continue
+ }
+
+ if info.IsDir() {
+ stageDirs[rel] = struct{}{}
+ if err := restoreFS.MkdirAll(dest, 0o755); err != nil {
+ return fmt.Errorf("mkdir %s: %w", dest, err)
+ }
+ if err := walkStage(src); err != nil {
+ return err
+ }
+ continue
+ }
+
+ stageFiles[rel] = struct{}{}
+ ok, err := copyFileExact(src, dest)
+ if err != nil {
+ return err
+ }
+ if ok {
+ applied = append(applied, dest)
+ }
+ }
+ return nil
+ }
+
+ if err := walkStage(srcDir); err != nil {
+ return applied, err
+ }
+
+ var pruneDest func(path string) error
+ pruneDest = func(path string) error {
+ entries, err := restoreFS.ReadDir(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("readdir %s: %w", path, err)
+ }
+ for _, entry := range entries {
+ if entry == nil {
+ continue
+ }
+ name := strings.TrimSpace(entry.Name())
+ if name == "" {
+ continue
+ }
+ dest := filepath.Join(path, name)
+ rel, relErr := filepath.Rel(destDir, dest)
+ if relErr != nil {
+ return fmt.Errorf("rel %s: %w", dest, relErr)
+ }
+ rel = filepath.ToSlash(filepath.Clean(rel))
+ if rel == "." || strings.HasPrefix(rel, "../") {
+ continue
+ }
+
+ info, infoErr := entry.Info()
+ if infoErr != nil {
+ return fmt.Errorf("stat %s: %w", dest, infoErr)
+ }
+
+ if info.IsDir() {
+ if err := pruneDest(dest); err != nil {
+ return err
+ }
+ // Best-effort: remove empty dirs that are not present in stage.
+ if _, keep := stageDirs[rel]; !keep {
+ _ = restoreFS.Remove(dest)
+ }
+ continue
+ }
+
+ if _, keep := stageFiles[rel]; keep {
+ continue
+ }
+ if err := restoreFS.Remove(dest); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove %s: %w", dest, err)
+ }
+ }
+ return nil
+ }
+
+ if err := pruneDest(destDir); err != nil {
+ return applied, err
+ }
+
+ return applied, nil
+}
diff --git a/internal/orchestrator/restore_firewall_test.go b/internal/orchestrator/restore_firewall_test.go
new file mode 100644
index 0000000..5ce76d5
--- /dev/null
+++ b/internal/orchestrator/restore_firewall_test.go
@@ -0,0 +1,133 @@
+package orchestrator
+
+import (
+ "os"
+ "testing"
+)
+
+func TestSyncDirExact_PrunesExtraneousFiles(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ restoreFS = fakeFS
+
+ stage := "/stage/etc/pve/firewall"
+ dest := "/etc/pve/firewall"
+
+ if err := fakeFS.AddFile(stage+"/cluster.fw", []byte("new\n")); err != nil {
+ t.Fatalf("add staged cluster.fw: %v", err)
+ }
+ if err := fakeFS.AddFile(stage+"/vm/100.fw", []byte("vm\n")); err != nil {
+ t.Fatalf("add staged vm/100.fw: %v", err)
+ }
+
+ if err := fakeFS.AddFile(dest+"/cluster.fw", []byte("old\n")); err != nil {
+ t.Fatalf("add dest cluster.fw: %v", err)
+ }
+ if err := fakeFS.AddFile(dest+"/old.fw", []byte("remove\n")); err != nil {
+ t.Fatalf("add dest old.fw: %v", err)
+ }
+
+ if _, err := syncDirExact(stage, dest); err != nil {
+ t.Fatalf("syncDirExact error: %v", err)
+ }
+
+ data, err := fakeFS.ReadFile(dest + "/cluster.fw")
+ if err != nil {
+ t.Fatalf("read dest cluster.fw: %v", err)
+ }
+ if string(data) != "new\n" {
+ t.Fatalf("unexpected cluster.fw content: %q", string(data))
+ }
+
+ if _, err := fakeFS.Stat(dest + "/old.fw"); err == nil || !os.IsNotExist(err) {
+ t.Fatalf("expected old.fw to be removed; stat err=%v", err)
+ }
+}
+
+func TestApplyPVEFirewallFromStage_AppliesFirewallAndHostFW(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ restoreFS = fakeFS
+
+ node, _ := os.Hostname()
+ node = shortHost(node)
+ if node == "" {
+ node = "localhost"
+ }
+
+ stageRoot := "/stage"
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/firewall/cluster.fw", []byte("cluster\n")); err != nil {
+ t.Fatalf("add staged firewall: %v", err)
+ }
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/nodes/"+node+"/host.fw", []byte("host\n")); err != nil {
+ t.Fatalf("add staged host.fw: %v", err)
+ }
+
+ if err := fakeFS.AddFile("/etc/pve/firewall/old.fw", []byte("remove\n")); err != nil {
+ t.Fatalf("add existing old.fw: %v", err)
+ }
+
+ applied, err := applyPVEFirewallFromStage(newTestLogger(), stageRoot)
+ if err != nil {
+ t.Fatalf("applyPVEFirewallFromStage error: %v", err)
+ }
+ if len(applied) == 0 {
+ t.Fatalf("expected applied paths, got none")
+ }
+
+ if _, err := fakeFS.Stat("/etc/pve/firewall/old.fw"); err == nil || !os.IsNotExist(err) {
+ t.Fatalf("expected old.fw to be removed; stat err=%v", err)
+ }
+
+ data, err := fakeFS.ReadFile("/etc/pve/firewall/cluster.fw")
+ if err != nil {
+ t.Fatalf("read cluster.fw: %v", err)
+ }
+ if string(data) != "cluster\n" {
+ t.Fatalf("unexpected cluster.fw content: %q", string(data))
+ }
+
+ data, err = fakeFS.ReadFile("/etc/pve/nodes/" + node + "/host.fw")
+ if err != nil {
+ t.Fatalf("read host.fw: %v", err)
+ }
+ if string(data) != "host\n" {
+ t.Fatalf("unexpected host.fw content: %q", string(data))
+ }
+}
+
+func TestApplyPVEFirewallFromStage_MapsSingleHostFWCandidate(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ restoreFS = fakeFS
+
+ node, _ := os.Hostname()
+ node = shortHost(node)
+ if node == "" {
+ node = "localhost"
+ }
+
+ stageRoot := "/stage"
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/nodes/othernode/host.fw", []byte("host\n")); err != nil {
+ t.Fatalf("add staged host.fw: %v", err)
+ }
+
+ _, err := applyPVEFirewallFromStage(newTestLogger(), stageRoot)
+ if err != nil {
+ t.Fatalf("applyPVEFirewallFromStage error: %v", err)
+ }
+
+ data, err := fakeFS.ReadFile("/etc/pve/nodes/" + node + "/host.fw")
+ if err != nil {
+ t.Fatalf("read mapped host.fw: %v", err)
+ }
+ if string(data) != "host\n" {
+ t.Fatalf("unexpected mapped host.fw content: %q", string(data))
+ }
+}
diff --git a/internal/orchestrator/restore_ha.go b/internal/orchestrator/restore_ha.go
new file mode 100644
index 0000000..a6062a2
--- /dev/null
+++ b/internal/orchestrator/restore_ha.go
@@ -0,0 +1,491 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+const defaultHARollbackTimeout = 180 * time.Second
+
+var ErrHAApplyNotCommitted = errors.New("HA configuration not committed")
+
+type HAApplyNotCommittedError struct {
+ RollbackLog string
+ RollbackMarker string
+ RollbackArmed bool
+ RollbackDeadline time.Time
+}
+
+func (e *HAApplyNotCommittedError) Error() string {
+ if e == nil {
+ return ErrHAApplyNotCommitted.Error()
+ }
+ return ErrHAApplyNotCommitted.Error()
+}
+
+func (e *HAApplyNotCommittedError) Unwrap() error {
+ return ErrHAApplyNotCommitted
+}
+
+type haRollbackHandle struct {
+ workDir string
+ markerPath string
+ unitName string
+ scriptPath string
+ logPath string
+ armedAt time.Time
+ timeout time.Duration
+}
+
+func (h *haRollbackHandle) remaining(now time.Time) time.Duration {
+ if h == nil {
+ return 0
+ }
+ rem := h.timeout - now.Sub(h.armedAt)
+ if rem < 0 {
+ return 0
+ }
+ return rem
+}
+
+func buildHAApplyNotCommittedError(handle *haRollbackHandle) *HAApplyNotCommittedError {
+ rollbackArmed := false
+ rollbackMarker := ""
+ rollbackLog := ""
+ var rollbackDeadline time.Time
+ if handle != nil {
+ rollbackMarker = strings.TrimSpace(handle.markerPath)
+ rollbackLog = strings.TrimSpace(handle.logPath)
+ if rollbackMarker != "" {
+ if _, err := restoreFS.Stat(rollbackMarker); err == nil {
+ rollbackArmed = true
+ }
+ }
+ rollbackDeadline = handle.armedAt.Add(handle.timeout)
+ }
+
+ return &HAApplyNotCommittedError{
+ RollbackLog: rollbackLog,
+ RollbackMarker: rollbackMarker,
+ RollbackArmed: rollbackArmed,
+ RollbackDeadline: rollbackDeadline,
+ }
+}
+
+func maybeApplyPVEHAWithUI(
+ ctx context.Context,
+ ui RestoreWorkflowUI,
+ logger *logging.Logger,
+ plan *RestorePlan,
+ safetyBackup, haRollbackBackup *SafetyBackupResult,
+ stageRoot string,
+ dryRun bool,
+) (err error) {
+ if plan == nil || plan.SystemType != SystemTypePVE || !plan.HasCategoryID("pve_ha") {
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "pve ha restore (ui)", "dryRun=%v stage=%s", dryRun, strings.TrimSpace(stageRoot))
+ defer func() { done(err) }()
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping PVE HA restore: non-system filesystem in use")
+ return nil
+ }
+ if dryRun {
+ logger.Info("Dry run enabled: skipping PVE HA restore")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping PVE HA restore: requires root privileges")
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "pve ha restore (ui)", "Skipped: staging directory not available")
+ return nil
+ }
+
+ // In cluster RECOVERY mode, config.db restoration owns /etc/pve state and /etc/pve is unmounted during restore.
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "pve ha restore (ui)", "Skip: cluster RECOVERY restores config.db")
+ return nil
+ }
+
+ stageHasHA, err := stageHasPVEHAConfig(stageRoot)
+ if err != nil {
+ return err
+ }
+ if !stageHasHA {
+ logging.DebugStep(logger, "pve ha restore (ui)", "Skipped: no HA config files in stage directory")
+ return nil
+ }
+
+ etcPVE := "/etc/pve"
+ mounted, mountErr := isMounted(etcPVE)
+ if mountErr != nil {
+ logger.Warning("PVE HA restore: unable to check pmxcfs mount (%s): %v", etcPVE, mountErr)
+ }
+ if !mounted {
+ logger.Warning("PVE HA restore: %s is not mounted; skipping HA apply to avoid shadow writes on root filesystem", etcPVE)
+ return nil
+ }
+
+ rollbackPath := ""
+ if haRollbackBackup != nil {
+ rollbackPath = strings.TrimSpace(haRollbackBackup.BackupPath)
+ }
+ fullRollbackPath := ""
+ if safetyBackup != nil {
+ fullRollbackPath = strings.TrimSpace(safetyBackup.BackupPath)
+ }
+
+ logger.Info("")
+ message := fmt.Sprintf(
+ "PVE HA restore: configuration is ready to apply.\nSource: %s\n\n"+
+ "WARNING: This may immediately affect HA-managed VMs/CTs (start/stop/migrate) cluster-wide.\n\n"+
+ "Rollback will restore HA config files, but cannot undo actions already taken by the HA manager.\n\n"+
+ "After applying, confirm within %ds or ProxSave will roll back automatically.\n\n"+
+ "Apply PVE HA configuration now?",
+ strings.TrimSpace(stageRoot),
+ int(defaultHARollbackTimeout.Seconds()),
+ )
+ applyNow, err := ui.ConfirmAction(ctx, "Apply PVE HA configuration", message, "Apply now", "Skip apply", 90*time.Second, false)
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "pve ha restore (ui)", "User choice: applyNow=%v", applyNow)
+ if !applyNow {
+ logger.Info("Skipping PVE HA apply (you can apply manually later).")
+ return nil
+ }
+
+ if rollbackPath == "" && fullRollbackPath != "" {
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "HA rollback backup not available",
+ "HA rollback backup is not available.\n\nIf you proceed, the rollback timer will use the full safety backup, which may revert other restored categories.\n\nProceed anyway?",
+ "Proceed with full rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ logging.DebugStep(logger, "pve ha restore (ui)", "User choice: allowFullRollback=%v", ok)
+ if !ok {
+ logger.Info("Skipping PVE HA apply (rollback backup not available).")
+ return nil
+ }
+ rollbackPath = fullRollbackPath
+ }
+
+ if rollbackPath == "" {
+ ok, err := ui.ConfirmAction(
+ ctx,
+ "No rollback available",
+ "No rollback backup is available.\n\nIf you proceed and the HA configuration causes disruption, ProxSave cannot roll back automatically.\n\nProceed anyway?",
+ "Proceed without rollback",
+ "Skip apply",
+ 0,
+ false,
+ )
+ if err != nil {
+ return err
+ }
+ if !ok {
+ logger.Info("Skipping PVE HA apply (no rollback available).")
+ return nil
+ }
+ }
+
+ var rollbackHandle *haRollbackHandle
+ if rollbackPath != "" {
+ logger.Info("")
+ logger.Info("Arming HA rollback timer (%ds)...", int(defaultHARollbackTimeout.Seconds()))
+ rollbackHandle, err = armHARollback(ctx, logger, rollbackPath, defaultHARollbackTimeout, "/tmp/proxsave")
+ if err != nil {
+ return fmt.Errorf("arm HA rollback: %w", err)
+ }
+ logger.Info("HA rollback log: %s", rollbackHandle.logPath)
+ }
+
+ applied, err := applyPVEHAFromStage(logger, stageRoot)
+ if err != nil {
+ return err
+ }
+ if len(applied) == 0 {
+ logger.Info("PVE HA restore: no changes applied (stage contained no HA config entries)")
+ if rollbackHandle != nil {
+ disarmHARollback(ctx, logger, rollbackHandle)
+ }
+ return nil
+ }
+
+ if rollbackHandle == nil {
+ logger.Info("PVE HA restore applied (no rollback timer armed).")
+ return nil
+ }
+
+ remaining := rollbackHandle.remaining(time.Now())
+ if remaining <= 0 {
+ return buildHAApplyNotCommittedError(rollbackHandle)
+ }
+
+ logger.Info("")
+ commitMessage := fmt.Sprintf(
+ "PVE HA configuration has been applied.\n\n"+
+ "If needed, ProxSave will roll back automatically in %ds.\n\n"+
+ "Keep HA changes?",
+ int(remaining.Seconds()),
+ )
+ commit, err := ui.ConfirmAction(ctx, "Commit HA changes", commitMessage, "Keep", "Rollback", remaining, false)
+ if err != nil {
+ if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) {
+ return err
+ }
+ logger.Warning("HA commit prompt failed: %v", err)
+ return buildHAApplyNotCommittedError(rollbackHandle)
+ }
+
+ if commit {
+ disarmHARollback(ctx, logger, rollbackHandle)
+ logger.Info("HA changes committed.")
+ return nil
+ }
+
+ return buildHAApplyNotCommittedError(rollbackHandle)
+}
+
+func stageHasPVEHAConfig(stageRoot string) (bool, error) {
+ stageHA := filepath.Join(strings.TrimSpace(stageRoot), "etc", "pve", "ha")
+ candidates := []string{
+ filepath.Join(stageHA, "resources.cfg"),
+ filepath.Join(stageHA, "groups.cfg"),
+ filepath.Join(stageHA, "rules.cfg"),
+ }
+ for _, candidate := range candidates {
+ if _, err := restoreFS.Stat(candidate); err == nil {
+ return true, nil
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return false, fmt.Errorf("stat %s: %w", candidate, err)
+ }
+ }
+ return false, nil
+}
+
+func applyPVEHAFromStage(logger *logging.Logger, stageRoot string) (applied []string, err error) {
+ stageRoot = strings.TrimSpace(stageRoot)
+ done := logging.DebugStart(logger, "pve ha apply", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ if stageRoot == "" {
+ return nil, nil
+ }
+
+ stageHA := filepath.Join(stageRoot, "etc", "pve", "ha")
+ destHA := "/etc/pve/ha"
+ if err := restoreFS.MkdirAll(destHA, 0o755); err != nil {
+ return nil, fmt.Errorf("mkdir %s: %w", destHA, err)
+ }
+
+ // Only prune config files if the stage actually contains HA config.
+ hasAny, err := stageHasPVEHAConfig(stageRoot)
+ if err != nil {
+ return nil, err
+ }
+ if !hasAny {
+ return nil, nil
+ }
+
+ configFiles := []string{"resources.cfg", "groups.cfg", "rules.cfg"}
+ for _, name := range configFiles {
+ src := filepath.Join(stageHA, name)
+ dest := filepath.Join(destHA, name)
+
+ ok, err := copyFileExact(src, dest)
+ if err != nil {
+ return applied, err
+ }
+ if ok {
+ applied = append(applied, dest)
+ continue
+ }
+
+ // Not present in stage -> remove from destination to maintain 1:1 semantics.
+ if err := removeIfExists(dest); err != nil {
+ return applied, fmt.Errorf("remove %s: %w", dest, err)
+ }
+ }
+
+ return applied, nil
+}
+
+func armHARollback(ctx context.Context, logger *logging.Logger, backupPath string, timeout time.Duration, workDir string) (handle *haRollbackHandle, err error) {
+ done := logging.DebugStart(logger, "arm ha rollback", "backup=%s timeout=%s workDir=%s", strings.TrimSpace(backupPath), timeout, strings.TrimSpace(workDir))
+ defer func() { done(err) }()
+
+ if strings.TrimSpace(backupPath) == "" {
+ return nil, fmt.Errorf("empty safety backup path")
+ }
+ if timeout <= 0 {
+ return nil, fmt.Errorf("invalid rollback timeout")
+ }
+
+ baseDir := strings.TrimSpace(workDir)
+ perm := os.FileMode(0o755)
+ if baseDir == "" {
+ baseDir = "/tmp/proxsave"
+ } else {
+ perm = 0o700
+ }
+ if err := restoreFS.MkdirAll(baseDir, perm); err != nil {
+ return nil, fmt.Errorf("create rollback directory: %w", err)
+ }
+
+ timestamp := nowRestore().Format("20060102_150405")
+ handle = &haRollbackHandle{
+ workDir: baseDir,
+ markerPath: filepath.Join(baseDir, fmt.Sprintf("ha_rollback_pending_%s", timestamp)),
+ scriptPath: filepath.Join(baseDir, fmt.Sprintf("ha_rollback_%s.sh", timestamp)),
+ logPath: filepath.Join(baseDir, fmt.Sprintf("ha_rollback_%s.log", timestamp)),
+ armedAt: time.Now(),
+ timeout: timeout,
+ }
+
+ if err := restoreFS.WriteFile(handle.markerPath, []byte("pending\n"), 0o640); err != nil {
+ return nil, fmt.Errorf("write rollback marker: %w", err)
+ }
+
+ script := buildHARollbackScript(handle.markerPath, backupPath, handle.logPath)
+ if err := restoreFS.WriteFile(handle.scriptPath, []byte(script), 0o640); err != nil {
+ return nil, fmt.Errorf("write rollback script: %w", err)
+ }
+
+ timeoutSeconds := int(timeout.Seconds())
+ if timeoutSeconds < 1 {
+ timeoutSeconds = 1
+ }
+
+ if commandAvailable("systemd-run") {
+ handle.unitName = fmt.Sprintf("proxsave-ha-rollback-%s", timestamp)
+ args := []string{
+ "--unit=" + handle.unitName,
+ "--on-active=" + fmt.Sprintf("%ds", timeoutSeconds),
+ "/bin/sh",
+ handle.scriptPath,
+ }
+ if output, err := restoreCmd.Run(ctx, "systemd-run", args...); err != nil {
+ logger.Warning("systemd-run failed, falling back to background timer: %v", err)
+ logger.Debug("systemd-run output: %s", strings.TrimSpace(string(output)))
+ handle.unitName = ""
+ }
+ }
+
+ if handle.unitName == "" {
+ cmd := fmt.Sprintf("nohup sh -c 'sleep %d; /bin/sh %s' >/dev/null 2>&1 &", timeoutSeconds, handle.scriptPath)
+ if output, err := restoreCmd.Run(ctx, "sh", "-c", cmd); err != nil {
+ logger.Debug("Background rollback output: %s", strings.TrimSpace(string(output)))
+ return nil, fmt.Errorf("failed to schedule rollback timer: %w", err)
+ }
+ }
+
+ return handle, nil
+}
+
+func disarmHARollback(ctx context.Context, logger *logging.Logger, handle *haRollbackHandle) {
+ if handle == nil {
+ return
+ }
+ if strings.TrimSpace(handle.markerPath) != "" {
+ _ = restoreFS.Remove(handle.markerPath)
+ }
+ if strings.TrimSpace(handle.unitName) != "" && commandAvailable("systemctl") {
+ timerUnit := strings.TrimSpace(handle.unitName) + ".timer"
+ _, _ = restoreCmd.Run(ctx, "systemctl", "stop", timerUnit)
+ _, _ = restoreCmd.Run(ctx, "systemctl", "reset-failed", strings.TrimSpace(handle.unitName)+".service", timerUnit)
+ }
+ if strings.TrimSpace(handle.scriptPath) != "" {
+ _ = restoreFS.Remove(handle.scriptPath)
+ }
+ if strings.TrimSpace(handle.logPath) != "" && logger != nil {
+ logger.Debug("HA rollback disarmed (log=%s)", strings.TrimSpace(handle.logPath))
+ }
+}
+
+func buildHARollbackScript(markerPath, backupPath, logPath string) string {
+ lines := []string{
+ "#!/bin/sh",
+ "set -eu",
+ fmt.Sprintf("LOG=%s", shellQuote(logPath)),
+ fmt.Sprintf("MARKER=%s", shellQuote(markerPath)),
+ fmt.Sprintf("BACKUP=%s", shellQuote(backupPath)),
+ `echo "[INFO] ========================================" >> "$LOG"`,
+ `echo "[INFO] HA ROLLBACK SCRIPT STARTED" >> "$LOG"`,
+ `echo "[INFO] Timestamp: $(date -Is)" >> "$LOG"`,
+ `echo "[INFO] Marker: $MARKER" >> "$LOG"`,
+ `echo "[INFO] Backup: $BACKUP" >> "$LOG"`,
+ `echo "[INFO] ========================================" >> "$LOG"`,
+ `if [ ! -f "$MARKER" ]; then`,
+ ` echo "[INFO] Marker not found - rollback cancelled (already disarmed)" >> "$LOG"`,
+ ` exit 0`,
+ `fi`,
+ `echo "[INFO] --- EXTRACT PHASE ---" >> "$LOG"`,
+ `TAR_OK=0`,
+ `if tar -xzf "$BACKUP" -C / >> "$LOG" 2>&1; then`,
+ ` TAR_OK=1`,
+ ` echo "[OK] Extract phase completed successfully" >> "$LOG"`,
+ `else`,
+ ` RC=$?`,
+ ` echo "[ERROR] Extract phase failed (exit=$RC) - skipping prune phase" >> "$LOG"`,
+ `fi`,
+ `if [ "$TAR_OK" -eq 1 ] && [ -d /etc/pve/ha ]; then`,
+ ` echo "[INFO] --- PRUNE PHASE ---" >> "$LOG"`,
+ ` (`,
+ ` set +e`,
+ ` MANIFEST_ALL=$(mktemp /tmp/proxsave/ha_rollback_manifest_all_XXXXXX 2>/dev/null)`,
+ ` MANIFEST=$(mktemp /tmp/proxsave/ha_rollback_manifest_XXXXXX 2>/dev/null)`,
+ ` CANDIDATES=$(mktemp /tmp/proxsave/ha_rollback_candidates_XXXXXX 2>/dev/null)`,
+ ` CLEANUP=$(mktemp /tmp/proxsave/ha_rollback_cleanup_XXXXXX 2>/dev/null)`,
+ ` if [ -z "$MANIFEST_ALL" ] || [ -z "$MANIFEST" ] || [ -z "$CANDIDATES" ] || [ -z "$CLEANUP" ]; then`,
+ ` echo "[WARN] mktemp failed - skipping prune phase"`,
+ ` exit 0`,
+ ` fi`,
+ ` if ! tar -tzf "$BACKUP" > "$MANIFEST_ALL"; then`,
+ ` echo "[WARN] Failed to list rollback archive - skipping prune phase"`,
+ ` rm -f "$MANIFEST_ALL" "$MANIFEST" "$CANDIDATES" "$CLEANUP"`,
+ ` exit 0`,
+ ` fi`,
+ ` sed 's#^\\./##' "$MANIFEST_ALL" > "$MANIFEST"`,
+ ` find /etc/pve/ha -maxdepth 1 -type f -name '*.cfg' -print > "$CANDIDATES" 2>/dev/null || true`,
+ ` : > "$CLEANUP"`,
+ ` while IFS= read -r path; do`,
+ ` rel=${path#/}`,
+ ` if ! grep -Fxq "$rel" "$MANIFEST"; then`,
+ ` echo "$path" >> "$CLEANUP"`,
+ ` fi`,
+ ` done < "$CANDIDATES"`,
+ ` if [ -s "$CLEANUP" ]; then`,
+ ` while IFS= read -r rmPath; do`,
+ ` rm -f -- "$rmPath" || true`,
+ ` done < "$CLEANUP"`,
+ ` fi`,
+ ` rm -f "$MANIFEST_ALL" "$MANIFEST" "$CANDIDATES" "$CLEANUP"`,
+ ` ) >> "$LOG" 2>&1 || true`,
+ `fi`,
+ `rm -f "$MARKER" 2>/dev/null || true`,
+ }
+ return strings.Join(lines, "\n") + "\n"
+}
+
diff --git a/internal/orchestrator/restore_ha_test.go b/internal/orchestrator/restore_ha_test.go
new file mode 100644
index 0000000..3622968
--- /dev/null
+++ b/internal/orchestrator/restore_ha_test.go
@@ -0,0 +1,87 @@
+package orchestrator
+
+import (
+ "os"
+ "testing"
+)
+
+func TestApplyPVEHAFromStage_AppliesAndPrunesKnownConfigFiles(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/ha/resources.cfg", []byte("res\n")); err != nil {
+ t.Fatalf("add staged resources.cfg: %v", err)
+ }
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/ha/groups.cfg", []byte("grp\n")); err != nil {
+ t.Fatalf("add staged groups.cfg: %v", err)
+ }
+
+ if err := fakeFS.AddFile("/etc/pve/ha/resources.cfg", []byte("old\n")); err != nil {
+ t.Fatalf("add existing resources.cfg: %v", err)
+ }
+ if err := fakeFS.AddFile("/etc/pve/ha/rules.cfg", []byte("remove\n")); err != nil {
+ t.Fatalf("add existing rules.cfg: %v", err)
+ }
+
+ applied, err := applyPVEHAFromStage(newTestLogger(), stageRoot)
+ if err != nil {
+ t.Fatalf("applyPVEHAFromStage error: %v", err)
+ }
+ if len(applied) == 0 {
+ t.Fatalf("expected applied paths, got none")
+ }
+
+ data, err := fakeFS.ReadFile("/etc/pve/ha/resources.cfg")
+ if err != nil {
+ t.Fatalf("read resources.cfg: %v", err)
+ }
+ if string(data) != "res\n" {
+ t.Fatalf("unexpected resources.cfg content: %q", string(data))
+ }
+
+ data, err = fakeFS.ReadFile("/etc/pve/ha/groups.cfg")
+ if err != nil {
+ t.Fatalf("read groups.cfg: %v", err)
+ }
+ if string(data) != "grp\n" {
+ t.Fatalf("unexpected groups.cfg content: %q", string(data))
+ }
+
+ if _, err := fakeFS.Stat("/etc/pve/ha/rules.cfg"); err == nil || !os.IsNotExist(err) {
+ t.Fatalf("expected rules.cfg to be removed; stat err=%v", err)
+ }
+}
+
+func TestApplyPVEHAFromStage_DoesNotPruneWhenStageMissing(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ restoreFS = fakeFS
+
+ if err := fakeFS.AddFile("/etc/pve/ha/resources.cfg", []byte("keep\n")); err != nil {
+ t.Fatalf("add existing resources.cfg: %v", err)
+ }
+
+ stageRoot := "/stage"
+ applied, err := applyPVEHAFromStage(newTestLogger(), stageRoot)
+ if err != nil {
+ t.Fatalf("applyPVEHAFromStage error: %v", err)
+ }
+ if len(applied) != 0 {
+ t.Fatalf("expected no applied paths, got %d", len(applied))
+ }
+
+ data, err := fakeFS.ReadFile("/etc/pve/ha/resources.cfg")
+ if err != nil {
+ t.Fatalf("read resources.cfg: %v", err)
+ }
+ if string(data) != "keep\n" {
+ t.Fatalf("unexpected resources.cfg content: %q", string(data))
+ }
+}
+
diff --git a/internal/orchestrator/restore_notifications.go b/internal/orchestrator/restore_notifications.go
new file mode 100644
index 0000000..bcc8d88
--- /dev/null
+++ b/internal/orchestrator/restore_notifications.go
@@ -0,0 +1,419 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+type proxmoxNotificationEntry struct {
+ Key string
+ Value string
+}
+
+type proxmoxNotificationSection struct {
+ Type string
+ Name string
+ Entries []proxmoxNotificationEntry
+ RedactFlags []string
+}
+
+func maybeApplyNotificationsFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot string, dryRun bool) (err error) {
+ if plan == nil {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "notifications staged apply", "Skipped: staging directory not available")
+ return nil
+ }
+ if !plan.HasCategoryID("pve_notifications") && !plan.HasCategoryID("pbs_notifications") {
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "notifications staged apply", "dryRun=%v stage=%s", dryRun, stageRoot)
+ defer func() { done(err) }()
+
+ if dryRun {
+ logger.Info("Dry run enabled: skipping staged notifications apply")
+ return nil
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping staged notifications apply: non-system filesystem in use")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping staged notifications apply: requires root privileges")
+ return nil
+ }
+
+ switch plan.SystemType {
+ case SystemTypePBS:
+ if !plan.HasCategoryID("pbs_notifications") {
+ return nil
+ }
+ return applyPBSNotificationsFromStage(ctx, logger, stageRoot)
+ case SystemTypePVE:
+ if !plan.HasCategoryID("pve_notifications") {
+ return nil
+ }
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "notifications staged apply", "Skip PVE notifications apply: cluster RECOVERY restores config.db")
+ return nil
+ }
+ if _, err := restoreCmd.Run(ctx, "which", "pvesh"); err != nil {
+ logger.Warning("pvesh not found; skipping PVE notifications apply")
+ return nil
+ }
+ return applyPVENotificationsFromStage(ctx, logger, stageRoot)
+ default:
+ return nil
+ }
+}
+
+func applyPBSNotificationsFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) error {
+ _ = ctx // reserved for future validation hooks
+
+ paths := []struct {
+ rel string
+ dest string
+ mode os.FileMode
+ }{
+ {
+ rel: "etc/proxmox-backup/notifications.cfg",
+ dest: "/etc/proxmox-backup/notifications.cfg",
+ mode: 0o640,
+ },
+ {
+ rel: "etc/proxmox-backup/notifications-priv.cfg",
+ dest: "/etc/proxmox-backup/notifications-priv.cfg",
+ mode: 0o600,
+ },
+ }
+
+ for _, item := range paths {
+ if err := applyConfigFileFromStage(logger, stageRoot, item.rel, item.dest, item.mode); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func applyPVENotificationsFromStage(ctx context.Context, logger *logging.Logger, stageRoot string) error {
+ cfgPath := filepath.Join(stageRoot, "etc/pve/notifications.cfg")
+ cfgData, err := restoreFS.ReadFile(cfgPath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "pve notifications apply", "Skipped: notifications.cfg not present in staging directory")
+ return nil
+ }
+ return fmt.Errorf("read staged notifications.cfg: %w", err)
+ }
+ cfgRaw := strings.TrimSpace(string(cfgData))
+ if cfgRaw == "" {
+ logging.DebugStep(logger, "pve notifications apply", "Skipped: notifications.cfg is empty")
+ return nil
+ }
+
+ privPath := filepath.Join(stageRoot, "etc/pve/priv/notifications.cfg")
+ privRaw := ""
+ if privData, err := restoreFS.ReadFile(privPath); err == nil {
+ privRaw = strings.TrimSpace(string(privData))
+ }
+
+ cfgSections, err := parseProxmoxNotificationSections(cfgRaw)
+ if err != nil {
+ return fmt.Errorf("parse notifications.cfg: %w", err)
+ }
+ privSections, err := parseProxmoxNotificationSections(privRaw)
+ if err != nil {
+ return fmt.Errorf("parse priv notifications.cfg: %w", err)
+ }
+
+ privByKey := make(map[string][]proxmoxNotificationEntry)
+ privRedactFlagsByKey := make(map[string][]string)
+ for _, s := range privSections {
+ if strings.TrimSpace(s.Type) == "" || strings.TrimSpace(s.Name) == "" {
+ continue
+ }
+ key := fmt.Sprintf("%s:%s", s.Type, s.Name)
+ privByKey[key] = append([]proxmoxNotificationEntry{}, s.Entries...)
+ privRedactFlagsByKey[key] = append([]string(nil), notificationRedactFlagsFromEntries(s.Entries)...)
+ }
+
+ var endpoints []proxmoxNotificationSection
+ var matchers []proxmoxNotificationSection
+ for _, s := range cfgSections {
+ switch strings.TrimSpace(s.Type) {
+ case "smtp", "sendmail", "gotify", "webhook":
+ key := fmt.Sprintf("%s:%s", s.Type, s.Name)
+ if priv, ok := privByKey[key]; ok && len(priv) > 0 {
+ s.Entries = append(s.Entries, priv...)
+ }
+ if redactFlags := privRedactFlagsByKey[key]; len(redactFlags) > 0 {
+ s.RedactFlags = append(s.RedactFlags, redactFlags...)
+ }
+ endpoints = append(endpoints, s)
+ case "matcher":
+ matchers = append(matchers, s)
+ default:
+ logger.Warning("PVE notifications apply: unknown section %q (%s); skipping", s.Type, s.Name)
+ }
+ }
+
+ failed := 0
+ for _, s := range endpoints {
+ if err := applyPVEEndpointSection(ctx, logger, s); err != nil {
+ failed++
+ logger.Warning("PVE notifications apply: endpoint %s:%s: %v", s.Type, s.Name, err)
+ }
+ }
+ for _, s := range matchers {
+ if err := applyPVEMatcherSection(ctx, logger, s); err != nil {
+ failed++
+ logger.Warning("PVE notifications apply: matcher %s: %v", s.Name, err)
+ }
+ }
+
+ if failed > 0 {
+ return fmt.Errorf("PVE notifications apply: %d item(s) failed", failed)
+ }
+ logger.Info("PVE notifications applied: endpoints=%d matchers=%d", len(endpoints), len(matchers))
+ return nil
+}
+
+func applyPVEEndpointSection(ctx context.Context, logger *logging.Logger, section proxmoxNotificationSection) error {
+ typ := strings.TrimSpace(section.Type)
+ name := strings.TrimSpace(section.Name)
+ if typ == "" || name == "" {
+ return fmt.Errorf("invalid endpoint section")
+ }
+ if typ == "matcher" {
+ return fmt.Errorf("endpoint section has matcher type")
+ }
+
+ setPath := fmt.Sprintf("/cluster/notifications/endpoints/%s/%s", typ, name)
+ createPath := fmt.Sprintf("/cluster/notifications/endpoints/%s", typ)
+ args := buildPveshArgs(section.Entries)
+ return applyPveshObject(ctx, logger, setPath, createPath, name, args, notificationRedactFlags(section))
+}
+
+func applyPVEMatcherSection(ctx context.Context, logger *logging.Logger, section proxmoxNotificationSection) error {
+ name := strings.TrimSpace(section.Name)
+ if strings.TrimSpace(section.Type) != "matcher" || name == "" {
+ return fmt.Errorf("invalid matcher section")
+ }
+ setPath := fmt.Sprintf("/cluster/notifications/matchers/%s", name)
+ createPath := "/cluster/notifications/matchers"
+ args := buildPveshArgs(section.Entries)
+ return applyPveshObject(ctx, logger, setPath, createPath, name, args, nil)
+}
+
+func applyPveshObject(ctx context.Context, logger *logging.Logger, setPath, createPath, name string, args []string, redactFlags []string) error {
+ setArgs := append([]string{"set", setPath}, args...)
+ if len(redactFlags) > 0 {
+ if _, err := runPveshSensitive(ctx, logger, setArgs, redactFlags...); err == nil {
+ return nil
+ }
+ } else if err := runPvesh(ctx, logger, setArgs); err == nil {
+ return nil
+ }
+
+ createArgs := []string{"create", createPath, "--name", name}
+ createArgs = append(createArgs, args...)
+ if len(redactFlags) > 0 {
+ _, err := runPveshSensitive(ctx, logger, createArgs, redactFlags...)
+ return err
+ }
+ return runPvesh(ctx, logger, createArgs)
+}
+
+func buildPveshArgs(entries []proxmoxNotificationEntry) []string {
+ if len(entries) == 0 {
+ return nil
+ }
+ args := make([]string, 0, len(entries)*2)
+ for _, entry := range entries {
+ key := strings.TrimSpace(entry.Key)
+ if key == "" || key == "name" || key == "digest" {
+ continue
+ }
+ args = append(args, "--"+key)
+ args = append(args, entry.Value)
+ }
+ return args
+}
+
+func notificationRedactFlagsFromEntries(entries []proxmoxNotificationEntry) []string {
+ if len(entries) == 0 {
+ return nil
+ }
+ var out []string
+ seen := make(map[string]struct{}, len(entries))
+ for _, entry := range entries {
+ key := strings.TrimSpace(entry.Key)
+ if key == "" || key == "name" || key == "digest" {
+ continue
+ }
+ flag := "--" + key
+ if _, ok := seen[flag]; ok {
+ continue
+ }
+ seen[flag] = struct{}{}
+ out = append(out, flag)
+ }
+ return out
+}
+
+func notificationRedactFlags(section proxmoxNotificationSection) []string {
+ out := make([]string, 0, len(section.RedactFlags)+8)
+ seen := make(map[string]struct{}, len(section.RedactFlags)+8)
+ add := func(flag string) {
+ flag = strings.TrimSpace(flag)
+ if flag == "" {
+ return
+ }
+ if _, ok := seen[flag]; ok {
+ return
+ }
+ seen[flag] = struct{}{}
+ out = append(out, flag)
+ }
+
+ for _, flag := range section.RedactFlags {
+ add(flag)
+ }
+
+ // Default set for notification endpoints; protects against secrets accidentally present in non-priv config.
+ for _, flag := range []string{"--password", "--token", "--secret", "--apikey", "--api-key"} {
+ add(flag)
+ }
+
+ // If the config uses alternative key names, still try to redact common secret-like fields.
+ for _, entry := range section.Entries {
+ key := strings.ToLower(strings.TrimSpace(entry.Key))
+ switch key {
+ case "password", "token", "secret", "apikey", "api-key":
+ add("--" + strings.TrimSpace(entry.Key))
+ }
+ }
+
+ return out
+}
+
+func applyConfigFileFromStage(logger *logging.Logger, stageRoot, relPath, destPath string, perm os.FileMode) error {
+ stagePath := filepath.Join(stageRoot, relPath)
+ data, err := restoreFS.ReadFile(stagePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logging.DebugStep(logger, "notifications staged apply file", "Skip %s: not present in staging directory", relPath)
+ return nil
+ }
+ return fmt.Errorf("read staged %s: %w", relPath, err)
+ }
+
+ trimmed := strings.TrimSpace(string(data))
+ if trimmed == "" {
+ logger.Warning("Notifications staged apply: %s is empty; removing %s to avoid Proxmox parse errors", relPath, destPath)
+ return removeIfExists(destPath)
+ }
+ if !pbsConfigHasHeader(trimmed) {
+ logger.Warning("Notifications staged apply: %s does not look like a valid Proxmox config file (missing section header); skipping apply", relPath)
+ return nil
+ }
+
+ if err := restoreFS.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
+ return fmt.Errorf("ensure %s: %w", filepath.Dir(destPath), err)
+ }
+ if err := restoreFS.WriteFile(destPath, []byte(trimmed+"\n"), perm); err != nil {
+ return fmt.Errorf("write %s: %w", destPath, err)
+ }
+ logging.DebugStep(logger, "notifications staged apply file", "Applied %s -> %s", relPath, destPath)
+ return nil
+}
+
+func parseProxmoxNotificationSections(content string) ([]proxmoxNotificationSection, error) {
+ raw := strings.TrimSpace(content)
+ if raw == "" {
+ return nil, nil
+ }
+
+ var out []proxmoxNotificationSection
+ var current *proxmoxNotificationSection
+
+ flush := func() {
+ if current == nil {
+ return
+ }
+ if strings.TrimSpace(current.Type) == "" || strings.TrimSpace(current.Name) == "" {
+ current = nil
+ return
+ }
+ out = append(out, *current)
+ current = nil
+ }
+
+ for _, line := range strings.Split(raw, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+
+ typ, name, ok := parseProxmoxNotificationHeader(trimmed)
+ if ok {
+ flush()
+ current = &proxmoxNotificationSection{Type: typ, Name: name}
+ continue
+ }
+
+ if current == nil {
+ continue
+ }
+
+ key, value := parseProxmoxNotificationKV(trimmed)
+ if strings.TrimSpace(key) == "" {
+ continue
+ }
+ current.Entries = append(current.Entries, proxmoxNotificationEntry{Key: key, Value: value})
+ }
+ flush()
+
+ return out, nil
+}
+
+func parseProxmoxNotificationHeader(line string) (typ, name string, ok bool) {
+ idx := strings.Index(line, ":")
+ if idx <= 0 {
+ return "", "", false
+ }
+ typ = strings.TrimSpace(line[:idx])
+ name = strings.TrimSpace(line[idx+1:])
+ if typ == "" || name == "" {
+ return "", "", false
+ }
+ for _, r := range typ {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case r >= 'A' && r <= 'Z':
+ case r >= '0' && r <= '9':
+ case r == '-' || r == '_':
+ default:
+ return "", "", false
+ }
+ }
+ return typ, name, true
+}
+
+func parseProxmoxNotificationKV(line string) (key, value string) {
+ fields := strings.Fields(line)
+ if len(fields) == 0 {
+ return "", ""
+ }
+ key = strings.TrimSpace(fields[0])
+ value = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), fields[0]))
+ return key, value
+}
diff --git a/internal/orchestrator/restore_notifications_test.go b/internal/orchestrator/restore_notifications_test.go
new file mode 100644
index 0000000..a67d597
--- /dev/null
+++ b/internal/orchestrator/restore_notifications_test.go
@@ -0,0 +1,250 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+)
+
+type interceptRunner struct {
+ calls []commandCall
+}
+
+type commandCall struct {
+ name string
+ args []string
+}
+
+func (r *interceptRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
+ _ = ctx
+ r.calls = append(r.calls, commandCall{name: name, args: append([]string(nil), args...)})
+ if name == "pvesh" && len(args) > 0 && args[0] == "set" {
+ return nil, fmt.Errorf("not found")
+ }
+ return []byte("ok"), nil
+}
+
+func TestParseProxmoxNotificationSections(t *testing.T) {
+ in := `
+# comment
+smtp: example
+ mailto-user root@pam
+ mailto-user admin@pve
+ mailto max@example.com
+ from-address pve1@example.com
+
+matcher: default-matcher
+ target example
+ comment route
+`
+
+ sections, err := parseProxmoxNotificationSections(in)
+ if err != nil {
+ t.Fatalf("parseProxmoxNotificationSections error: %v", err)
+ }
+ if len(sections) != 2 {
+ t.Fatalf("expected 2 sections, got %d", len(sections))
+ }
+
+ if sections[0].Type != "smtp" || sections[0].Name != "example" {
+ t.Fatalf("unexpected first section: %#v", sections[0])
+ }
+ if len(sections[0].Entries) != 4 {
+ t.Fatalf("expected 4 smtp entries, got %d", len(sections[0].Entries))
+ }
+ if sections[0].Entries[0].Key != "mailto-user" || sections[0].Entries[0].Value != "root@pam" {
+ t.Fatalf("unexpected first smtp entry: %#v", sections[0].Entries[0])
+ }
+
+ if sections[1].Type != "matcher" || sections[1].Name != "default-matcher" {
+ t.Fatalf("unexpected second section: %#v", sections[1])
+ }
+ if len(sections[1].Entries) != 2 {
+ t.Fatalf("expected 2 matcher entries, got %d", len(sections[1].Entries))
+ }
+}
+
+func TestApplyPVENotificationsFromStage_CreatesEndpointsAndMatchers(t *testing.T) {
+ origFS := restoreFS
+ origCmd := restoreCmd
+ t.Cleanup(func() {
+ restoreFS = origFS
+ restoreCmd = origCmd
+ })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ runner := &interceptRunner{}
+ restoreCmd = runner
+
+ stageRoot := "/stage"
+ cfg := `
+smtp: example
+ mailto-user root@pam
+ mailto-user admin@pve
+ mailto max@example.com
+ from-address pve1@example.com
+ username pve1
+ server mail.example.com
+ mode starttls
+ comment hello
+
+matcher: default-matcher
+ target example
+ comment route
+`
+ priv := `
+smtp: example
+ password somepassword
+`
+
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/notifications.cfg", []byte(cfg), 0o640); err != nil {
+ t.Fatalf("write staged notifications.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/pve/priv/notifications.cfg", []byte(priv), 0o600); err != nil {
+ t.Fatalf("write staged priv notifications.cfg: %v", err)
+ }
+
+ if err := applyPVENotificationsFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPVENotificationsFromStage error: %v", err)
+ }
+
+ want := []commandCall{
+ {
+ name: "pvesh",
+ args: []string{
+ "set", "/cluster/notifications/endpoints/smtp/example",
+ "--mailto-user", "root@pam",
+ "--mailto-user", "admin@pve",
+ "--mailto", "max@example.com",
+ "--from-address", "pve1@example.com",
+ "--username", "pve1",
+ "--server", "mail.example.com",
+ "--mode", "starttls",
+ "--comment", "hello",
+ "--password", "somepassword",
+ },
+ },
+ {
+ name: "pvesh",
+ args: []string{
+ "create", "/cluster/notifications/endpoints/smtp",
+ "--name", "example",
+ "--mailto-user", "root@pam",
+ "--mailto-user", "admin@pve",
+ "--mailto", "max@example.com",
+ "--from-address", "pve1@example.com",
+ "--username", "pve1",
+ "--server", "mail.example.com",
+ "--mode", "starttls",
+ "--comment", "hello",
+ "--password", "somepassword",
+ },
+ },
+ {
+ name: "pvesh",
+ args: []string{
+ "set", "/cluster/notifications/matchers/default-matcher",
+ "--target", "example",
+ "--comment", "route",
+ },
+ },
+ {
+ name: "pvesh",
+ args: []string{
+ "create", "/cluster/notifications/matchers",
+ "--name", "default-matcher",
+ "--target", "example",
+ "--comment", "route",
+ },
+ },
+ }
+
+ if len(runner.calls) != len(want) {
+ t.Fatalf("calls=%d want %d: %#v", len(runner.calls), len(want), runner.calls)
+ }
+ for i := range want {
+ if runner.calls[i].name != want[i].name {
+ t.Fatalf("call[%d].name=%q want %q", i, runner.calls[i].name, want[i].name)
+ }
+ if fmt.Sprintf("%#v", runner.calls[i].args) != fmt.Sprintf("%#v", want[i].args) {
+ t.Fatalf("call[%d].args=%#v want %#v", i, runner.calls[i].args, want[i].args)
+ }
+ }
+}
+
+func TestApplyPVEEndpointSection_RedactsSecretsInError(t *testing.T) {
+ origCmd := restoreCmd
+ t.Cleanup(func() { restoreCmd = origCmd })
+
+ runner := &FakeCommandRunner{
+ Errors: map[string]error{
+ "pvesh set /cluster/notifications/endpoints/smtp/example --password somepassword": fmt.Errorf("boom"),
+ "pvesh create /cluster/notifications/endpoints/smtp --name example --password somepassword": fmt.Errorf("boom"),
+ },
+ }
+ restoreCmd = runner
+
+ section := proxmoxNotificationSection{
+ Type: "smtp",
+ Name: "example",
+ Entries: []proxmoxNotificationEntry{{Key: "password", Value: "somepassword"}},
+ }
+
+ err := applyPVEEndpointSection(context.Background(), newTestLogger(), section)
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ if strings.Contains(err.Error(), "somepassword") {
+ t.Fatalf("expected password to be redacted from error, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "") {
+ t.Fatalf("expected placeholder in error, got: %v", err)
+ }
+}
+
+func TestApplyPBSNotificationsFromStage_WritesFilesWithPermissions(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+ cfg := "sendmail: example\n mailto-user root@pam\n"
+ priv := "sendmail: example\n secret token\n"
+
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/notifications.cfg", []byte(cfg), 0o640); err != nil {
+ t.Fatalf("write staged notifications.cfg: %v", err)
+ }
+ if err := fakeFS.WriteFile(stageRoot+"/etc/proxmox-backup/notifications-priv.cfg", []byte(priv), 0o600); err != nil {
+ t.Fatalf("write staged notifications-priv.cfg: %v", err)
+ }
+
+ if err := applyPBSNotificationsFromStage(context.Background(), newTestLogger(), stageRoot); err != nil {
+ t.Fatalf("applyPBSNotificationsFromStage error: %v", err)
+ }
+
+ if _, err := fakeFS.ReadFile("/etc/proxmox-backup/notifications.cfg"); err != nil {
+ t.Fatalf("expected restored notifications.cfg: %v", err)
+ }
+ if _, err := fakeFS.ReadFile("/etc/proxmox-backup/notifications-priv.cfg"); err != nil {
+ t.Fatalf("expected restored notifications-priv.cfg: %v", err)
+ }
+
+ if info, err := fakeFS.Stat("/etc/proxmox-backup/notifications.cfg"); err != nil {
+ t.Fatalf("stat notifications.cfg: %v", err)
+ } else if info.Mode().Perm() != 0o640 {
+ t.Fatalf("notifications.cfg mode=%#o want %#o", info.Mode().Perm(), 0o640)
+ }
+ if info, err := fakeFS.Stat("/etc/proxmox-backup/notifications-priv.cfg"); err != nil {
+ t.Fatalf("stat notifications-priv.cfg: %v", err)
+ } else if info.Mode().Perm() != 0o600 {
+ t.Fatalf("notifications-priv.cfg mode=%#o want %#o", info.Mode().Perm(), 0o600)
+ }
+}
diff --git a/internal/orchestrator/restore_plan.go b/internal/orchestrator/restore_plan.go
index b075fe1..3c88564 100644
--- a/internal/orchestrator/restore_plan.go
+++ b/internal/orchestrator/restore_plan.go
@@ -1,6 +1,10 @@
package orchestrator
-import "github.com/tis24dev/proxsave/internal/backup"
+import (
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/backup"
+)
// RestorePlan contains a pure, side-effect-free description of a restore run.
type RestorePlan struct {
@@ -9,6 +13,7 @@ type RestorePlan struct {
NormalCategories []Category
StagedCategories []Category
ExportCategories []Category
+ ClusterBackup bool
ClusterSafeMode bool
NeedsClusterRestore bool
NeedsPBSServices bool
@@ -29,6 +34,7 @@ func PlanRestore(
NormalCategories: normal,
StagedCategories: staged,
ExportCategories: export,
+ ClusterBackup: manifest != nil && strings.EqualFold(strings.TrimSpace(manifest.ClusterMode), "cluster"),
}
plan.NeedsClusterRestore = systemType == SystemTypePVE && hasCategoryID(normal, "pve_cluster")
@@ -74,3 +80,4 @@ func (p *RestorePlan) HasCategoryID(id string) bool {
}
return hasCategoryID(p.NormalCategories, id) || hasCategoryID(p.StagedCategories, id) || hasCategoryID(p.ExportCategories, id)
}
+
diff --git a/internal/orchestrator/restore_sdn.go b/internal/orchestrator/restore_sdn.go
new file mode 100644
index 0000000..5cd81bf
--- /dev/null
+++ b/internal/orchestrator/restore_sdn.go
@@ -0,0 +1,113 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+func maybeApplyPVESDNFromStage(ctx context.Context, logger *logging.Logger, plan *RestorePlan, stageRoot string, dryRun bool) (err error) {
+ if plan == nil || plan.SystemType != SystemTypePVE || !plan.HasCategoryID("pve_sdn") {
+ return nil
+ }
+ if strings.TrimSpace(stageRoot) == "" {
+ logging.DebugStep(logger, "pve sdn staged apply", "Skipped: staging directory not available")
+ return nil
+ }
+
+ done := logging.DebugStart(logger, "pve sdn staged apply", "dryRun=%v stage=%s", dryRun, strings.TrimSpace(stageRoot))
+ defer func() { done(err) }()
+
+ if dryRun {
+ logger.Info("Dry run enabled: skipping PVE SDN apply")
+ return nil
+ }
+ if !isRealRestoreFS(restoreFS) {
+ logger.Debug("Skipping PVE SDN apply: non-system filesystem in use")
+ return nil
+ }
+ if os.Geteuid() != 0 {
+ logger.Warning("Skipping PVE SDN apply: requires root privileges")
+ return nil
+ }
+
+ // In cluster RECOVERY mode, config.db restoration owns /etc/pve state and /etc/pve is unmounted during restore.
+ if plan.NeedsClusterRestore {
+ logging.DebugStep(logger, "pve sdn staged apply", "Skip: cluster RECOVERY restores config.db")
+ return nil
+ }
+
+ etcPVE := "/etc/pve"
+ mounted, mountErr := isMounted(etcPVE)
+ if mountErr != nil {
+ logger.Warning("PVE SDN apply: unable to check pmxcfs mount (%s): %v", etcPVE, mountErr)
+ }
+ if !mounted {
+ logger.Warning("PVE SDN apply: %s is not mounted; skipping SDN apply to avoid shadow writes on root filesystem", etcPVE)
+ return nil
+ }
+
+ applied, err := applyPVESDNFromStage(logger, stageRoot)
+ if err != nil {
+ return err
+ }
+ if len(applied) == 0 {
+ logging.DebugStep(logger, "pve sdn staged apply", "No changes applied (no SDN data in staging directory)")
+ return nil
+ }
+
+ logger.Info("PVE SDN staged apply: applied %d item(s)", len(applied))
+ logger.Warning("PVE SDN note: this restores SDN definitions only; you may still need to apply SDN changes via the Proxmox UI/CLI")
+ return nil
+}
+
+func applyPVESDNFromStage(logger *logging.Logger, stageRoot string) (applied []string, err error) {
+ stageRoot = strings.TrimSpace(stageRoot)
+ done := logging.DebugStart(logger, "pve sdn apply", "stage=%s", stageRoot)
+ defer func() { done(err) }()
+
+ if stageRoot == "" {
+ return nil, nil
+ }
+
+ stageSDN := filepath.Join(stageRoot, "etc", "pve", "sdn")
+ destSDN := "/etc/pve/sdn"
+
+ if info, err := restoreFS.Stat(stageSDN); err == nil {
+ if info.IsDir() {
+ paths, err := syncDirExact(stageSDN, destSDN)
+ if err != nil {
+ return applied, err
+ }
+ applied = append(applied, paths...)
+ } else {
+ ok, err := copyFileExact(stageSDN, destSDN)
+ if err != nil {
+ return applied, err
+ }
+ if ok {
+ applied = append(applied, destSDN)
+ }
+ }
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return applied, fmt.Errorf("stat staged sdn %s: %w", stageSDN, err)
+ }
+
+ // Legacy/alternate config layout (best-effort).
+ stageSDNCfg := filepath.Join(stageRoot, "etc", "pve", "sdn.cfg")
+ destSDNCfg := "/etc/pve/sdn.cfg"
+ ok, err := copyFileExact(stageSDNCfg, destSDNCfg)
+ if err != nil {
+ return applied, err
+ }
+ if ok {
+ applied = append(applied, destSDNCfg)
+ }
+
+ return applied, nil
+}
diff --git a/internal/orchestrator/restore_sdn_test.go b/internal/orchestrator/restore_sdn_test.go
new file mode 100644
index 0000000..3a288d9
--- /dev/null
+++ b/internal/orchestrator/restore_sdn_test.go
@@ -0,0 +1,83 @@
+package orchestrator
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestApplyPVESDNFromStage_SyncsDirectoryAndCfg(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ stageRoot := "/stage"
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/sdn/zones.cfg", []byte("zone: z1\n")); err != nil {
+ t.Fatalf("add zones.cfg: %v", err)
+ }
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/sdn/vnets.cfg", []byte("vnet: v1\n")); err != nil {
+ t.Fatalf("add vnets.cfg: %v", err)
+ }
+ if err := fakeFS.AddFile(stageRoot+"/etc/pve/sdn.cfg", []byte("legacy\n")); err != nil {
+ t.Fatalf("add sdn.cfg: %v", err)
+ }
+ if err := fakeFS.AddFile("/etc/pve/sdn/old.cfg", []byte("old\n")); err != nil {
+ t.Fatalf("add old.cfg: %v", err)
+ }
+
+ applied, err := applyPVESDNFromStage(newTestLogger(), stageRoot)
+ if err != nil {
+ t.Fatalf("applyPVESDNFromStage error: %v", err)
+ }
+
+ if got, err := fakeFS.ReadFile("/etc/pve/sdn/zones.cfg"); err != nil {
+ t.Fatalf("read zones.cfg: %v", err)
+ } else if string(got) != "zone: z1\n" {
+ t.Fatalf("unexpected zones.cfg content: %q", string(got))
+ }
+
+ if got, err := fakeFS.ReadFile("/etc/pve/sdn/vnets.cfg"); err != nil {
+ t.Fatalf("read vnets.cfg: %v", err)
+ } else if string(got) != "vnet: v1\n" {
+ t.Fatalf("unexpected vnets.cfg content: %q", string(got))
+ }
+
+ if got, err := fakeFS.ReadFile("/etc/pve/sdn.cfg"); err != nil {
+ t.Fatalf("read sdn.cfg: %v", err)
+ } else if string(got) != "legacy\n" {
+ t.Fatalf("unexpected sdn.cfg content: %q", string(got))
+ }
+
+ if _, err := fakeFS.Stat("/etc/pve/sdn/old.cfg"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("expected old.cfg to be pruned, stat err=%v", err)
+ }
+
+ joined := strings.Join(applied, "\n")
+ for _, want := range []string{"/etc/pve/sdn/zones.cfg", "/etc/pve/sdn/vnets.cfg", "/etc/pve/sdn.cfg"} {
+ if !strings.Contains(joined, want) {
+ t.Fatalf("expected applied paths to include %s; applied=%v", want, applied)
+ }
+ }
+}
+
+func TestApplyPVESDNFromStage_NoStageData_NoChanges(t *testing.T) {
+ origFS := restoreFS
+ t.Cleanup(func() { restoreFS = origFS })
+
+ fakeFS := NewFakeFS()
+ t.Cleanup(func() { _ = os.RemoveAll(fakeFS.Root) })
+ restoreFS = fakeFS
+
+ applied, err := applyPVESDNFromStage(newTestLogger(), "/stage")
+ if err != nil {
+ t.Fatalf("applyPVESDNFromStage error: %v", err)
+ }
+ if len(applied) != 0 {
+ t.Fatalf("expected no applied paths, got=%v", applied)
+ }
+}
+
diff --git a/internal/orchestrator/restore_test.go b/internal/orchestrator/restore_test.go
index 9f4c41b..c9d008d 100644
--- a/internal/orchestrator/restore_test.go
+++ b/internal/orchestrator/restore_test.go
@@ -180,9 +180,9 @@ func TestShouldSkipProxmoxSystemRestore(t *testing.T) {
{name: "skip domains.cfg", path: "etc/proxmox-backup/domains.cfg", wantSkip: true},
{name: "skip user.cfg", path: "etc/proxmox-backup/user.cfg", wantSkip: true},
{name: "skip acl.cfg", path: "etc/proxmox-backup/acl.cfg", wantSkip: true},
- {name: "skip proxy.cfg", path: "etc/proxmox-backup/proxy.cfg", wantSkip: true},
- {name: "skip proxy.pem", path: "etc/proxmox-backup/proxy.pem", wantSkip: true},
- {name: "skip ssl subtree", path: "etc/proxmox-backup/ssl/example.pem", wantSkip: true},
+ {name: "allow proxy.cfg", path: "etc/proxmox-backup/proxy.cfg", wantSkip: false},
+ {name: "allow proxy.pem", path: "etc/proxmox-backup/proxy.pem", wantSkip: false},
+ {name: "allow ssl subtree", path: "etc/proxmox-backup/ssl/example.pem", wantSkip: false},
{name: "skip lock subtree", path: "var/lib/proxmox-backup/lock/lockfile", wantSkip: true},
{name: "skip clusterlock", path: "var/lib/proxmox-backup/.clusterlock", wantSkip: true},
{name: "allow datastore.cfg", path: "etc/proxmox-backup/datastore.cfg", wantSkip: false},
@@ -808,12 +808,13 @@ func TestParseStorageBlocks_MultipleBlocks(t *testing.T) {
restoreFS = osFS{}
cfgPath := filepath.Join(t.TempDir(), "storage.cfg")
- content := `storage: local
+ content := `dir: local
path /var/lib/vz
content images,rootdir
-storage: nfs-backup
- path /mnt/pbs
+nfs: nfs-backup
+ server 192.168.1.10
+ export /mnt/pbs
content backup
`
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
@@ -832,6 +833,33 @@ storage: nfs-backup
}
}
+func TestParseStorageBlocks_LegacyStoragePrefix(t *testing.T) {
+ orig := restoreFS
+ t.Cleanup(func() { restoreFS = orig })
+ restoreFS = osFS{}
+
+ cfgPath := filepath.Join(t.TempDir(), "storage.cfg")
+ content := `storage: local
+ type dir
+ path /var/lib/vz
+ content images,rootdir
+`
+ if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ blocks, err := parseStorageBlocks(cfgPath)
+ if err != nil {
+ t.Fatalf("parse error: %v", err)
+ }
+ if len(blocks) != 1 {
+ t.Fatalf("expected 1 block, got %d", len(blocks))
+ }
+ if blocks[0].ID != "local" {
+ t.Fatalf("unexpected block ID: %v", blocks[0].ID)
+ }
+}
+
// --------------------------------------------------------------------------
// minDuration tests
// --------------------------------------------------------------------------
diff --git a/internal/orchestrator/restore_tui.go b/internal/orchestrator/restore_tui.go
index 898e812..bc263dc 100644
--- a/internal/orchestrator/restore_tui.go
+++ b/internal/orchestrator/restore_tui.go
@@ -1,35 +1,25 @@
package orchestrator
import (
- "bufio"
"context"
"errors"
"fmt"
- "os"
- "path/filepath"
"sort"
"strings"
- "sync/atomic"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/tis24dev/proxsave/internal/config"
- "github.com/tis24dev/proxsave/internal/input"
"github.com/tis24dev/proxsave/internal/logging"
"github.com/tis24dev/proxsave/internal/tui"
"github.com/tis24dev/proxsave/internal/tui/components"
)
-type restoreSelection struct {
- Candidate *decryptCandidate
-}
-
const (
restoreWizardSubtitle = "Restore Backup Workflow"
restoreNavText = "[yellow]Navigation:[white] TAB/↑↓ to move | ENTER to select | ESC to exit screens | Mouse clicks enabled"
- restoreErrorModalPage = "restore-error-modal"
)
var errRestoreBackToMode = errors.New("restore mode back")
@@ -38,802 +28,26 @@ var promptYesNoTUIFunc = promptYesNoTUI
// RunRestoreWorkflowTUI runs the restore workflow using a TUI flow.
func RunRestoreWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version, configPath, buildSig string) (err error) {
if cfg == nil {
- return fmt.Errorf("configuration not available")
- }
- if logger == nil {
- logger = logging.GetDefaultLogger()
- }
- done := logging.DebugStart(logger, "restore workflow (tui)", "version=%s", version)
- defer func() { done(err) }()
- defer func() {
- if err == nil {
- return
- }
- if errors.Is(err, ErrDecryptAborted) ||
- errors.Is(err, ErrAgeRecipientSetupAborted) ||
- errors.Is(err, context.Canceled) ||
- (ctx != nil && ctx.Err() != nil) {
- err = ErrRestoreAborted
- }
- }()
- if strings.TrimSpace(buildSig) == "" {
- buildSig = "n/a"
- }
-
- candidate, prepared, err := prepareDecryptedBackupTUI(ctx, cfg, logger, version, configPath, buildSig)
- if err != nil {
- return err
- }
- defer prepared.Cleanup()
-
- destRoot := "/"
- logger.Info("Restore target: system root (/) — files will be written back to their original paths")
-
- // Detect system type
- systemType := restoreSystem.DetectCurrentSystem()
- logger.Info("Detected system type: %s", GetSystemTypeString(systemType))
-
- // Validate compatibility
- if err := ValidateCompatibility(candidate.Manifest); err != nil {
- logger.Warning("Compatibility check: %v", err)
- proceed, perr := promptCompatibilityTUI(configPath, buildSig, err)
- if perr != nil {
- return perr
- }
- if !proceed {
- return fmt.Errorf("restore aborted due to incompatibility")
- }
- }
-
- // Analyze available categories in the backup
- logger.Info("Analyzing backup contents...")
- availableCategories, err := AnalyzeBackupCategories(prepared.ArchivePath, logger)
- if err != nil {
- logger.Warning("Could not analyze categories: %v", err)
- logger.Info("Falling back to full restore mode")
- return runFullRestoreTUI(ctx, candidate, prepared, destRoot, logger, cfg.DryRun, configPath, buildSig)
- }
-
- // Restore mode selection (loop to allow going back from category selection)
- var (
- mode RestoreMode
- selectedCategories []Category
- )
-
- for {
- backupSummary := fmt.Sprintf(
- "%s (%s)",
- candidate.DisplayBase,
- candidate.Manifest.CreatedAt.Format("2006-01-02 15:04:05"),
- )
-
- mode, err = selectRestoreModeTUI(systemType, configPath, buildSig, backupSummary)
- if err != nil {
- if errors.Is(err, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- return err
- }
-
- if mode != RestoreModeCustom {
- selectedCategories = GetCategoriesForMode(mode, systemType, availableCategories)
- break
- }
-
- selectedCategories, err = selectCategoriesTUI(availableCategories, systemType, configPath, buildSig)
- if err != nil {
- if errors.Is(err, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- if errors.Is(err, errRestoreBackToMode) {
- // User chose "Back" from category selection: re-open restore mode selection.
- continue
- }
- return err
- }
- break
- }
-
- plan := PlanRestore(candidate.Manifest, selectedCategories, systemType, mode)
-
- // Cluster safety prompt (SAFE vs RECOVERY)
- clusterBackup := strings.EqualFold(strings.TrimSpace(candidate.Manifest.ClusterMode), "cluster")
- if plan.NeedsClusterRestore && clusterBackup {
- logger.Info("Backup marked as cluster node; enabling guarded restore options for pve_cluster")
- choice, promptErr := promptClusterRestoreModeTUI(configPath, buildSig)
- if promptErr != nil {
- if errors.Is(promptErr, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- return promptErr
- }
- if choice == 0 {
- return ErrRestoreAborted
- }
- if choice == 1 {
- plan.ApplyClusterSafeMode(true)
- logger.Info("Selected SAFE cluster restore: /var/lib/pve-cluster will be exported only, not written to system")
- } else {
- plan.ApplyClusterSafeMode(false)
- logger.Warning("Selected RECOVERY cluster restore: full cluster database will be restored; ensure other nodes are isolated")
- }
- }
-
- // Staging is designed to protect live systems. In test runs (fake filesystem) or non-root targets,
- // extract staged categories directly to the destination to keep restore semantics predictable.
- if destRoot != "/" || !isRealRestoreFS(restoreFS) {
- if len(plan.StagedCategories) > 0 {
- logging.DebugStep(logger, "restore", "Staging disabled (destRoot=%s realFS=%v): extracting %d staged category(ies) directly", destRoot, isRealRestoreFS(restoreFS), len(plan.StagedCategories))
- plan.NormalCategories = append(plan.NormalCategories, plan.StagedCategories...)
- plan.StagedCategories = nil
- }
- }
-
- // Create restore configuration
- restoreConfig := &SelectiveRestoreConfig{
- Mode: mode,
- SystemType: systemType,
- Metadata: candidate.Manifest,
- }
- restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.NormalCategories...)
- restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.StagedCategories...)
- restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.ExportCategories...)
-
- // Show detailed restore plan
- if err := showRestorePlanTUI(restoreConfig, configPath, buildSig); err != nil {
- if errors.Is(err, ErrRestoreAborted) {
- return ErrRestoreAborted
- }
- return err
- }
-
- // Confirm operation (RESTORE)
- confirmed, err := confirmRestoreTUI(configPath, buildSig)
- if err != nil {
- return err
- }
- if !confirmed {
- logger.Info("Restore operation cancelled by user")
- return ErrRestoreAborted
- }
-
- // Create safety backup of current configuration (only for categories that will write to system paths)
- var safetyBackup *SafetyBackupResult
- var networkRollbackBackup *SafetyBackupResult
- systemWriteCategories := append([]Category{}, plan.NormalCategories...)
- systemWriteCategories = append(systemWriteCategories, plan.StagedCategories...)
- if len(systemWriteCategories) > 0 {
- logger.Info("")
- safetyBackup, err = CreateSafetyBackup(logger, systemWriteCategories, destRoot)
- if err != nil {
- logger.Warning("Failed to create safety backup: %v", err)
- cont, perr := promptContinueWithoutSafetyBackupTUI(configPath, buildSig, err)
- if perr != nil {
- return perr
- }
- if !cont {
- return fmt.Errorf("restore aborted: safety backup failed")
- }
- } else {
- logger.Info("Safety backup location: %s", safetyBackup.BackupPath)
- logger.Info("You can restore from this backup if needed using: tar -xzf %s -C /", safetyBackup.BackupPath)
- }
- }
-
- if plan.HasCategoryID("network") {
- logger.Info("")
- logging.DebugStep(logger, "restore", "Create network-only rollback backup for transactional network apply")
- networkRollbackBackup, err = CreateNetworkRollbackBackup(logger, systemWriteCategories, destRoot)
- if err != nil {
- logger.Warning("Failed to create network rollback backup: %v", err)
- } else if networkRollbackBackup != nil && strings.TrimSpace(networkRollbackBackup.BackupPath) != "" {
- logger.Info("Network rollback backup location: %s", networkRollbackBackup.BackupPath)
- logger.Info("This backup is used for the %ds network rollback timer and only includes network paths.", int(defaultNetworkRollbackTimeout.Seconds()))
- }
- }
-
- // If we are restoring cluster database, stop PVE services and unmount /etc/pve before writing
- needsClusterRestore := plan.NeedsClusterRestore
- clusterServicesStopped := false
- pbsServicesStopped := false
- needsPBSServices := plan.NeedsPBSServices
- if needsClusterRestore {
- logger.Info("")
- logger.Info("Preparing system for cluster database restore: stopping PVE services and unmounting /etc/pve")
- if err := stopPVEClusterServices(ctx, logger); err != nil {
- return err
- }
- clusterServicesStopped = true
- defer func() {
- restartCtx, cancel := context.WithTimeout(context.Background(), 2*serviceStartTimeout+2*serviceVerifyTimeout+10*time.Second)
- defer cancel()
- if err := startPVEClusterServices(restartCtx, logger); err != nil {
- logger.Warning("Failed to restart PVE services after restore: %v", err)
- }
- }()
-
- if err := unmountEtcPVE(ctx, logger); err != nil {
- logger.Warning("Could not unmount /etc/pve: %v", err)
- }
- }
-
- // For PBS restores, stop PBS services before applying configuration/datastore changes if relevant categories are selected
- if needsPBSServices {
- logger.Info("")
- logger.Info("Preparing PBS system for restore: stopping proxmox-backup services")
- if err := stopPBSServices(ctx, logger); err != nil {
- logger.Warning("Unable to stop PBS services automatically: %v", err)
- cont, perr := promptContinueWithPBSServicesTUI(configPath, buildSig)
- if perr != nil {
- return perr
- }
- if !cont {
- return ErrRestoreAborted
- }
- logger.Warning("Continuing restore with PBS services still running")
- } else {
- pbsServicesStopped = true
- defer func() {
- restartCtx, cancel := context.WithTimeout(context.Background(), 2*serviceStartTimeout+2*serviceVerifyTimeout+10*time.Second)
- defer cancel()
- if err := startPBSServices(restartCtx, logger); err != nil {
- logger.Warning("Failed to restart PBS services after restore: %v", err)
- }
- }()
- }
- }
-
- // Perform selective extraction for normal categories
- var detailedLogPath string
- if len(plan.NormalCategories) > 0 {
- logger.Info("")
- categoriesForExtraction := plan.NormalCategories
- if needsClusterRestore {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: sanitize categories to avoid /etc/pve shadow writes")
- sanitized, removed := sanitizeCategoriesForClusterRecovery(categoriesForExtraction)
- removedPaths := 0
- for _, paths := range removed {
- removedPaths += len(paths)
- }
- logging.DebugStep(
- logger,
- "restore",
- "Cluster RECOVERY shadow-guard: categories_before=%d categories_after=%d removed_categories=%d removed_paths=%d",
- len(categoriesForExtraction),
- len(sanitized),
- len(removed),
- removedPaths,
- )
- if len(removed) > 0 {
- logger.Warning("Cluster RECOVERY restore: skipping direct restore of /etc/pve paths to prevent shadowing while pmxcfs is stopped/unmounted")
- for _, cat := range categoriesForExtraction {
- if paths, ok := removed[cat.ID]; ok && len(paths) > 0 {
- logger.Warning(" - %s (%s): %s", cat.Name, cat.ID, strings.Join(paths, ", "))
- }
- }
- logger.Info("These paths are expected to be restored from config.db and become visible after /etc/pve is remounted.")
- } else {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: no /etc/pve paths detected in selected categories")
- }
- categoriesForExtraction = sanitized
- var extractionIDs []string
- for _, cat := range categoriesForExtraction {
- if id := strings.TrimSpace(cat.ID); id != "" {
- extractionIDs = append(extractionIDs, id)
- }
- }
- if len(extractionIDs) > 0 {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: extraction_categories=%s", strings.Join(extractionIDs, ","))
- } else {
- logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: extraction_categories=")
- }
- }
-
- if len(categoriesForExtraction) == 0 {
- logging.DebugStep(logger, "restore", "Skip system-path extraction: no categories remain after shadow-guard")
- logger.Info("No system-path categories remain after cluster shadow-guard; skipping system-path extraction.")
- } else {
- detailedLogPath, err = extractSelectiveArchive(ctx, prepared.ArchivePath, destRoot, categoriesForExtraction, mode, logger)
- if err != nil {
- logger.Error("Restore failed: %v", err)
- if safetyBackup != nil {
- logger.Info("You can rollback using the safety backup at: %s", safetyBackup.BackupPath)
- }
- return err
- }
- }
- } else {
- logger.Info("")
- logger.Info("No system-path categories selected for restore (only export categories will be processed).")
- }
-
- // Handle export-only categories by extracting them to a separate directory
- exportLogPath := ""
- exportRoot := ""
- if len(plan.ExportCategories) > 0 {
- exportRoot = exportDestRoot(cfg.BaseDir)
- logger.Info("")
- logger.Info("Exporting %d export-only category(ies) to: %s", len(plan.ExportCategories), exportRoot)
- if err := restoreFS.MkdirAll(exportRoot, 0o755); err != nil {
- return fmt.Errorf("failed to create export directory %s: %w", exportRoot, err)
- }
-
- if exportLog, exErr := extractSelectiveArchive(ctx, prepared.ArchivePath, exportRoot, plan.ExportCategories, RestoreModeCustom, logger); exErr != nil {
- if errors.Is(exErr, ErrRestoreAborted) || input.IsAborted(exErr) {
- return exErr
- }
- logger.Warning("Export completed with errors: %v", exErr)
- } else {
- exportLogPath = exportLog
- }
- }
-
- // SAFE cluster mode: offer applying configs via pvesh without touching config.db
- if plan.ClusterSafeMode {
- if exportRoot == "" {
- logger.Warning("Cluster SAFE mode selected but export directory not available; skipping automatic pvesh apply")
- } else if err := runSafeClusterApply(ctx, bufio.NewReader(os.Stdin), exportRoot, logger); err != nil {
- // Note: runSafeClusterApply currently uses console prompts; this step remains non-TUI.
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Cluster SAFE apply completed with errors: %v", err)
- }
- }
-
- // Stage sensitive categories (network, PBS datastore/jobs) to a temporary directory and apply them safely later.
- stageLogPath := ""
- stageRoot := ""
- if len(plan.StagedCategories) > 0 {
- stageRoot = stageDestRoot()
- logger.Info("")
- logger.Info("Staging %d sensitive category(ies) to: %s", len(plan.StagedCategories), stageRoot)
- if err := restoreFS.MkdirAll(stageRoot, 0o755); err != nil {
- return fmt.Errorf("failed to create staging directory %s: %w", stageRoot, err)
- }
-
- if stageLog, err := extractSelectiveArchive(ctx, prepared.ArchivePath, stageRoot, plan.StagedCategories, RestoreModeCustom, logger); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Staging completed with errors: %v", err)
- } else {
- stageLogPath = stageLog
- }
-
- logger.Info("")
- if err := maybeApplyPBSConfigsFromStage(ctx, logger, plan, stageRoot, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("PBS staged config apply: %v", err)
- }
- }
-
- stageRootForNetworkApply := stageRoot
- if installed, err := maybeInstallNetworkConfigFromStage(ctx, logger, plan, stageRoot, prepared.ArchivePath, networkRollbackBackup, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Network staged install: %v", err)
- } else if installed {
- stageRootForNetworkApply = ""
- logging.DebugStep(logger, "restore", "Network staged install completed: configuration written to /etc (no reload); live apply will use system paths")
- }
-
- // Recreate directory structures from configuration files if relevant categories were restored
- logger.Info("")
- categoriesForDirRecreate := append([]Category{}, plan.NormalCategories...)
- categoriesForDirRecreate = append(categoriesForDirRecreate, plan.StagedCategories...)
- if shouldRecreateDirectories(systemType, categoriesForDirRecreate) {
- if err := RecreateDirectoriesFromConfig(systemType, logger); err != nil {
- logger.Warning("Failed to recreate directory structures: %v", err)
- logger.Warning("You may need to manually create storage/datastore directories")
- }
- } else {
- logger.Debug("Skipping datastore/storage directory recreation (category not selected)")
- }
-
- logger.Info("")
- if plan.HasCategoryID("network") {
- logger.Info("")
- if err := maybeRepairResolvConfAfterRestore(ctx, logger, prepared.ArchivePath, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("DNS resolver repair: %v", err)
- }
- }
-
- logger.Info("")
- if err := maybeApplyNetworkConfigTUI(ctx, logger, plan, safetyBackup, networkRollbackBackup, stageRootForNetworkApply, prepared.ArchivePath, configPath, buildSig, cfg.DryRun); err != nil {
- if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
- return err
- }
- logger.Warning("Network apply step skipped or failed: %v", err)
- }
-
- logger.Info("")
- logger.Info("Restore completed successfully.")
- logger.Info("Temporary decrypted bundle removed.")
-
- if detailedLogPath != "" {
- logger.Info("Detailed restore log: %s", detailedLogPath)
- }
- if exportRoot != "" {
- logger.Info("Export directory: %s", exportRoot)
- }
- if exportLogPath != "" {
- logger.Info("Export detailed log: %s", exportLogPath)
- }
- if stageRoot != "" {
- logger.Info("Staging directory: %s", stageRoot)
- }
- if stageLogPath != "" {
- logger.Info("Staging detailed log: %s", stageLogPath)
- }
-
- if safetyBackup != nil {
- logger.Info("Safety backup preserved at: %s", safetyBackup.BackupPath)
- logger.Info("Remove it manually if restore was successful: rm %s", safetyBackup.BackupPath)
- }
-
- logger.Info("")
- logger.Info("IMPORTANT: You may need to restart services for changes to take effect.")
- if systemType == SystemTypePVE {
- if needsClusterRestore && clusterServicesStopped {
- logger.Info(" PVE services were stopped/restarted during restore; verify status with: pvecm status")
- } else {
- logger.Info(" PVE services: systemctl restart pve-cluster pvedaemon pveproxy")
- }
- } else if systemType == SystemTypePBS {
- if pbsServicesStopped {
- logger.Info(" PBS services were stopped/restarted during restore; verify status with: systemctl status proxmox-backup proxmox-backup-proxy")
- } else {
- logger.Info(" PBS services: systemctl restart proxmox-backup-proxy proxmox-backup")
- }
-
- // Check ZFS pool status for PBS systems only when ZFS category was restored
- if hasCategoryID(plan.NormalCategories, "zfs") {
- logger.Info("")
- if err := checkZFSPoolsAfterRestore(logger); err != nil {
- logger.Warning("ZFS pool check: %v", err)
- }
- } else {
- logger.Debug("Skipping ZFS pool verification (ZFS category not selected)")
- }
- }
-
- logger.Info("")
- logger.Warning("⚠ SYSTEM REBOOT RECOMMENDED")
- logger.Info("Reboot the node (or at least restart networking and system services) to ensure all restored configurations take effect cleanly.")
-
- return nil
-}
-
-func prepareDecryptedBackupTUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version, configPath, buildSig string) (*decryptCandidate, *preparedBundle, error) {
- candidate, err := runRestoreSelectionWizard(ctx, cfg, logger, configPath, buildSig)
- if err != nil {
- return nil, nil, err
- }
-
- prepared, err := preparePlainBundleTUI(ctx, candidate, version, logger, configPath, buildSig)
- if err != nil {
- return nil, nil, err
- }
-
- return candidate, prepared, nil
-}
-
-func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, logger *logging.Logger, configPath, buildSig string) (candidate *decryptCandidate, err error) {
- if ctx == nil {
- ctx = context.Background()
- }
- done := logging.DebugStart(logger, "restore selection wizard", "tui=true")
- defer func() { done(err) }()
- options := buildDecryptPathOptions(cfg, logger)
- if len(options) == 0 {
- err = fmt.Errorf("no backup paths configured in backup.env")
- return nil, err
- }
- for _, opt := range options {
- logging.DebugStep(logger, "restore selection wizard", "option label=%q path=%q rclone=%v", opt.Label, opt.Path, opt.IsRclone)
- }
-
- app := newTUIApp()
- pages := tview.NewPages()
-
- selection := &restoreSelection{}
- var selectionErr error
- var scan scanController
- var scanSeq uint64
-
- pathList := tview.NewList().ShowSecondaryText(false)
- pathList.SetMainTextColor(tcell.ColorWhite).
- SetSelectedTextColor(tcell.ColorWhite).
- SetSelectedBackgroundColor(tui.ProxmoxOrange)
-
- for _, opt := range options {
- // Use parentheses instead of square brackets (tview interprets [] as color tags)
- label := fmt.Sprintf("%s (%s)", opt.Label, opt.Path)
- pathList.AddItem(label, "", 0, nil)
- }
-
- pathList.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
- if index < 0 || index >= len(options) {
- return
- }
- selectedOption := options[index]
- scanID := atomic.AddUint64(&scanSeq, 1)
- logging.DebugStep(logger, "restore selection wizard", "selected source label=%q path=%q rclone=%v", selectedOption.Label, selectedOption.Path, selectedOption.IsRclone)
- pages.SwitchToPage("paths-loading")
- go func() {
- scanCtx, finish := scan.Start(ctx)
- defer finish()
-
- var candidates []*decryptCandidate
- var scanErr error
- scanDone := logging.DebugStart(logger, "scan backup source", "id=%d path=%s rclone=%v", scanID, selectedOption.Path, selectedOption.IsRclone)
- defer func() { scanDone(scanErr) }()
-
- if selectedOption.IsRclone {
- timeout := 30 * time.Second
- if cfg != nil && cfg.RcloneTimeoutConnection > 0 {
- timeout = time.Duration(cfg.RcloneTimeoutConnection) * time.Second
- }
- logging.DebugStep(logger, "scan backup source", "id=%d rclone_timeout=%s", scanID, timeout)
- rcloneCtx, cancel := context.WithTimeout(scanCtx, timeout)
- defer cancel()
- candidates, scanErr = discoverRcloneBackups(rcloneCtx, selectedOption.Path, logger)
- } else {
- candidates, scanErr = discoverBackupCandidates(logger, selectedOption.Path)
- }
- logging.DebugStep(logger, "scan backup source", "candidates=%d", len(candidates))
- if scanCtx.Err() != nil {
- scanErr = scanCtx.Err()
- return
- }
- app.QueueUpdateDraw(func() {
- if scanErr != nil {
- message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, scanErr)
- if selectedOption.IsRclone && errors.Is(scanErr, context.DeadlineExceeded) {
- message = fmt.Sprintf("Timed out while scanning %s (rclone). Check connectivity/rclone config or increase RCLONE_TIMEOUT_CONNECTION. (%v)", selectedOption.Path, scanErr)
- }
- showRestoreErrorModal(app, pages, configPath, buildSig, message, func() {
- pages.SwitchToPage("paths")
- })
- return
- }
- if len(candidates) == 0 {
- message := "No backups found in selected path."
- showRestoreErrorModal(app, pages, configPath, buildSig, message, func() {
- pages.SwitchToPage("paths")
- })
- return
- }
-
- showRestoreCandidatePage(app, pages, candidates, configPath, buildSig, func(c *decryptCandidate) {
- selection.Candidate = c
- app.Stop()
- }, func() {
- selectionErr = ErrRestoreAborted
- app.Stop()
- })
- })
- }()
- })
- pathList.SetDoneFunc(func() {
- logging.DebugStep(logger, "restore selection wizard", "cancel requested (done func)")
- scan.Cancel()
- selectionErr = ErrRestoreAborted
- app.Stop()
- })
-
- form := components.NewForm(app)
- listHeight := len(options)
- if listHeight < 8 {
- listHeight = 8
- }
- if listHeight > 14 {
- listHeight = 14
- }
- listItem := components.NewListFormItem(pathList).
- SetLabel("Available backup sources").
- SetFieldHeight(listHeight)
- form.Form.AddFormItem(listItem)
- form.Form.SetFocus(0)
-
- form.SetOnCancel(func() {
- logging.DebugStep(logger, "restore selection wizard", "cancel requested (form)")
- scan.Cancel()
- selectionErr = ErrRestoreAborted
- })
- form.AddCancelButton("Cancel")
- enableFormNavigation(form, nil)
-
- pathPage := buildRestoreWizardPage("Select backup source", configPath, buildSig, form.Form)
- pages.AddPage("paths", pathPage, true, true)
-
- loadingText := tview.NewTextView().
- SetText("Scanning backup path...").
- SetTextAlign(tview.AlignCenter)
-
- loadingForm := components.NewForm(app)
- loadingForm.SetOnCancel(func() {
- logging.DebugStep(logger, "restore selection wizard", "cancel requested (loading form)")
- scan.Cancel()
- selectionErr = ErrRestoreAborted
- })
- loadingForm.AddCancelButton("Cancel")
- loadingContent := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(loadingText, 0, 1, false).
- AddItem(loadingForm.Form, 3, 0, false)
- loadingPage := buildRestoreWizardPage("Loading backups", configPath, buildSig, loadingContent)
- pages.AddPage("paths-loading", loadingPage, true, false)
-
- app.SetRoot(pages, true).SetFocus(form.Form)
- if runErr := app.Run(); runErr != nil {
- err = runErr
- return nil, err
- }
- if selectionErr != nil {
- err = selectionErr
- return nil, err
- }
- if selection.Candidate == nil {
- err = ErrRestoreAborted
- return nil, err
- }
- candidate = selection.Candidate
- return candidate, nil
-}
-
-func showRestoreErrorModal(app *tui.App, pages *tview.Pages, configPath, buildSig, message string, onDismiss func()) {
- modal := tview.NewModal().
- SetText(fmt.Sprintf("%s %s\n\n[yellow]Press ENTER to continue[white]", tui.SymbolError, message)).
- AddButtons([]string{"OK"}).
- SetDoneFunc(func(buttonIndex int, buttonLabel string) {
- if pages.HasPage(restoreErrorModalPage) {
- pages.RemovePage(restoreErrorModalPage)
- }
- if onDismiss != nil {
- onDismiss()
- }
- })
-
- modal.SetBorder(true).
- SetTitle(" Restore Error ").
- SetTitleAlign(tview.AlignCenter).
- SetTitleColor(tui.ErrorRed).
- SetBorderColor(tui.ErrorRed).
- SetBackgroundColor(tcell.ColorBlack)
-
- page := buildRestoreWizardPage("Error", configPath, buildSig, modal)
- if pages.HasPage(restoreErrorModalPage) {
- pages.RemovePage(restoreErrorModalPage)
- }
- pages.AddPage(restoreErrorModalPage, page, true, true)
- app.SetFocus(modal)
-}
-
-func showRestoreCandidatePage(app *tui.App, pages *tview.Pages, candidates []*decryptCandidate, configPath, buildSig string, onSelect func(*decryptCandidate), onCancel func()) {
- list := tview.NewList().ShowSecondaryText(false)
- list.SetMainTextColor(tcell.ColorWhite).
- SetSelectedTextColor(tcell.ColorWhite).
- SetSelectedBackgroundColor(tui.ProxmoxOrange)
-
- type row struct {
- created string
- mode string
- tool string
- targets string
- compression string
- }
-
- rows := make([]row, len(candidates))
- var maxMode, maxTool, maxTargets, maxComp int
-
- for idx, cand := range candidates {
- created := cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05")
-
- mode := strings.ToUpper(statusFromManifest(cand.Manifest))
- if mode == "" {
- mode = "UNKNOWN"
- }
-
- toolVersion := strings.TrimSpace(cand.Manifest.ScriptVersion)
- if toolVersion == "" {
- toolVersion = "unknown"
- }
- tool := "Tool " + toolVersion
-
- targets := buildTargetInfo(cand.Manifest)
-
- comp := ""
- if c := strings.TrimSpace(cand.Manifest.CompressionType); c != "" {
- comp = strings.ToUpper(c)
- }
-
- rows[idx] = row{
- created: created,
- mode: mode,
- tool: tool,
- targets: targets,
- compression: comp,
- }
-
- if len(mode) > maxMode {
- maxMode = len(mode)
- }
- if len(tool) > maxTool {
- maxTool = len(tool)
- }
- if len(targets) > maxTargets {
- maxTargets = len(targets)
- }
- if len(comp) > maxComp {
- maxComp = len(comp)
- }
- }
-
- for idx, r := range rows {
- line := fmt.Sprintf(
- "%2d) %s %-*s %-*s %-*s",
- idx+1,
- r.created,
- maxMode, r.mode,
- maxTool, r.tool,
- maxTargets, r.targets,
- )
- if maxComp > 0 {
- line = fmt.Sprintf("%s %-*s", line, maxComp, r.compression)
- }
- list.AddItem(line, "", 0, nil)
- }
-
- list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
- if index < 0 || index >= len(candidates) {
- return
- }
- onSelect(candidates[index])
- })
- list.SetDoneFunc(func() {
- pages.SwitchToPage("paths")
- })
-
- form := components.NewForm(app)
- listHeight := len(candidates)
- if listHeight < 8 {
- listHeight = 8
+ return fmt.Errorf("configuration not available")
}
- if listHeight > 14 {
- listHeight = 14
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+ if strings.TrimSpace(buildSig) == "" {
+ buildSig = "n/a"
}
- listItem := components.NewListFormItem(list).
- SetLabel("Available backups").
- SetFieldHeight(listHeight)
- form.Form.AddFormItem(listItem)
- form.Form.SetFocus(0)
-
- form.SetOnCancel(func() {
- if onCancel != nil {
- onCancel()
- }
- })
- // Back goes on the left, Cancel on the right (order of AddButton calls)
- form.Form.AddButton("Back", func() {
- pages.SwitchToPage("paths")
- })
- form.AddCancelButton("Cancel")
- enableFormNavigation(form, nil)
+ done := logging.DebugStart(logger, "restore workflow (tui)", "version=%s", version)
+ defer func() { done(err) }()
- page := buildRestoreWizardPage("Select backup to restore", configPath, buildSig, form.Form)
- if pages.HasPage("candidates") {
- pages.RemovePage("candidates")
+ ui := newTUIRestoreWorkflowUI(configPath, buildSig, logger)
+ if err := runRestoreWorkflowWithUI(ctx, cfg, logger, version, ui); err != nil {
+ if errors.Is(err, ErrRestoreAborted) {
+ return ErrRestoreAborted
+ }
+ return err
}
- pages.AddPage("candidates", page, true, true)
+ return nil
}
func selectRestoreModeTUI(systemType SystemType, configPath, buildSig, backupSummary string) (RestoreMode, error) {
@@ -850,13 +64,13 @@ func selectRestoreModeTUI(systemType SystemType, configPath, buildSig, backupSum
storageText := ""
switch systemType {
case SystemTypePVE:
- storageText = "STORAGE only - PVE cluster + storage configuration + VM configs + jobs"
+ storageText = "STORAGE only - PVE cluster + storage + jobs + mounts"
case SystemTypePBS:
- storageText = "DATASTORE only - PBS config + datastore definitions + sync/verify/prune jobs"
+ storageText = "DATASTORE only - PBS datastore definitions + sync/verify/prune jobs + mounts"
default:
storageText = "STORAGE/DATASTORE only - Storage or datastore configuration"
}
- baseText := "SYSTEM BASE only - Network + SSL + SSH + services"
+ baseText := "SYSTEM BASE only - Network + SSL + SSH + services + filesystem"
customText := "CUSTOM selection - Choose specific categories"
list.AddItem("1) "+fullText, "", 0, nil)
@@ -1098,436 +312,6 @@ func promptContinueWithPBSServicesTUI(configPath, buildSig string) (bool, error)
)
}
-func maybeApplyNetworkConfigTUI(ctx context.Context, logger *logging.Logger, plan *RestorePlan, safetyBackup, networkRollbackBackup *SafetyBackupResult, stageRoot, archivePath, configPath, buildSig string, dryRun bool) (err error) {
- if !shouldAttemptNetworkApply(plan) {
- if logger != nil {
- logger.Debug("Network safe apply (TUI): skipped (network category not selected)")
- }
- return nil
- }
- done := logging.DebugStart(logger, "network safe apply (tui)", "dryRun=%v euid=%d stage=%s archive=%s", dryRun, os.Geteuid(), strings.TrimSpace(stageRoot), strings.TrimSpace(archivePath))
- defer func() { done(err) }()
-
- if !isRealRestoreFS(restoreFS) {
- logger.Debug("Skipping live network apply: non-system filesystem in use")
- return nil
- }
- if dryRun {
- logger.Info("Dry run enabled: skipping live network apply")
- return nil
- }
- if os.Geteuid() != 0 {
- logger.Warning("Skipping live network apply: requires root privileges")
- return nil
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Resolve rollback backup paths")
- networkRollbackPath := ""
- if networkRollbackBackup != nil {
- networkRollbackPath = strings.TrimSpace(networkRollbackBackup.BackupPath)
- }
- fullRollbackPath := ""
- if safetyBackup != nil {
- fullRollbackPath = strings.TrimSpace(safetyBackup.BackupPath)
- }
- logging.DebugStep(logger, "network safe apply (tui)", "Rollback backup resolved: network=%q full=%q", networkRollbackPath, fullRollbackPath)
- if networkRollbackPath == "" && fullRollbackPath == "" {
- logger.Warning("Skipping live network apply: rollback backup not available")
- if strings.TrimSpace(stageRoot) != "" {
- logger.Info("Network configuration is staged; skipping NIC repair/apply due to missing rollback backup.")
- return nil
- }
- repairNow, err := promptYesNoTUIFunc(
- "NIC name repair (recommended)",
- configPath,
- buildSig,
- "Attempt NIC name repair in restored network config files now (no reload)?\n\nThis will only rewrite /etc/network/interfaces and /etc/network/interfaces.d/* when safe mappings are found.",
- "Repair now",
- "Skip repair",
- )
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User choice: repairNow=%v", repairNow)
- if repairNow {
- if repair := maybeRepairNICNamesTUI(ctx, logger, archivePath, configPath, buildSig); repair != nil {
- _ = promptOkTUI("NIC repair result", configPath, buildSig, repair.Details(), "OK")
- }
- }
- logger.Info("Skipping live network apply (you can reboot or apply manually later).")
- return nil
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Prompt: apply network now with rollback timer")
- sourceLine := "Source: /etc/network (will be applied)"
- if strings.TrimSpace(stageRoot) != "" {
- sourceLine = fmt.Sprintf("Source: %s (will be copied to /etc and applied)", strings.TrimSpace(stageRoot))
- }
- message := fmt.Sprintf(
- "Network restore: a restored network configuration is ready to apply.\n%s\n\nThis will reload networking immediately (no reboot).\n\nWARNING: This may change the active IP and disconnect SSH/Web sessions.\n\nAfter applying, type COMMIT within %ds or ProxSave will roll back automatically.\n\nRecommendation: run this step from the local console/IPMI, not over SSH.\n\nApply network configuration now?",
- sourceLine,
- int(defaultNetworkRollbackTimeout.Seconds()),
- )
- applyNow, err := promptYesNoTUIWithCountdown(
- ctx,
- logger,
- "Apply network configuration",
- configPath,
- buildSig,
- message,
- "Apply now",
- "Skip apply",
- 90*time.Second,
- )
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User choice: applyNow=%v", applyNow)
- if !applyNow {
- if strings.TrimSpace(stageRoot) == "" {
- repairNow, err := promptYesNoTUIFunc(
- "NIC name repair (recommended)",
- configPath,
- buildSig,
- "Attempt NIC name repair in restored network config files now (no reload)?\n\nThis will only rewrite /etc/network/interfaces and /etc/network/interfaces.d/* when safe mappings are found.",
- "Repair now",
- "Skip repair",
- )
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User choice: repairNow=%v", repairNow)
- if repairNow {
- if repair := maybeRepairNICNamesTUI(ctx, logger, archivePath, configPath, buildSig); repair != nil {
- _ = promptOkTUI("NIC repair result", configPath, buildSig, repair.Details(), "OK")
- }
- }
- } else {
- logger.Info("Network configuration is staged (not yet written to /etc); skipping NIC repair prompt.")
- }
- logger.Info("Skipping live network apply (you can apply later).")
- return nil
- }
-
- rollbackPath := networkRollbackPath
- if rollbackPath == "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Prompt: network-only rollback missing; allow full rollback backup fallback")
- ok, err := promptYesNoTUIFunc(
- "Network-only rollback not available",
- configPath,
- buildSig,
- "Network-only rollback backup is not available.\n\nIf you proceed, the rollback timer will use the full safety backup, which may revert other restored categories.\n\nProceed anyway?",
- "Proceed with full rollback",
- "Skip apply",
- )
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User choice: allowFullRollback=%v", ok)
- if !ok {
- repairNow, err := promptYesNoTUIFunc(
- "NIC name repair (recommended)",
- configPath,
- buildSig,
- "Attempt NIC name repair in restored network config files now (no reload)?\n\nThis will only rewrite /etc/network/interfaces and /etc/network/interfaces.d/* when safe mappings are found.",
- "Repair now",
- "Skip repair",
- )
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User choice: repairNow=%v", repairNow)
- if repairNow {
- if repair := maybeRepairNICNamesTUI(ctx, logger, archivePath, configPath, buildSig); repair != nil {
- _ = promptOkTUI("NIC repair result", configPath, buildSig, repair.Details(), "OK")
- }
- }
- logger.Info("Skipping live network apply (you can reboot or apply manually later).")
- return nil
- }
- rollbackPath = fullRollbackPath
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Selected rollback backup: %s", rollbackPath)
- if err := applyNetworkWithRollbackTUI(ctx, logger, rollbackPath, networkRollbackPath, stageRoot, archivePath, configPath, buildSig, defaultNetworkRollbackTimeout, plan.SystemType); err != nil {
- return err
- }
- return nil
-}
-
-func applyNetworkWithRollbackTUI(ctx context.Context, logger *logging.Logger, rollbackBackupPath, networkRollbackPath, stageRoot, archivePath, configPath, buildSig string, timeout time.Duration, systemType SystemType) (err error) {
- done := logging.DebugStart(
- logger,
- "network safe apply (tui)",
- "rollbackBackup=%s networkRollback=%s timeout=%s systemType=%s stage=%s",
- strings.TrimSpace(rollbackBackupPath),
- strings.TrimSpace(networkRollbackPath),
- timeout,
- systemType,
- strings.TrimSpace(stageRoot),
- )
- defer func() { done(err) }()
-
- logging.DebugStep(logger, "network safe apply (tui)", "Create diagnostics directory")
- diagnosticsDir, err := createNetworkDiagnosticsDir()
- if err != nil {
- logger.Warning("Network diagnostics disabled: %v", err)
- diagnosticsDir = ""
- } else {
- logger.Info("Network diagnostics directory: %s", diagnosticsDir)
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Detect management interface (SSH/default route)")
- iface, source := detectManagementInterface(ctx, logger)
- if iface != "" {
- logger.Info("Detected management interface: %s (%s)", iface, source)
- }
-
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Capture network snapshot (before)")
- if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "before", 3*time.Second); err != nil {
- logger.Debug("Network snapshot before apply failed: %v", err)
- } else {
- logger.Debug("Network snapshot (before): %s", snap)
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Run baseline health checks (before)")
- healthBefore := runNetworkHealthChecks(ctx, networkHealthOptions{
- SystemType: systemType,
- Logger: logger,
- CommandTimeout: 3 * time.Second,
- EnableGatewayPing: false,
- ForceSSHRouteCheck: false,
- EnableDNSResolve: false,
- })
- if path, err := writeNetworkHealthReportFileNamed(diagnosticsDir, "health_before.txt", healthBefore); err != nil {
- logger.Debug("Failed to write network health (before) report: %v", err)
- } else {
- logger.Debug("Network health (before) report: %s", path)
- }
- }
-
- if strings.TrimSpace(stageRoot) != "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Apply staged network files to system paths (before NIC repair)")
- applied, err := applyNetworkFilesFromStage(logger, stageRoot)
- if err != nil {
- return err
- }
- if len(applied) > 0 {
- logging.DebugStep(logger, "network safe apply (tui)", "Staged network files written: %d", len(applied))
- }
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "NIC name repair (optional)")
- nicRepair := maybeRepairNICNamesTUI(ctx, logger, archivePath, configPath, buildSig)
- if nicRepair != nil {
- if nicRepair.Applied() || nicRepair.SkippedReason != "" {
- logger.Info("%s", nicRepair.Summary())
- } else {
- logger.Debug("%s", nicRepair.Summary())
- }
- }
-
- if strings.TrimSpace(iface) != "" {
- if cur, err := currentNetworkEndpoint(ctx, iface, 2*time.Second); err == nil {
- if tgt, err := targetNetworkEndpointFromConfig(logger, iface); err == nil {
- logger.Info("Network plan: %s -> %s", cur.summary(), tgt.summary())
- }
- }
- }
-
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Write network plan (current -> target)")
- if planText, err := buildNetworkPlanReport(ctx, logger, iface, source, 2*time.Second); err != nil {
- logger.Debug("Network plan build failed: %v", err)
- } else if strings.TrimSpace(planText) != "" {
- if path, err := writeNetworkTextReportFile(diagnosticsDir, "plan.txt", planText+"\n"); err != nil {
- logger.Debug("Network plan write failed: %v", err)
- } else {
- logger.Debug("Network plan: %s", path)
- }
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Run ifquery diagnostic (pre-apply)")
- ifqueryPre := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
- if !ifqueryPre.Skipped {
- if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_pre_apply.txt", ifqueryPre); err != nil {
- logger.Debug("Failed to write ifquery (pre-apply) report: %v", err)
- } else {
- logger.Debug("ifquery (pre-apply) report: %s", path)
- }
- }
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Network preflight validation (ifupdown/ifupdown2)")
- preflight := runNetworkPreflightValidation(ctx, 5*time.Second, logger)
- if diagnosticsDir != "" {
- if path, err := writeNetworkPreflightReportFile(diagnosticsDir, preflight); err != nil {
- logger.Debug("Failed to write network preflight report: %v", err)
- } else {
- logger.Debug("Network preflight report: %s", path)
- }
- }
- if !preflight.Ok() {
- message := preflight.Summary()
- if strings.TrimSpace(diagnosticsDir) != "" {
- message += "\n\nDiagnostics saved under:\n" + diagnosticsDir
- }
- if out := strings.TrimSpace(preflight.Output); out != "" {
- message += "\n\nOutput:\n" + out
- }
- if strings.TrimSpace(stageRoot) != "" && strings.TrimSpace(networkRollbackPath) != "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Preflight failed in staged mode: rolling back network files automatically")
- rollbackLog, rbErr := rollbackNetworkFilesNow(ctx, logger, networkRollbackPath, diagnosticsDir)
- if strings.TrimSpace(rollbackLog) != "" {
- logger.Info("Network rollback log: %s", rollbackLog)
- }
- if rbErr != nil {
- logger.Error("Network apply aborted: preflight validation failed (%s) and rollback failed: %v", preflight.CommandLine(), rbErr)
- _ = promptOkTUI("Network rollback failed", configPath, buildSig, rbErr.Error(), "OK")
- return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr)
- }
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Capture network snapshot (after rollback)")
- if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "after_rollback", 3*time.Second); err != nil {
- logger.Debug("Network snapshot after rollback failed: %v", err)
- } else {
- logger.Debug("Network snapshot (after rollback): %s", snap)
- }
- logging.DebugStep(logger, "network safe apply (tui)", "Run ifquery diagnostic (after rollback)")
- ifqueryAfterRollback := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
- if !ifqueryAfterRollback.Skipped {
- if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_after_rollback.txt", ifqueryAfterRollback); err != nil {
- logger.Debug("Failed to write ifquery (after rollback) report: %v", err)
- } else {
- logger.Debug("ifquery (after rollback) report: %s", path)
- }
- }
- }
- logger.Warning(
- "Network apply aborted: preflight validation failed (%s). Rolled back /etc/network/*, /etc/hosts, /etc/hostname, /etc/resolv.conf to the pre-restore state (rollback=%s).",
- preflight.CommandLine(),
- strings.TrimSpace(networkRollbackPath),
- )
- _ = promptOkTUI(
- "Network preflight failed",
- configPath,
- buildSig,
- fmt.Sprintf("Network configuration failed preflight and was rolled back automatically.\n\nRollback log:\n%s", strings.TrimSpace(rollbackLog)),
- "OK",
- )
- return fmt.Errorf("network preflight validation failed; network files rolled back")
- }
- if !preflight.Skipped && preflight.ExitError != nil && strings.TrimSpace(networkRollbackPath) != "" {
- message += "\n\nRollback restored network config files to the pre-restore configuration now? (recommended)"
- rollbackNow, err := promptYesNoTUIFunc(
- "Network preflight failed",
- configPath,
- buildSig,
- message,
- "Rollback now",
- "Keep restored files",
- )
- if err != nil {
- return err
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User choice: rollbackNow=%v", rollbackNow)
- if rollbackNow {
- logging.DebugStep(logger, "network safe apply (tui)", "Rollback network files now (backup=%s)", strings.TrimSpace(networkRollbackPath))
- rollbackLog, rbErr := rollbackNetworkFilesNow(ctx, logger, networkRollbackPath, diagnosticsDir)
- if strings.TrimSpace(rollbackLog) != "" {
- logger.Info("Network rollback log: %s", rollbackLog)
- }
- if rbErr != nil {
- _ = promptOkTUI("Network rollback failed", configPath, buildSig, rbErr.Error(), "OK")
- return fmt.Errorf("network preflight validation failed; rollback attempt failed: %w", rbErr)
- }
- _ = promptOkTUI(
- "Network rollback completed",
- configPath,
- buildSig,
- fmt.Sprintf("Network files rolled back to pre-restore configuration.\n\nRollback log:\n%s", strings.TrimSpace(rollbackLog)),
- "OK",
- )
- return fmt.Errorf("network preflight validation failed; network files rolled back")
- }
- } else {
- _ = promptOkTUI("Network preflight failed", configPath, buildSig, message, "OK")
- }
- return fmt.Errorf("network preflight validation failed; aborting live network apply")
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Arm rollback timer BEFORE applying changes")
- handle, err := armNetworkRollback(ctx, logger, rollbackBackupPath, timeout, diagnosticsDir)
- if err != nil {
- return err
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Apply network configuration now")
- if err := applyNetworkConfig(ctx, logger); err != nil {
- logger.Warning("Network apply failed: %v", err)
- return err
- }
-
- if diagnosticsDir != "" {
- logging.DebugStep(logger, "network safe apply (tui)", "Capture network snapshot (after)")
- if snap, err := writeNetworkSnapshot(ctx, logger, diagnosticsDir, "after", 3*time.Second); err != nil {
- logger.Debug("Network snapshot after apply failed: %v", err)
- } else {
- logger.Debug("Network snapshot (after): %s", snap)
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Run ifquery diagnostic (post-apply)")
- ifqueryPost := runNetworkIfqueryDiagnostic(ctx, 5*time.Second, logger)
- if !ifqueryPost.Skipped {
- if path, err := writeNetworkIfqueryDiagnosticReportFile(diagnosticsDir, "ifquery_post_apply.txt", ifqueryPost); err != nil {
- logger.Debug("Failed to write ifquery (post-apply) report: %v", err)
- } else {
- logger.Debug("ifquery (post-apply) report: %s", path)
- }
- }
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Run post-apply health checks")
- health := runNetworkHealthChecks(ctx, networkHealthOptions{
- SystemType: systemType,
- Logger: logger,
- CommandTimeout: 3 * time.Second,
- EnableGatewayPing: true,
- ForceSSHRouteCheck: false,
- EnableDNSResolve: true,
- LocalPortChecks: defaultNetworkPortChecks(systemType),
- })
- logNetworkHealthReport(logger, health)
- if diagnosticsDir != "" {
- if path, err := writeNetworkHealthReportFile(diagnosticsDir, health); err != nil {
- logger.Debug("Failed to write network health report: %v", err)
- } else {
- logger.Debug("Network health report: %s", path)
- }
- }
-
- remaining := handle.remaining(time.Now())
- if remaining <= 0 {
- logger.Warning("Rollback window already expired; leaving rollback armed")
- return nil
- }
-
- logging.DebugStep(logger, "network safe apply (tui)", "Wait for COMMIT (rollback in %ds)", int(remaining.Seconds()))
- committed, err := promptNetworkCommitTUI(remaining, health, nicRepair, diagnosticsDir, configPath, buildSig)
- if err != nil {
- logger.Warning("Commit prompt error: %v", err)
- }
- logging.DebugStep(logger, "network safe apply (tui)", "User commit result: committed=%v", committed)
- if committed {
- disarmNetworkRollback(ctx, logger, handle)
- logger.Info("Network configuration committed successfully.")
- return nil
- }
- logger.Warning("Network configuration not committed; rollback will run automatically.")
- return nil
-}
-
func maybeRepairNICNamesTUI(ctx context.Context, logger *logging.Logger, archivePath, configPath, buildSig string) *nicRepairResult {
logging.DebugStep(logger, "NIC repair", "Plan NIC name repair (archive=%s)", strings.TrimSpace(archivePath))
plan, err := planNICNameRepair(ctx, archivePath)
@@ -1703,12 +487,12 @@ func buildRestorePlanText(config *SelectiveRestoreConfig) string {
modeName = "FULL restore (all categories)"
case RestoreModeStorage:
if config.SystemType == SystemTypePVE {
- modeName = "STORAGE only (PVE cluster + storage + jobs)"
+ modeName = "STORAGE only (cluster + storage + jobs + mounts)"
} else {
- modeName = "DATASTORE only (PBS config + datastores + jobs)"
+ modeName = "DATASTORE only (datastores + jobs + mounts)"
}
case RestoreModeBase:
- modeName = "SYSTEM BASE only (network + SSL + SSH + services)"
+ modeName = "SYSTEM BASE only (network + SSL + SSH + services + filesystem)"
case RestoreModeCustom:
modeName = fmt.Sprintf("CUSTOM selection (%d categories)", len(config.SelectedCategories))
default:
@@ -1736,6 +520,10 @@ func buildRestorePlanText(config *SelectiveRestoreConfig) string {
b.WriteString(" • Existing files at these locations will be OVERWRITTEN\n")
b.WriteString(" • A safety backup will be created before restoration\n")
b.WriteString(" • Services may need to be restarted after restoration\n\n")
+ if (hasCategoryID(config.SelectedCategories, "pve_access_control") || hasCategoryID(config.SelectedCategories, "pbs_access_control")) &&
+ (!hasCategoryID(config.SelectedCategories, "network") || !hasCategoryID(config.SelectedCategories, "ssl")) {
+ b.WriteString(" • TFA/WebAuthn: for best 1:1 compatibility keep the same UI origin (FQDN/hostname and port) and restore 'network' + 'ssl'\n\n")
+ }
return b.String()
}
@@ -1837,152 +625,6 @@ func confirmRestoreTUI(configPath, buildSig string) (bool, error) {
return true, nil
}
-func runFullRestoreTUI(ctx context.Context, candidate *decryptCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool, configPath, buildSig string) error {
- if candidate == nil || prepared == nil || prepared.Manifest.ArchivePath == "" {
- return fmt.Errorf("invalid restore candidate")
- }
-
- app := newTUIApp()
- manifest := candidate.Manifest
-
- var b strings.Builder
- fmt.Fprintf(&b, "Selected backup: %s (%s)\n",
- candidate.DisplayBase,
- manifest.CreatedAt.Format("2006-01-02 15:04:05"),
- )
- b.WriteString("Restore destination: / (system root; original paths will be preserved)\n")
- b.WriteString("WARNING: This operation will overwrite configuration files on this system.\n\n")
- b.WriteString("Press RESTORE to start the restore process, or Cancel to abort.\nYou will be asked for explicit confirmation before overwriting files.\n")
-
- infoText := tview.NewTextView().
- SetText(b.String()).
- SetWrap(true).
- SetTextColor(tcell.ColorWhite)
-
- form := components.NewForm(app)
- var confirmed bool
- var aborted bool
- form.SetOnSubmit(func(values map[string]string) error {
- confirmed = true
- return nil
- })
- form.SetOnCancel(func() {
- aborted = true
- })
- form.AddSubmitButton("RESTORE")
- form.AddCancelButton("Cancel")
- enableFormNavigation(form, nil)
-
- content := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(infoText, 4, 0, false).
- AddItem(form.Form, 0, 1, true)
-
- page := buildRestoreWizardPage("Full restore confirmation", configPath, buildSig, content)
- form.SetParentView(page)
-
- if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
- return err
- }
- if aborted || !confirmed {
- return ErrRestoreAborted
- }
-
- ok, err := confirmOverwriteTUI(configPath, buildSig)
- if err != nil {
- return err
- }
- if !ok {
- return ErrRestoreAborted
- }
-
- safeFstabMerge := destRoot == "/" && isRealRestoreFS(restoreFS)
- skipFn := func(name string) bool {
- if !safeFstabMerge {
- return false
- }
- clean := strings.TrimPrefix(strings.TrimSpace(name), "./")
- clean = strings.TrimPrefix(clean, "/")
- return clean == "etc/fstab"
- }
-
- if safeFstabMerge {
- logger.Warning("Full restore safety: /etc/fstab will not be overwritten; Smart Merge will be offered after extraction.")
- }
-
- if err := extractPlainArchive(ctx, prepared.ArchivePath, destRoot, logger, skipFn); err != nil {
- return err
- }
-
- if safeFstabMerge {
- fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-")
- if err != nil {
- logger.Warning("Failed to create temp dir for fstab merge: %v", err)
- } else {
- defer restoreFS.RemoveAll(fsTempDir)
- fsCategory := []Category{{
- ID: "filesystem",
- Name: "Filesystem Configuration",
- Paths: []string{
- "./etc/fstab",
- },
- }}
- if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil {
- logger.Warning("Failed to extract filesystem config for merge: %v", err)
- } else {
- currentFstab := filepath.Join(destRoot, "etc", "fstab")
- backupFstab := filepath.Join(fsTempDir, "etc", "fstab")
- currentEntries, currentRaw, err := parseFstab(currentFstab)
- if err != nil {
- logger.Warning("Failed to parse current fstab: %v", err)
- } else if backupEntries, _, err := parseFstab(backupFstab); err != nil {
- logger.Warning("Failed to parse backup fstab: %v", err)
- } else {
- analysis := analyzeFstabMerge(logger, currentEntries, backupEntries)
- if len(analysis.ProposedMounts) == 0 {
- logger.Info("No new safe mounts found to restore. Keeping current fstab.")
- } else {
- var msg strings.Builder
- msg.WriteString("ProxSave found missing mounts in /etc/fstab.\n\n")
- if analysis.RootComparable && !analysis.RootMatch {
- msg.WriteString("⚠ Root UUID mismatch: the backup appears to come from a different machine.\n")
- }
- if analysis.SwapComparable && !analysis.SwapMatch {
- msg.WriteString("⚠ Swap mismatch: the current swap configuration will be kept.\n")
- }
- msg.WriteString("\nProposed mounts (safe):\n")
- for _, m := range analysis.ProposedMounts {
- fmt.Fprintf(&msg, " - %s -> %s (%s)\n", m.Device, m.MountPoint, m.Type)
- }
- if len(analysis.SkippedMounts) > 0 {
- msg.WriteString("\nMounts found but not auto-proposed:\n")
- for _, m := range analysis.SkippedMounts {
- fmt.Fprintf(&msg, " - %s -> %s (%s)\n", m.Device, m.MountPoint, m.Type)
- }
- msg.WriteString("\nHint: verify disks/UUIDs and options (nofail/_netdev) before adding them.\n")
- }
-
- apply, perr := promptYesNoTUIWithCountdown(ctx, logger, "Smart fstab merge", configPath, buildSig, msg.String(), "Apply", "Skip", 90*time.Second)
- if perr != nil {
- return perr
- }
- if apply {
- if err := applyFstabMerge(ctx, logger, currentRaw, currentFstab, analysis.ProposedMounts, dryRun); err != nil {
- logger.Warning("Smart Fstab Merge failed: %v", err)
- }
- } else {
- logger.Info("Fstab merge skipped by user.")
- }
- }
- }
- }
- }
- }
-
- logger.Info("Restore completed successfully.")
- return nil
-}
-
func promptYesNoTUI(title, configPath, buildSig, message, yesLabel, noLabel string) (bool, error) {
app := newTUIApp()
var result bool
diff --git a/internal/orchestrator/restore_tui_test.go b/internal/orchestrator/restore_tui_test.go
index ac8fb42..d7f1523 100644
--- a/internal/orchestrator/restore_tui_test.go
+++ b/internal/orchestrator/restore_tui_test.go
@@ -4,13 +4,8 @@ import (
"errors"
"strings"
"testing"
- "time"
"github.com/rivo/tview"
-
- "github.com/tis24dev/proxsave/internal/backup"
- "github.com/tis24dev/proxsave/internal/tui"
- "github.com/tis24dev/proxsave/internal/tui/components"
)
func TestFilterAndSortCategoriesForSystem(t *testing.T) {
@@ -169,85 +164,6 @@ func TestConfirmOverwriteTUI(t *testing.T) {
}
}
-func TestShowRestoreErrorModalAddsWizardPage(t *testing.T) {
- app := tui.NewApp()
- pages := tview.NewPages()
-
- showRestoreErrorModal(app, pages, "cfg", "sig", "boom", nil)
-
- if !pages.HasPage(restoreErrorModalPage) {
- t.Fatalf("expected %q page to be present", restoreErrorModalPage)
- }
- page := pages.GetPage(restoreErrorModalPage)
- flex, ok := page.(*tview.Flex)
- if !ok {
- t.Fatalf("expected *tview.Flex, got %T", page)
- }
- content := flex.GetItem(3)
- modal, ok := content.(*tview.Modal)
- if !ok {
- t.Fatalf("expected *tview.Modal content, got %T", content)
- }
- if modal.GetTitle() != " Restore Error " {
- t.Fatalf("modal title=%q; want %q", modal.GetTitle(), " Restore Error ")
- }
-}
-
-func TestShowRestoreCandidatePageAddsCandidatesPageWithItems(t *testing.T) {
- app := tui.NewApp()
- pages := tview.NewPages()
-
- now := time.Unix(1700000000, 0)
- candidates := []*decryptCandidate{
- {
- Manifest: &backup.Manifest{
- CreatedAt: now,
- EncryptionMode: "age",
- ProxmoxTargets: []string{"pve"},
- ProxmoxVersion: "8.1",
- CompressionType: "zstd",
- ClusterMode: "standalone",
- ScriptVersion: "1.0.0",
- },
- },
- {
- Manifest: &backup.Manifest{
- CreatedAt: now.Add(-time.Hour),
- EncryptionMode: "age",
- ProxmoxTargets: []string{"pbs"},
- CompressionType: "xz",
- ScriptVersion: "1.0.0",
- },
- },
- }
-
- showRestoreCandidatePage(app, pages, candidates, "cfg", "sig", func(*decryptCandidate) {}, func() {})
-
- if !pages.HasPage("candidates") {
- t.Fatalf("expected candidates page to be present")
- }
- page := pages.GetPage("candidates")
- flex, ok := page.(*tview.Flex)
- if !ok {
- t.Fatalf("expected *tview.Flex, got %T", page)
- }
- content := flex.GetItem(3)
- form, ok := content.(*tview.Form)
- if !ok {
- t.Fatalf("expected *tview.Form content, got %T", content)
- }
- if form.GetFormItemCount() != 1 {
- t.Fatalf("form items=%d; want 1", form.GetFormItemCount())
- }
- listItem, ok := form.GetFormItem(0).(*components.ListFormItem)
- if !ok {
- t.Fatalf("expected *components.ListFormItem, got %T", form.GetFormItem(0))
- }
- if got := listItem.GetItemCount(); got != len(candidates) {
- t.Fatalf("list items=%d; want %d", got, len(candidates))
- }
-}
-
func stubPromptYesNo(fn func(title, configPath, buildSig, message, yesLabel, noLabel string) (bool, error)) func() {
orig := promptYesNoTUIFunc
promptYesNoTUIFunc = fn
diff --git a/internal/orchestrator/restore_workflow_abort_test.go b/internal/orchestrator/restore_workflow_abort_test.go
index 329daa1..5a1fed1 100644
--- a/internal/orchestrator/restore_workflow_abort_test.go
+++ b/internal/orchestrator/restore_workflow_abort_test.go
@@ -1,7 +1,6 @@
package orchestrator
import (
- "bufio"
"context"
"os"
"path/filepath"
@@ -10,6 +9,7 @@ import (
"github.com/tis24dev/proxsave/internal/backup"
"github.com/tis24dev/proxsave/internal/config"
+ "github.com/tis24dev/proxsave/internal/input"
"github.com/tis24dev/proxsave/internal/logging"
"github.com/tis24dev/proxsave/internal/types"
)
@@ -17,21 +17,19 @@ import (
func TestRunRestoreWorkflow_FstabPromptInputAborted_AbortsWorkflow(t *testing.T) {
origRestoreFS := restoreFS
origRestoreCmd := restoreCmd
- origRestorePrompter := restorePrompter
origRestoreSystem := restoreSystem
origRestoreTime := restoreTime
origCompatFS := compatFS
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
origSafetyFS := safetyFS
origSafetyNow := safetyNow
t.Cleanup(func() {
restoreFS = origRestoreFS
restoreCmd = origRestoreCmd
- restorePrompter = origRestorePrompter
restoreSystem = origRestoreSystem
restoreTime = origRestoreTime
compatFS = origCompatFS
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
safetyFS = origSafetyFS
safetyNow = origSafetyNow
})
@@ -74,13 +72,7 @@ func TestRunRestoreWorkflow_FstabPromptInputAborted_AbortsWorkflow(t *testing.T)
t.Fatalf("fakeFS.WriteFile(/bundle.tar): %v", err)
}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: []Category{mustCategoryByID(t, "filesystem")},
- confirmed: true,
- }
-
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -98,46 +90,17 @@ func TestRunRestoreWorkflow_FstabPromptInputAborted_AbortsWorkflow(t *testing.T)
return cand, prepared, nil
}
- // Simulate Ctrl+C behavior: stdin closed -> input.ErrInputAborted during the fstab prompt.
- oldIn := os.Stdin
- oldOut := os.Stdout
- oldErr := os.Stderr
- t.Cleanup(func() {
- os.Stdin = oldIn
- os.Stdout = oldOut
- os.Stderr = oldErr
- })
- inR, inW, err := os.Pipe()
- if err != nil {
- t.Fatalf("os.Pipe: %v", err)
- }
- out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- errOut, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- _ = out.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- os.Stdin = inR
- os.Stdout = out
- os.Stderr = errOut
- t.Cleanup(func() {
- _ = inR.Close()
- _ = out.Close()
- _ = errOut.Close()
- })
- _ = inW.Close()
-
logger := logging.New(types.LogLevelError, false)
cfg := &config.Config{BaseDir: "/base"}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: []Category{mustCategoryByID(t, "filesystem")},
+ confirmRestore: true,
+ confirmFstabMerge: false,
+ confirmFstabMergeErr: input.ErrInputAborted,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != ErrRestoreAborted {
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != ErrRestoreAborted {
t.Fatalf("err=%v; want %v", err, ErrRestoreAborted)
}
}
diff --git a/internal/orchestrator/restore_workflow_integration_test.go b/internal/orchestrator/restore_workflow_integration_test.go
index de7e412..294c8d5 100644
--- a/internal/orchestrator/restore_workflow_integration_test.go
+++ b/internal/orchestrator/restore_workflow_integration_test.go
@@ -70,7 +70,9 @@ func TestRunSafeClusterApply_PveshNotFound(t *testing.T) {
func TestDetectConfiguredZFSPools_Empty(t *testing.T) {
orig := restoreFS
defer func() { restoreFS = orig }()
- restoreFS = NewFakeFS()
+ fakeFS := NewFakeFS()
+ defer func() { _ = os.RemoveAll(fakeFS.Root) }()
+ restoreFS = fakeFS
if pools := detectConfiguredZFSPools(); len(pools) != 0 {
t.Fatalf("expected no pools, got %v", pools)
}
diff --git a/internal/orchestrator/restore_workflow_more_test.go b/internal/orchestrator/restore_workflow_more_test.go
index d9d4bff..41fa569 100644
--- a/internal/orchestrator/restore_workflow_more_test.go
+++ b/internal/orchestrator/restore_workflow_more_test.go
@@ -1,7 +1,6 @@
package orchestrator
import (
- "bufio"
"context"
"errors"
"os"
@@ -30,21 +29,19 @@ func mustCategoryByID(t *testing.T, id string) Category {
func TestRunRestoreWorkflow_ClusterBackupSafeMode_ExportsClusterAndRestoresNetwork(t *testing.T) {
origRestoreFS := restoreFS
origRestoreCmd := restoreCmd
- origRestorePrompter := restorePrompter
origRestoreSystem := restoreSystem
origRestoreTime := restoreTime
origCompatFS := compatFS
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
origSafetyFS := safetyFS
origSafetyNow := safetyNow
t.Cleanup(func() {
restoreFS = origRestoreFS
restoreCmd = origRestoreCmd
- restorePrompter = origRestorePrompter
restoreSystem = origRestoreSystem
restoreTime = origRestoreTime
compatFS = origCompatFS
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
safetyFS = origSafetyFS
safetyNow = origSafetyNow
})
@@ -84,17 +81,7 @@ func TestRunRestoreWorkflow_ClusterBackupSafeMode_ExportsClusterAndRestoresNetwo
t.Fatalf("fakeFS.WriteFile: %v", err)
}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: []Category{
- mustCategoryByID(t, "network"),
- mustCategoryByID(t, "pve_cluster"),
- mustCategoryByID(t, "pve_config_export"),
- },
- confirmed: true,
- }
-
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -112,42 +99,23 @@ func TestRunRestoreWorkflow_ClusterBackupSafeMode_ExportsClusterAndRestoresNetwo
return cand, prepared, nil
}
- oldIn := os.Stdin
- oldOut := os.Stdout
- t.Cleanup(func() {
- os.Stdin = oldIn
- os.Stdout = oldOut
- })
- inR, inW, err := os.Pipe()
- if err != nil {
- t.Fatalf("os.Pipe: %v", err)
- }
- out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- os.Stdin = inR
- os.Stdout = out
- t.Cleanup(func() {
- _ = inR.Close()
- _ = out.Close()
- })
-
- // Cluster restore prompt -> SAFE mode.
- if _, err := inW.WriteString("1\n"); err != nil {
- t.Fatalf("WriteString: %v", err)
- }
- _ = inW.Close()
-
t.Setenv("PATH", "") // ensure pvesh is not found for SAFE apply
logger := logging.New(types.LogLevelError, false)
cfg := &config.Config{BaseDir: "/base"}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: []Category{
+ mustCategoryByID(t, "network"),
+ mustCategoryByID(t, "pve_cluster"),
+ mustCategoryByID(t, "pve_config_export"),
+ },
+ confirmRestore: true,
+ clusterMode: ClusterRestoreSafe,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil {
- t.Fatalf("RunRestoreWorkflow error: %v", err)
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != nil {
+ t.Fatalf("runRestoreWorkflowWithUI error: %v", err)
}
hosts, err := fakeFS.ReadFile("/etc/hosts")
@@ -173,21 +141,19 @@ func TestRunRestoreWorkflow_ClusterBackupSafeMode_ExportsClusterAndRestoresNetwo
func TestRunRestoreWorkflow_PBSStopsServicesAndChecksZFSWhenSelected(t *testing.T) {
origRestoreFS := restoreFS
origRestoreCmd := restoreCmd
- origRestorePrompter := restorePrompter
origRestoreSystem := restoreSystem
origRestoreTime := restoreTime
origCompatFS := compatFS
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
origSafetyFS := safetyFS
origSafetyNow := safetyNow
t.Cleanup(func() {
restoreFS = origRestoreFS
restoreCmd = origRestoreCmd
- restorePrompter = origRestorePrompter
restoreSystem = origRestoreSystem
restoreTime = origRestoreTime
compatFS = origCompatFS
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
safetyFS = origSafetyFS
safetyNow = origSafetyNow
})
@@ -240,16 +206,7 @@ func TestRunRestoreWorkflow_PBSStopsServicesAndChecksZFSWhenSelected(t *testing.
t.Fatalf("fakeFS.WriteFile: %v", err)
}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: []Category{
- mustCategoryByID(t, "pbs_jobs"),
- mustCategoryByID(t, "zfs"),
- },
- confirmed: true,
- }
-
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -269,9 +226,17 @@ func TestRunRestoreWorkflow_PBSStopsServicesAndChecksZFSWhenSelected(t *testing.
logger := logging.New(types.LogLevelError, false)
cfg := &config.Config{BaseDir: "/base"}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: []Category{
+ mustCategoryByID(t, "pbs_jobs"),
+ mustCategoryByID(t, "zfs"),
+ },
+ confirmRestore: true,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil {
- t.Fatalf("RunRestoreWorkflow error: %v", err)
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != nil {
+ t.Fatalf("runRestoreWorkflowWithUI error: %v", err)
}
if _, err := fakeFS.ReadFile("/etc/proxmox-backup/sync.cfg"); err != nil {
@@ -314,21 +279,19 @@ func TestRunRestoreWorkflow_IncompatibilityAndSafetyBackupFailureCanContinue(t *
origRestoreFS := restoreFS
origRestoreCmd := restoreCmd
- origRestorePrompter := restorePrompter
origRestoreSystem := restoreSystem
origRestoreTime := restoreTime
origCompatFS := compatFS
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
origSafetyFS := safetyFS
origSafetyNow := safetyNow
t.Cleanup(func() {
restoreFS = origRestoreFS
restoreCmd = origRestoreCmd
- restorePrompter = origRestorePrompter
restoreSystem = origRestoreSystem
restoreTime = origRestoreTime
compatFS = origCompatFS
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
safetyFS = origSafetyFS
safetyNow = origSafetyNow
})
@@ -370,15 +333,7 @@ func TestRunRestoreWorkflow_IncompatibilityAndSafetyBackupFailureCanContinue(t *
t.Fatalf("restoreSandbox.WriteFile: %v", err)
}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: []Category{
- mustCategoryByID(t, "network"),
- },
- confirmed: true,
- }
-
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -396,40 +351,18 @@ func TestRunRestoreWorkflow_IncompatibilityAndSafetyBackupFailureCanContinue(t *
return cand, prepared, nil
}
- oldIn := os.Stdin
- oldOut := os.Stdout
- t.Cleanup(func() {
- os.Stdin = oldIn
- os.Stdout = oldOut
- })
- inR, inW, err := os.Pipe()
- if err != nil {
- t.Fatalf("os.Pipe: %v", err)
- }
- out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- os.Stdin = inR
- os.Stdout = out
- t.Cleanup(func() {
- _ = inR.Close()
- _ = out.Close()
- })
-
- // Compatibility prompt -> continue; safety backup failure prompt -> continue.
- if _, err := inW.WriteString("yes\nyes\n"); err != nil {
- t.Fatalf("WriteString: %v", err)
- }
- _ = inW.Close()
-
logger := logging.New(types.LogLevelError, false)
cfg := &config.Config{BaseDir: "/base"}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: []Category{mustCategoryByID(t, "network")},
+ confirmRestore: true,
+ confirmCompatible: true,
+ continueNoSafety: true,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil {
- t.Fatalf("RunRestoreWorkflow error: %v", err)
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != nil {
+ t.Fatalf("runRestoreWorkflowWithUI error: %v", err)
}
if _, err := restoreSandbox.ReadFile("/etc/hosts"); err != nil {
@@ -440,21 +373,19 @@ func TestRunRestoreWorkflow_IncompatibilityAndSafetyBackupFailureCanContinue(t *
func TestRunRestoreWorkflow_ClusterRecoveryModeStopsAndRestartsServices(t *testing.T) {
origRestoreFS := restoreFS
origRestoreCmd := restoreCmd
- origRestorePrompter := restorePrompter
origRestoreSystem := restoreSystem
origRestoreTime := restoreTime
origCompatFS := compatFS
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
origSafetyFS := safetyFS
origSafetyNow := safetyNow
t.Cleanup(func() {
restoreFS = origRestoreFS
restoreCmd = origRestoreCmd
- restorePrompter = origRestorePrompter
restoreSystem = origRestoreSystem
restoreTime = origRestoreTime
compatFS = origCompatFS
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
safetyFS = origSafetyFS
safetyNow = origSafetyNow
})
@@ -506,16 +437,7 @@ func TestRunRestoreWorkflow_ClusterRecoveryModeStopsAndRestartsServices(t *testi
t.Fatalf("fakeFS.WriteFile: %v", err)
}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: []Category{
- mustCategoryByID(t, "network"),
- mustCategoryByID(t, "pve_cluster"),
- },
- confirmed: true,
- }
-
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -533,40 +455,20 @@ func TestRunRestoreWorkflow_ClusterRecoveryModeStopsAndRestartsServices(t *testi
return cand, prepared, nil
}
- oldIn := os.Stdin
- oldOut := os.Stdout
- t.Cleanup(func() {
- os.Stdin = oldIn
- os.Stdout = oldOut
- })
- inR, inW, err := os.Pipe()
- if err != nil {
- t.Fatalf("os.Pipe: %v", err)
- }
- out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- os.Stdin = inR
- os.Stdout = out
- t.Cleanup(func() {
- _ = inR.Close()
- _ = out.Close()
- })
-
- // Cluster restore prompt -> RECOVERY mode.
- if _, err := inW.WriteString("2\n"); err != nil {
- t.Fatalf("WriteString: %v", err)
- }
- _ = inW.Close()
-
logger := logging.New(types.LogLevelError, false)
cfg := &config.Config{BaseDir: "/base"}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: []Category{
+ mustCategoryByID(t, "network"),
+ mustCategoryByID(t, "pve_cluster"),
+ },
+ confirmRestore: true,
+ clusterMode: ClusterRestoreRecovery,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil {
- t.Fatalf("RunRestoreWorkflow error: %v", err)
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != nil {
+ t.Fatalf("runRestoreWorkflowWithUI error: %v", err)
}
for _, want := range []string{
diff --git a/internal/orchestrator/restore_workflow_test.go b/internal/orchestrator/restore_workflow_test.go
index 6358cb9..e6d836f 100644
--- a/internal/orchestrator/restore_workflow_test.go
+++ b/internal/orchestrator/restore_workflow_test.go
@@ -2,7 +2,6 @@ package orchestrator
import (
"archive/tar"
- "bufio"
"context"
"os"
"path/filepath"
@@ -20,28 +19,6 @@ type fakeSystemDetector struct {
func (f fakeSystemDetector) DetectCurrentSystem() SystemType { return f.systemType }
-type fakeRestorePrompter struct {
- mode RestoreMode
- categories []Category
- confirmed bool
-
- modeErr error
- categoriesErr error
- confirmErr error
-}
-
-func (f fakeRestorePrompter) SelectRestoreMode(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) {
- return f.mode, f.modeErr
-}
-
-func (f fakeRestorePrompter) SelectCategories(ctx context.Context, logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) {
- return f.categories, f.categoriesErr
-}
-
-func (f fakeRestorePrompter) ConfirmRestore(ctx context.Context, logger *logging.Logger) (bool, error) {
- return f.confirmed, f.confirmErr
-}
-
func writeMinimalTar(t *testing.T, dir string) string {
t.Helper()
@@ -77,14 +54,12 @@ func writeMinimalTar(t *testing.T, dir string) string {
func TestRunRestoreWorkflow_CustomModeNoCategories_Succeeds(t *testing.T) {
origCompatFS := compatFS
- origPrompter := restorePrompter
origSystem := restoreSystem
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
t.Cleanup(func() {
compatFS = origCompatFS
- restorePrompter = origPrompter
restoreSystem = origSystem
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
})
fakeCompat := NewFakeFS()
@@ -95,15 +70,10 @@ func TestRunRestoreWorkflow_CustomModeNoCategories_Succeeds(t *testing.T) {
compatFS = fakeCompat
restoreSystem = fakeSystemDetector{systemType: SystemTypePVE}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: nil,
- confirmed: true,
- }
tmp := t.TempDir()
archivePath := writeMinimalTar(t, tmp)
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -122,22 +92,25 @@ func TestRunRestoreWorkflow_CustomModeNoCategories_Succeeds(t *testing.T) {
logger := logging.New(logging.GetDefaultLogger().GetLevel(), false)
cfg := &config.Config{BaseDir: tmp}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: nil,
+ confirmRestore: true,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil {
- t.Fatalf("RunRestoreWorkflow error: %v", err)
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != nil {
+ t.Fatalf("runRestoreWorkflowWithUI error: %v", err)
}
}
func TestRunRestoreWorkflow_ConfirmFalseAborts(t *testing.T) {
origCompatFS := compatFS
- origPrompter := restorePrompter
origSystem := restoreSystem
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
t.Cleanup(func() {
compatFS = origCompatFS
- restorePrompter = origPrompter
restoreSystem = origSystem
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
})
fakeCompat := NewFakeFS()
@@ -148,15 +121,10 @@ func TestRunRestoreWorkflow_ConfirmFalseAborts(t *testing.T) {
compatFS = fakeCompat
restoreSystem = fakeSystemDetector{systemType: SystemTypePVE}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: nil,
- confirmed: false,
- }
tmp := t.TempDir()
archivePath := writeMinimalTar(t, tmp)
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -175,8 +143,13 @@ func TestRunRestoreWorkflow_ConfirmFalseAborts(t *testing.T) {
logger := logging.New(logging.GetDefaultLogger().GetLevel(), false)
cfg := &config.Config{BaseDir: tmp}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: nil,
+ confirmRestore: false,
+ }
- err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest")
+ err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui)
if err != ErrRestoreAborted {
t.Fatalf("err=%v; want %v", err, ErrRestoreAborted)
}
diff --git a/internal/orchestrator/restore_workflow_ui.go b/internal/orchestrator/restore_workflow_ui.go
new file mode 100644
index 0000000..b07bc37
--- /dev/null
+++ b/internal/orchestrator/restore_workflow_ui.go
@@ -0,0 +1,1204 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/config"
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+var prepareRestoreBundleFunc = prepareRestoreBundleWithUI
+
+func prepareRestoreBundleWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
+ candidate, err := selectBackupCandidateWithUI(ctx, ui, cfg, logger, false)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ prepared, err := preparePlainBundleWithUI(ctx, candidate, version, logger, ui)
+ if err != nil {
+ return nil, nil, err
+ }
+ return candidate, prepared, nil
+}
+
+func runRestoreWorkflowWithUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (err error) {
+ if cfg == nil {
+ return fmt.Errorf("configuration not available")
+ }
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+
+ done := logging.DebugStart(logger, "restore workflow (ui)", "version=%s", version)
+ defer func() { done(err) }()
+
+ restoreHadWarnings := false
+ defer func() {
+ if err == nil {
+ return
+ }
+ if err == io.EOF {
+ logger.Warning("Restore input closed unexpectedly (EOF). This usually means the interactive UI lost access to stdin/TTY (e.g., SSH disconnect or non-interactive execution). Re-run with --restore --cli from an interactive shell.")
+ err = ErrRestoreAborted
+ return
+ }
+ if errors.Is(err, input.ErrInputAborted) ||
+ errors.Is(err, ErrDecryptAborted) ||
+ errors.Is(err, ErrAgeRecipientSetupAborted) ||
+ errors.Is(err, context.Canceled) ||
+ (ctx != nil && ctx.Err() != nil) {
+ err = ErrRestoreAborted
+ }
+ }()
+
+ candidate, prepared, err := prepareRestoreBundleFunc(ctx, cfg, logger, version, ui)
+ if err != nil {
+ return err
+ }
+ defer prepared.Cleanup()
+
+ destRoot := "/"
+ logger.Info("Restore target: system root (/) — files will be written back to their original paths")
+
+ systemType := restoreSystem.DetectCurrentSystem()
+ logger.Info("Detected system type: %s", GetSystemTypeString(systemType))
+
+ if warn := ValidateCompatibility(candidate.Manifest); warn != nil {
+ logger.Warning("Compatibility check: %v", warn)
+ proceed, perr := ui.ConfirmCompatibility(ctx, warn)
+ if perr != nil {
+ return perr
+ }
+ if !proceed {
+ return ErrRestoreAborted
+ }
+ }
+
+ logger.Info("Analyzing backup contents...")
+ availableCategories, err := AnalyzeBackupCategories(prepared.ArchivePath, logger)
+ if err != nil {
+ logger.Warning("Could not analyze categories: %v", err)
+ logger.Info("Falling back to full restore mode")
+ return runFullRestoreWithUI(ctx, ui, candidate, prepared, destRoot, logger, cfg.DryRun)
+ }
+
+ var (
+ mode RestoreMode
+ selectedCategories []Category
+ )
+ for {
+ mode, err = ui.SelectRestoreMode(ctx, systemType)
+ if err != nil {
+ return err
+ }
+
+ if mode != RestoreModeCustom {
+ selectedCategories = GetCategoriesForMode(mode, systemType, availableCategories)
+ break
+ }
+
+ selectedCategories, err = ui.SelectCategories(ctx, availableCategories, systemType)
+ if err != nil {
+ if errors.Is(err, errRestoreBackToMode) {
+ continue
+ }
+ return err
+ }
+ break
+ }
+
+ if mode == RestoreModeCustom {
+ selectedCategories, err = maybeAddRecommendedCategoriesForTFA(ctx, ui, logger, selectedCategories, availableCategories)
+ if err != nil {
+ return err
+ }
+ }
+
+ plan := PlanRestore(candidate.Manifest, selectedCategories, systemType, mode)
+
+ clusterBackup := strings.EqualFold(strings.TrimSpace(candidate.Manifest.ClusterMode), "cluster")
+ if plan.NeedsClusterRestore && clusterBackup {
+ logger.Info("Backup marked as cluster node; enabling guarded restore options for pve_cluster")
+ choice, promptErr := ui.SelectClusterRestoreMode(ctx)
+ if promptErr != nil {
+ return promptErr
+ }
+ switch choice {
+ case ClusterRestoreAbort:
+ return ErrRestoreAborted
+ case ClusterRestoreSafe:
+ plan.ApplyClusterSafeMode(true)
+ logger.Info("Selected SAFE cluster restore: /var/lib/pve-cluster will be exported only, not written to system")
+ case ClusterRestoreRecovery:
+ plan.ApplyClusterSafeMode(false)
+ logger.Warning("Selected RECOVERY cluster restore: full cluster database will be restored; ensure other nodes are isolated")
+ default:
+ return fmt.Errorf("invalid cluster restore mode selected")
+ }
+ }
+
+ if plan.HasCategoryID("pve_access_control") || plan.HasCategoryID("pbs_access_control") {
+ currentHost, hostErr := os.Hostname()
+ if hostErr == nil && strings.TrimSpace(candidate.Manifest.Hostname) != "" && strings.TrimSpace(currentHost) != "" {
+ backupHost := strings.TrimSpace(candidate.Manifest.Hostname)
+ if !strings.EqualFold(strings.TrimSpace(currentHost), backupHost) {
+ logger.Warning("Access control/TFA: backup hostname=%s current hostname=%s; WebAuthn users may require re-enrollment if the UI origin (FQDN/port) changes", backupHost, currentHost)
+ }
+ }
+ }
+
+ if destRoot != "/" || !isRealRestoreFS(restoreFS) {
+ if len(plan.StagedCategories) > 0 {
+ logging.DebugStep(logger, "restore", "Staging disabled (destRoot=%s realFS=%v): extracting %d staged category(ies) directly", destRoot, isRealRestoreFS(restoreFS), len(plan.StagedCategories))
+ plan.NormalCategories = append(plan.NormalCategories, plan.StagedCategories...)
+ plan.StagedCategories = nil
+ }
+ }
+
+ restoreConfig := &SelectiveRestoreConfig{
+ Mode: mode,
+ SystemType: systemType,
+ Metadata: candidate.Manifest,
+ }
+ restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.NormalCategories...)
+ restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.StagedCategories...)
+ restoreConfig.SelectedCategories = append(restoreConfig.SelectedCategories, plan.ExportCategories...)
+
+ if err := ui.ShowRestorePlan(ctx, restoreConfig); err != nil {
+ return err
+ }
+
+ confirmed, err := ui.ConfirmRestore(ctx)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ logger.Info("Restore operation cancelled by user")
+ return ErrRestoreAborted
+ }
+
+ var safetyBackup *SafetyBackupResult
+ var networkRollbackBackup *SafetyBackupResult
+ var firewallRollbackBackup *SafetyBackupResult
+ var haRollbackBackup *SafetyBackupResult
+ var accessControlRollbackBackup *SafetyBackupResult
+ systemWriteCategories := append([]Category{}, plan.NormalCategories...)
+ systemWriteCategories = append(systemWriteCategories, plan.StagedCategories...)
+ if len(systemWriteCategories) > 0 {
+ logger.Info("")
+ safetyBackup, err = CreateSafetyBackup(logger, systemWriteCategories, destRoot)
+ if err != nil {
+ logger.Warning("Failed to create safety backup: %v", err)
+ cont, perr := ui.ConfirmContinueWithoutSafetyBackup(ctx, err)
+ if perr != nil {
+ return perr
+ }
+ if !cont {
+ return ErrRestoreAborted
+ }
+ } else {
+ logger.Info("Safety backup location: %s", safetyBackup.BackupPath)
+ logger.Info("You can restore from this backup if needed using: tar -xzf %s -C /", safetyBackup.BackupPath)
+ }
+ }
+
+ if plan.HasCategoryID("network") {
+ logger.Info("")
+ logging.DebugStep(logger, "restore", "Create network-only rollback backup for transactional network apply")
+ networkRollbackBackup, err = CreateNetworkRollbackBackup(logger, systemWriteCategories, destRoot)
+ if err != nil {
+ logger.Warning("Failed to create network rollback backup: %v", err)
+ } else if networkRollbackBackup != nil && strings.TrimSpace(networkRollbackBackup.BackupPath) != "" {
+ logger.Info("Network rollback backup location: %s", networkRollbackBackup.BackupPath)
+ logger.Info("This backup is used for the %ds network rollback timer and only includes network paths.", int(defaultNetworkRollbackTimeout.Seconds()))
+ }
+ }
+ if plan.HasCategoryID("pve_firewall") {
+ logger.Info("")
+ logging.DebugStep(logger, "restore", "Create firewall-only rollback backup for transactional firewall apply")
+ firewallRollbackBackup, err = CreateFirewallRollbackBackup(logger, systemWriteCategories, destRoot)
+ if err != nil {
+ logger.Warning("Failed to create firewall rollback backup: %v", err)
+ } else if firewallRollbackBackup != nil && strings.TrimSpace(firewallRollbackBackup.BackupPath) != "" {
+ logger.Info("Firewall rollback backup location: %s", firewallRollbackBackup.BackupPath)
+ logger.Info("This backup is used for the %ds firewall rollback timer and only includes firewall paths.", int(defaultFirewallRollbackTimeout.Seconds()))
+ }
+ }
+ if plan.HasCategoryID("pve_ha") {
+ logger.Info("")
+ logging.DebugStep(logger, "restore", "Create HA-only rollback backup for transactional HA apply")
+ haRollbackBackup, err = CreateHARollbackBackup(logger, systemWriteCategories, destRoot)
+ if err != nil {
+ logger.Warning("Failed to create HA rollback backup: %v", err)
+ } else if haRollbackBackup != nil && strings.TrimSpace(haRollbackBackup.BackupPath) != "" {
+ logger.Info("HA rollback backup location: %s", haRollbackBackup.BackupPath)
+ logger.Info("This backup is used for the %ds HA rollback timer and only includes HA paths.", int(defaultHARollbackTimeout.Seconds()))
+ }
+ }
+ if plan.SystemType == SystemTypePVE && plan.ClusterBackup && !plan.NeedsClusterRestore && plan.HasCategoryID("pve_access_control") {
+ logger.Info("")
+ logging.DebugStep(logger, "restore", "Create access-control-only rollback backup for optional cluster-safe access control apply")
+ accessControlRollbackBackup, err = CreatePVEAccessControlRollbackBackup(logger, systemWriteCategories, destRoot)
+ if err != nil {
+ logger.Warning("Failed to create access control rollback backup: %v", err)
+ } else if accessControlRollbackBackup != nil && strings.TrimSpace(accessControlRollbackBackup.BackupPath) != "" {
+ logger.Info("Access control rollback backup location: %s", accessControlRollbackBackup.BackupPath)
+ logger.Info("This backup is used for the %ds access control rollback timer and only includes access control paths.", int(defaultAccessControlRollbackTimeout.Seconds()))
+ }
+ }
+
+ needsClusterRestore := plan.NeedsClusterRestore
+ clusterServicesStopped := false
+ pbsServicesStopped := false
+ needsPBSServices := plan.NeedsPBSServices
+
+ if needsClusterRestore {
+ logger.Info("")
+ logger.Info("Preparing system for cluster database restore: stopping PVE services and unmounting /etc/pve")
+ if err := stopPVEClusterServices(ctx, logger); err != nil {
+ return err
+ }
+ clusterServicesStopped = true
+ defer func() {
+ restartCtx, cancel := context.WithTimeout(context.Background(), 2*serviceStartTimeout+2*serviceVerifyTimeout+10*time.Second)
+ defer cancel()
+ if err := startPVEClusterServices(restartCtx, logger); err != nil {
+ logger.Warning("Failed to restart PVE services after restore: %v", err)
+ }
+ }()
+
+ if err := unmountEtcPVE(ctx, logger); err != nil {
+ logger.Warning("Could not unmount /etc/pve: %v", err)
+ }
+ }
+
+ if needsPBSServices {
+ logger.Info("")
+ logger.Info("Preparing PBS system for restore: stopping proxmox-backup services")
+ if err := stopPBSServices(ctx, logger); err != nil {
+ logger.Warning("Unable to stop PBS services automatically: %v", err)
+ cont, perr := ui.ConfirmContinueWithPBSServicesRunning(ctx)
+ if perr != nil {
+ return perr
+ }
+ if !cont {
+ return ErrRestoreAborted
+ }
+ logger.Warning("Continuing restore with PBS services still running")
+ } else {
+ pbsServicesStopped = true
+ defer func() {
+ restartCtx, cancel := context.WithTimeout(context.Background(), 2*serviceStartTimeout+2*serviceVerifyTimeout+10*time.Second)
+ defer cancel()
+ if err := startPBSServices(restartCtx, logger); err != nil {
+ logger.Warning("Failed to restart PBS services after restore: %v", err)
+ }
+ }()
+ }
+ }
+
+ var detailedLogPath string
+
+ needsFilesystemRestore := false
+ if plan.HasCategoryID("filesystem") {
+ needsFilesystemRestore = true
+ var filtered []Category
+ for _, cat := range plan.NormalCategories {
+ if cat.ID != "filesystem" {
+ filtered = append(filtered, cat)
+ }
+ }
+ plan.NormalCategories = filtered
+ logging.DebugStep(logger, "restore", "Filesystem category intercepted: enabling Smart Merge workflow (skipping generic extraction)")
+ }
+
+ if len(plan.NormalCategories) > 0 {
+ logger.Info("")
+ categoriesForExtraction := plan.NormalCategories
+ if needsClusterRestore {
+ logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: sanitize categories to avoid /etc/pve shadow writes")
+ sanitized, removed := sanitizeCategoriesForClusterRecovery(categoriesForExtraction)
+ removedPaths := 0
+ for _, paths := range removed {
+ removedPaths += len(paths)
+ }
+ logging.DebugStep(
+ logger,
+ "restore",
+ "Cluster RECOVERY shadow-guard: categories_before=%d categories_after=%d removed_categories=%d removed_paths=%d",
+ len(categoriesForExtraction),
+ len(sanitized),
+ len(removed),
+ removedPaths,
+ )
+ if len(removed) > 0 {
+ logger.Warning("Cluster RECOVERY restore: skipping direct restore of /etc/pve paths to prevent shadowing while pmxcfs is stopped/unmounted")
+ for _, cat := range categoriesForExtraction {
+ if paths, ok := removed[cat.ID]; ok && len(paths) > 0 {
+ logger.Warning(" - %s (%s): %s", cat.Name, cat.ID, strings.Join(paths, ", "))
+ }
+ }
+ logger.Info("These paths are expected to be restored from config.db and become visible after /etc/pve is remounted.")
+ } else {
+ logging.DebugStep(logger, "restore", "Cluster RECOVERY shadow-guard: no /etc/pve paths detected in selected categories")
+ }
+ categoriesForExtraction = sanitized
+ }
+
+ if len(categoriesForExtraction) == 0 {
+ logging.DebugStep(logger, "restore", "Skip system-path extraction: no categories remain after shadow-guard")
+ logger.Info("No system-path categories remain after cluster shadow-guard; skipping system-path extraction.")
+ } else {
+ detailedLogPath, err = extractSelectiveArchive(ctx, prepared.ArchivePath, destRoot, categoriesForExtraction, mode, logger)
+ if err != nil {
+ logger.Error("Restore failed: %v", err)
+ if safetyBackup != nil {
+ logger.Info("You can rollback using the safety backup at: %s", safetyBackup.BackupPath)
+ }
+ return err
+ }
+ }
+ } else {
+ logger.Info("")
+ logger.Info("No system-path categories selected for restore (only export categories will be processed).")
+ }
+
+ // Mount-first: restore /etc/fstab (Smart Merge) before applying PBS datastore configs.
+ if needsFilesystemRestore {
+ logger.Info("")
+ fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-")
+ if err != nil {
+ restoreHadWarnings = true
+ logger.Warning("Failed to create temp dir for fstab merge: %v", err)
+ } else {
+ defer restoreFS.RemoveAll(fsTempDir)
+ fsCat := GetCategoryByID("filesystem", availableCategories)
+ if fsCat == nil {
+ logger.Warning("Filesystem category not available in analyzed backup contents; skipping fstab merge")
+ } else {
+ fsCategory := []Category{*fsCat}
+ if _, err := extractSelectiveArchive(ctx, prepared.ArchivePath, fsTempDir, fsCategory, RestoreModeCustom, logger); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("Failed to extract filesystem config for merge: %v", err)
+ } else {
+ // Best-effort: extract ProxSave inventory files used for stable fstab device remapping.
+ // (e.g., blkid/lsblk JSON from var/lib/proxsave-info).
+ invCategory := []Category{{
+ ID: "fstab_inventory",
+ Name: "Fstab inventory (device mapping)",
+ Paths: []string{
+ "./var/lib/proxsave-info/commands/system/blkid.txt",
+ "./var/lib/proxsave-info/commands/system/lsblk_json.json",
+ "./var/lib/proxsave-info/commands/system/lsblk.txt",
+ "./var/lib/proxsave-info/commands/pbs/pbs_datastore_inventory.json",
+ },
+ }}
+ if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, invCategory, RestoreModeCustom, nil, "", nil); err != nil {
+ logger.Debug("Failed to extract fstab inventory data (continuing): %v", err)
+ }
+
+ currentFstab := filepath.Join(destRoot, "etc", "fstab")
+ backupFstab := filepath.Join(fsTempDir, "etc", "fstab")
+ if err := smartMergeFstabWithUI(ctx, logger, ui, currentFstab, backupFstab, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.")
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("Smart Fstab Merge failed: %v", err)
+ }
+ }
+ }
+ }
+ }
+
+ exportLogPath := ""
+ exportRoot := ""
+ if len(plan.ExportCategories) > 0 {
+ exportRoot = exportDestRoot(cfg.BaseDir)
+ logger.Info("")
+ logger.Info("Exporting %d export-only category(ies) to: %s", len(plan.ExportCategories), exportRoot)
+ if err := restoreFS.MkdirAll(exportRoot, 0o755); err != nil {
+ return fmt.Errorf("failed to create export directory %s: %w", exportRoot, err)
+ }
+
+ if exportLog, exErr := extractSelectiveArchive(ctx, prepared.ArchivePath, exportRoot, plan.ExportCategories, RestoreModeCustom, logger); exErr != nil {
+ if errors.Is(exErr, ErrRestoreAborted) || input.IsAborted(exErr) {
+ return exErr
+ }
+ restoreHadWarnings = true
+ logger.Warning("Export completed with errors: %v", exErr)
+ } else {
+ exportLogPath = exportLog
+ }
+ }
+
+ if plan.ClusterSafeMode {
+ if exportRoot == "" {
+ logger.Warning("Cluster SAFE mode selected but export directory not available; skipping automatic pvesh apply")
+ } else {
+ // Best-effort: extract extra SAFE apply inventory (pools/mappings) used by pvesh apply workflows.
+ // This keeps SAFE apply usable even when the user did not explicitly export proxsave_info or /etc/pve.
+ safeInvCategory := []Category{{
+ ID: "safe_apply_inventory",
+ Name: "SAFE apply inventory (pools/mappings)",
+ Paths: []string{
+ "./etc/pve/user.cfg",
+ "./var/lib/proxsave-info/commands/pve/mapping_pci.json",
+ "./var/lib/proxsave-info/commands/pve/mapping_usb.json",
+ "./var/lib/proxsave-info/commands/pve/mapping_dir.json",
+ },
+ }}
+ if err := extractArchiveNative(ctx, prepared.ArchivePath, exportRoot, logger, safeInvCategory, RestoreModeCustom, nil, "", nil); err != nil {
+ logger.Debug("Failed to extract SAFE apply inventory (continuing): %v", err)
+ }
+
+ if err := runSafeClusterApplyWithUI(ctx, ui, exportRoot, logger, plan); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("Cluster SAFE apply completed with errors: %v", err)
+ }
+ }
+ }
+
+ stageLogPath := ""
+ stageRoot := ""
+ if len(plan.StagedCategories) > 0 {
+ stageRoot = stageDestRoot()
+ logger.Info("")
+ logger.Info("Staging %d sensitive category(ies) to: %s", len(plan.StagedCategories), stageRoot)
+ if err := restoreFS.MkdirAll(stageRoot, 0o755); err != nil {
+ return fmt.Errorf("failed to create staging directory %s: %w", stageRoot, err)
+ }
+
+ if stageLog, err := extractSelectiveArchive(ctx, prepared.ArchivePath, stageRoot, plan.StagedCategories, RestoreModeCustom, logger); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("Staging completed with errors: %v", err)
+ } else {
+ stageLogPath = stageLog
+ }
+
+ if err := maybeApplyPBSDatastoreMountGuards(ctx, logger, plan, stageRoot, destRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("PBS mount guard: %v", err)
+ }
+
+ logger.Info("")
+ if err := maybeApplyPBSConfigsFromStage(ctx, logger, plan, stageRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("PBS staged config apply: %v", err)
+ }
+ if err := maybeApplyPVEConfigsFromStage(ctx, logger, plan, stageRoot, destRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("PVE staged config apply: %v", err)
+ }
+ if err := maybeApplyPVESDNFromStage(ctx, logger, plan, stageRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("PVE SDN staged apply: %v", err)
+ }
+ if err := maybeApplyAccessControlWithUI(ctx, ui, logger, plan, safetyBackup, accessControlRollbackBackup, stageRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ if errors.Is(err, ErrAccessControlApplyNotCommitted) {
+ var notCommitted *AccessControlApplyNotCommittedError
+ rollbackLog := ""
+ rollbackArmed := false
+ deadline := time.Time{}
+ if errors.As(err, ¬Committed) && notCommitted != nil {
+ rollbackLog = strings.TrimSpace(notCommitted.RollbackLog)
+ rollbackArmed = notCommitted.RollbackArmed
+ deadline = notCommitted.RollbackDeadline
+ }
+ if rollbackArmed {
+ logger.Warning("Access control apply not committed; rollback is ARMED and will run automatically.")
+ } else {
+ logger.Warning("Access control apply not committed; rollback has executed (or marker cleared).")
+ }
+ if !deadline.IsZero() {
+ logger.Info("Rollback deadline: %s", deadline.Format(time.RFC3339))
+ }
+ if rollbackLog != "" {
+ logger.Info("Rollback log: %s", rollbackLog)
+ }
+ } else {
+ logger.Warning("Access control staged apply: %v", err)
+ }
+ }
+ if err := maybeApplyNotificationsFromStage(ctx, logger, plan, stageRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("Notifications staged apply: %v", err)
+ }
+ }
+
+ stageRootForNetworkApply := stageRoot
+ if installed, err := maybeInstallNetworkConfigFromStage(ctx, logger, plan, stageRoot, prepared.ArchivePath, networkRollbackBackup, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("Network staged install: %v", err)
+ } else if installed {
+ stageRootForNetworkApply = ""
+ logging.DebugStep(logger, "restore", "Network staged install completed: configuration written to /etc (no reload); live apply will use system paths")
+ }
+
+ logger.Info("")
+ categoriesForDirRecreate := append([]Category{}, plan.NormalCategories...)
+ categoriesForDirRecreate = append(categoriesForDirRecreate, plan.StagedCategories...)
+ if shouldRecreateDirectories(systemType, categoriesForDirRecreate) {
+ if err := RecreateDirectoriesFromConfig(systemType, logger); err != nil {
+ restoreHadWarnings = true
+ logger.Warning("Failed to recreate directory structures: %v", err)
+ logger.Warning("You may need to manually create storage/datastore directories")
+ }
+ } else {
+ logger.Debug("Skipping datastore/storage directory recreation (category not selected)")
+ }
+
+ logger.Info("")
+ if plan.HasCategoryID("network") {
+ logger.Info("")
+ if err := maybeRepairResolvConfAfterRestore(ctx, logger, prepared.ArchivePath, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ return err
+ }
+ restoreHadWarnings = true
+ logger.Warning("DNS resolver repair: %v", err)
+ }
+ }
+
+ logger.Info("")
+ if err := maybeApplyNetworkConfigWithUI(ctx, ui, logger, plan, safetyBackup, networkRollbackBackup, stageRootForNetworkApply, prepared.ArchivePath, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ logger.Info("Restore aborted by user during network apply prompt.")
+ return err
+ }
+ restoreHadWarnings = true
+ if errors.Is(err, ErrNetworkApplyNotCommitted) {
+ var notCommitted *NetworkApplyNotCommittedError
+ observedIP := "unknown"
+ rollbackLog := ""
+ rollbackArmed := false
+ if errors.As(err, ¬Committed) && notCommitted != nil {
+ if strings.TrimSpace(notCommitted.RestoredIP) != "" {
+ observedIP = strings.TrimSpace(notCommitted.RestoredIP)
+ }
+ rollbackLog = strings.TrimSpace(notCommitted.RollbackLog)
+ rollbackArmed = notCommitted.RollbackArmed
+ lastRestoreAbortInfo = &RestoreAbortInfo{
+ NetworkRollbackArmed: rollbackArmed,
+ NetworkRollbackLog: rollbackLog,
+ NetworkRollbackMarker: strings.TrimSpace(notCommitted.RollbackMarker),
+ OriginalIP: notCommitted.OriginalIP,
+ CurrentIP: observedIP,
+ RollbackDeadline: notCommitted.RollbackDeadline,
+ }
+ }
+ if rollbackArmed {
+ logger.Warning("Network apply not committed; rollback is ARMED and will run automatically. Current IP: %s", observedIP)
+ } else {
+ logger.Warning("Network apply not committed; rollback has executed (or marker cleared). Current IP: %s", observedIP)
+ }
+ if rollbackLog != "" {
+ logger.Info("Rollback log: %s", rollbackLog)
+ }
+ } else {
+ logger.Warning("Network apply step skipped or failed: %v", err)
+ }
+ }
+
+ logger.Info("")
+ if err := maybeApplyPVEFirewallWithUI(ctx, ui, logger, plan, safetyBackup, firewallRollbackBackup, stageRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ logger.Info("Restore aborted by user during firewall apply prompt.")
+ return err
+ }
+ restoreHadWarnings = true
+ if errors.Is(err, ErrFirewallApplyNotCommitted) {
+ var notCommitted *FirewallApplyNotCommittedError
+ rollbackLog := ""
+ rollbackArmed := false
+ deadline := time.Time{}
+ if errors.As(err, ¬Committed) && notCommitted != nil {
+ rollbackLog = strings.TrimSpace(notCommitted.RollbackLog)
+ rollbackArmed = notCommitted.RollbackArmed
+ deadline = notCommitted.RollbackDeadline
+ }
+ if rollbackArmed {
+ logger.Warning("Firewall apply not committed; rollback is ARMED and will run automatically.")
+ } else {
+ logger.Warning("Firewall apply not committed; rollback has executed (or marker cleared).")
+ }
+ if !deadline.IsZero() {
+ logger.Info("Rollback deadline: %s", deadline.Format(time.RFC3339))
+ }
+ if rollbackLog != "" {
+ logger.Info("Rollback log: %s", rollbackLog)
+ }
+ } else {
+ logger.Warning("Firewall apply step skipped or failed: %v", err)
+ }
+ }
+
+ logger.Info("")
+ if err := maybeApplyPVEHAWithUI(ctx, ui, logger, plan, safetyBackup, haRollbackBackup, stageRoot, cfg.DryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ logger.Info("Restore aborted by user during HA apply prompt.")
+ return err
+ }
+ restoreHadWarnings = true
+ if errors.Is(err, ErrHAApplyNotCommitted) {
+ var notCommitted *HAApplyNotCommittedError
+ rollbackLog := ""
+ rollbackArmed := false
+ deadline := time.Time{}
+ if errors.As(err, ¬Committed) && notCommitted != nil {
+ rollbackLog = strings.TrimSpace(notCommitted.RollbackLog)
+ rollbackArmed = notCommitted.RollbackArmed
+ deadline = notCommitted.RollbackDeadline
+ }
+ if rollbackArmed {
+ logger.Warning("HA apply not committed; rollback is ARMED and will run automatically.")
+ } else {
+ logger.Warning("HA apply not committed; rollback has executed (or marker cleared).")
+ }
+ if !deadline.IsZero() {
+ logger.Info("Rollback deadline: %s", deadline.Format(time.RFC3339))
+ }
+ if rollbackLog != "" {
+ logger.Info("Rollback log: %s", rollbackLog)
+ }
+ } else {
+ logger.Warning("HA apply step skipped or failed: %v", err)
+ }
+ }
+
+ logger.Info("")
+ if restoreHadWarnings {
+ logger.Warning("Restore completed with warnings.")
+ } else {
+ logger.Info("Restore completed successfully.")
+ }
+ logger.Info("Temporary decrypted bundle removed.")
+
+ if detailedLogPath != "" {
+ logger.Info("Detailed restore log: %s", detailedLogPath)
+ }
+ if exportRoot != "" {
+ logger.Info("Export directory: %s", exportRoot)
+ }
+ if exportLogPath != "" {
+ logger.Info("Export detailed log: %s", exportLogPath)
+ }
+ if stageRoot != "" {
+ logger.Info("Staging directory: %s", stageRoot)
+ }
+ if stageLogPath != "" {
+ logger.Info("Staging detailed log: %s", stageLogPath)
+ }
+
+ if safetyBackup != nil {
+ logger.Info("Safety backup preserved at: %s", safetyBackup.BackupPath)
+ logger.Info("Remove it manually if restore was successful: rm %s", safetyBackup.BackupPath)
+ }
+
+ logger.Info("")
+ logger.Info("IMPORTANT: You may need to restart services for changes to take effect.")
+ if systemType == SystemTypePVE {
+ if needsClusterRestore && clusterServicesStopped {
+ logger.Info(" PVE services were stopped/restarted during restore; verify status with: pvecm status")
+ } else {
+ logger.Info(" PVE services: systemctl restart pve-cluster pvedaemon pveproxy")
+ }
+ } else if systemType == SystemTypePBS {
+ if pbsServicesStopped {
+ logger.Info(" PBS services were stopped/restarted during restore; verify status with: systemctl status proxmox-backup proxmox-backup-proxy")
+ } else {
+ logger.Info(" PBS services: systemctl restart proxmox-backup-proxy proxmox-backup")
+ }
+
+ if hasCategoryID(plan.NormalCategories, "zfs") {
+ logger.Info("")
+ if err := checkZFSPoolsAfterRestore(logger); err != nil {
+ logger.Warning("ZFS pool check: %v", err)
+ }
+ } else {
+ logger.Debug("Skipping ZFS pool verification (ZFS category not selected)")
+ }
+ }
+
+ logger.Info("")
+ logger.Warning("⚠ SYSTEM REBOOT RECOMMENDED")
+ logger.Info("Reboot the node (or at least restart networking and system services) to ensure all restored configurations take effect cleanly.")
+
+ return nil
+}
+
+func maybeAddRecommendedCategoriesForTFA(ctx context.Context, ui RestoreWorkflowUI, logger *logging.Logger, selected []Category, available []Category) ([]Category, error) {
+ if ui == nil || logger == nil {
+ return selected, nil
+ }
+ if !hasCategoryID(selected, "pve_access_control") && !hasCategoryID(selected, "pbs_access_control") {
+ return selected, nil
+ }
+
+ var missing []string
+ if !hasCategoryID(selected, "network") {
+ missing = append(missing, "network")
+ }
+ if !hasCategoryID(selected, "ssl") {
+ missing = append(missing, "ssl")
+ }
+ if len(missing) == 0 {
+ return selected, nil
+ }
+
+ var addCategories []Category
+ var addNames []string
+ for _, id := range missing {
+ cat := GetCategoryByID(id, available)
+ if cat == nil || !cat.IsAvailable || cat.ExportOnly {
+ continue
+ }
+ addCategories = append(addCategories, *cat)
+ addNames = append(addNames, cat.Name)
+ }
+ if len(addCategories) == 0 {
+ return selected, nil
+ }
+
+ message := fmt.Sprintf(
+ "You selected Access Control without restoring: %s\n\n"+
+ "If TFA includes WebAuthn/FIDO2, changing the UI origin (FQDN/hostname or port) may require re-enrollment.\n\n"+
+ "For maximum 1:1 compatibility, ProxSave recommends restoring these categories too.\n\n"+
+ "Add recommended categories now?",
+ strings.Join(addNames, ", "),
+ )
+ addNow, err := ui.ConfirmAction(ctx, "TFA/WebAuthn compatibility", message, "Add recommended", "Keep current", 0, true)
+ if err != nil {
+ return nil, err
+ }
+ if !addNow {
+ logger.Warning("Access control selected without %s; WebAuthn users may require re-enrollment if the UI origin changes", strings.Join(addNames, ", "))
+ return selected, nil
+ }
+
+ selected = append(selected, addCategories...)
+ return dedupeCategoriesByID(selected), nil
+}
+
+func dedupeCategoriesByID(categories []Category) []Category {
+ if len(categories) == 0 {
+ return categories
+ }
+ seen := make(map[string]struct{}, len(categories))
+ out := make([]Category, 0, len(categories))
+ for _, cat := range categories {
+ id := strings.TrimSpace(cat.ID)
+ if id == "" {
+ out = append(out, cat)
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, cat)
+ }
+ return out
+}
+
+func runFullRestoreWithUI(ctx context.Context, ui RestoreWorkflowUI, candidate *decryptCandidate, prepared *preparedBundle, destRoot string, logger *logging.Logger, dryRun bool) error {
+ if candidate == nil || prepared == nil || prepared.Manifest.ArchivePath == "" {
+ return fmt.Errorf("invalid restore candidate")
+ }
+
+ if err := ui.ShowMessage(ctx, "Full restore", "Backup category analysis failed; ProxSave will run a full restore (no selective modes)."); err != nil {
+ return err
+ }
+
+ confirmed, err := ui.ConfirmRestore(ctx)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ return ErrRestoreAborted
+ }
+
+ safeFstabMerge := destRoot == "/" && isRealRestoreFS(restoreFS)
+ skipFn := func(name string) bool {
+ if !safeFstabMerge {
+ return false
+ }
+ clean := strings.TrimPrefix(strings.TrimSpace(name), "./")
+ clean = strings.TrimPrefix(clean, "/")
+ return clean == "etc/fstab"
+ }
+
+ if safeFstabMerge {
+ logger.Warning("Full restore safety: /etc/fstab will not be overwritten; Smart Merge will be applied after extraction.")
+ }
+
+ if err := extractPlainArchive(ctx, prepared.ArchivePath, destRoot, logger, skipFn); err != nil {
+ return err
+ }
+
+ if safeFstabMerge {
+ logger.Info("")
+ fsTempDir, err := restoreFS.MkdirTemp("", "proxsave-fstab-")
+ if err != nil {
+ logger.Warning("Failed to create temp dir for fstab merge: %v", err)
+ } else {
+ defer restoreFS.RemoveAll(fsTempDir)
+ fsCategory := []Category{{
+ ID: "filesystem",
+ Name: "Filesystem Configuration",
+ Paths: []string{
+ "./etc/fstab",
+ },
+ }}
+ if err := extractArchiveNative(ctx, prepared.ArchivePath, fsTempDir, logger, fsCategory, RestoreModeCustom, nil, "", nil); err != nil {
+ logger.Warning("Failed to extract filesystem config for merge: %v", err)
+ } else {
+ currentFstab := filepath.Join(destRoot, "etc", "fstab")
+ backupFstab := filepath.Join(fsTempDir, "etc", "fstab")
+ if err := smartMergeFstabWithUI(ctx, logger, ui, currentFstab, backupFstab, dryRun); err != nil {
+ if errors.Is(err, ErrRestoreAborted) || input.IsAborted(err) {
+ logger.Info("Restore aborted by user during Smart Filesystem Configuration Merge.")
+ return err
+ }
+ logger.Warning("Smart Fstab Merge failed: %v", err)
+ }
+ }
+ }
+ }
+
+ logger.Info("Restore completed successfully.")
+ return nil
+}
+
+func runSafeClusterApplyWithUI(ctx context.Context, ui RestoreWorkflowUI, exportRoot string, logger *logging.Logger, plan *RestorePlan) (err error) {
+ done := logging.DebugStart(logger, "safe cluster apply (ui)", "export_root=%s", exportRoot)
+ defer func() { done(err) }()
+
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ if ui == nil {
+ return fmt.Errorf("restore UI not available")
+ }
+
+ pveshPath, lookErr := exec.LookPath("pvesh")
+ if lookErr != nil {
+ logger.Warning("pvesh not found in PATH; skipping SAFE cluster apply")
+ return nil
+ }
+ logging.DebugStep(logger, "safe cluster apply (ui)", "pvesh=%s", pveshPath)
+
+ currentNode, _ := os.Hostname()
+ currentNode = shortHost(currentNode)
+ if strings.TrimSpace(currentNode) == "" {
+ currentNode = "localhost"
+ }
+ logging.DebugStep(logger, "safe cluster apply (ui)", "current_node=%s", currentNode)
+
+ logger.Info("")
+ logger.Info("SAFE cluster restore: applying configs via pvesh (node=%s)", currentNode)
+
+ // Datacenter-wide objects (SAFE apply):
+ // - resource mappings (used by VM configs via mapping=)
+ // - resource pools (definitions + membership)
+ if mapErr := maybeApplyPVEClusterResourceMappingsWithUI(ctx, ui, logger, exportRoot); mapErr != nil {
+ logger.Warning("SAFE apply: resource mappings: %v", mapErr)
+ }
+
+ pools, poolsErr := readPVEPoolsFromExportUserCfg(exportRoot)
+ if poolsErr != nil {
+ logger.Warning("SAFE apply: failed to parse pools from export: %v", poolsErr)
+ pools = nil
+ }
+ applyPools := false
+ allowPoolMove := false
+ if len(pools) > 0 {
+ poolNames := summarizePoolIDs(pools, 10)
+ message := fmt.Sprintf("Found %d pool(s) in exported user.cfg.\n\nPools: %s\n\nApply pool definitions now? (Membership will be applied later in this SAFE apply flow.)", len(pools), poolNames)
+ ok, promptErr := ui.ConfirmAction(ctx, "Apply PVE resource pools (merge)", message, "Apply now", "Skip apply", 0, false)
+ if promptErr != nil {
+ return promptErr
+ }
+ applyPools = ok
+ logging.DebugStep(logger, "safe cluster apply (ui)", "User choice: apply_pools=%v (pools=%d)", applyPools, len(pools))
+ if applyPools {
+ if anyPoolHasVMs(pools) {
+ moveMsg := "Allow moving guests from other pools to match the backup? This may change the current pool assignment of existing VMs/CTs."
+ move, moveErr := ui.ConfirmAction(ctx, "Pools: allow move (VM/CT)", moveMsg, "Allow move", "Don't move", 0, false)
+ if moveErr != nil {
+ return moveErr
+ }
+ allowPoolMove = move
+ }
+
+ applied, failed, applyErr := applyPVEPoolsDefinitions(ctx, logger, pools)
+ if applyErr != nil {
+ logger.Warning("Pools apply (definitions) encountered errors: %v", applyErr)
+ }
+ logger.Info("Pools apply (definitions) completed: ok=%d failed=%d", applied, failed)
+ }
+ }
+
+ sourceNode := currentNode
+ logging.DebugStep(logger, "safe cluster apply (ui)", "List exported node directories under %s", filepath.Join(exportRoot, "etc/pve/nodes"))
+ exportNodes, nodesErr := listExportNodeDirs(exportRoot)
+ if nodesErr != nil {
+ logger.Warning("Failed to inspect exported node directories: %v", nodesErr)
+ } else if len(exportNodes) > 0 {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "export_nodes=%s", strings.Join(exportNodes, ","))
+ } else {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "No exported node directories found")
+ }
+
+ if len(exportNodes) > 0 && !stringSliceContains(exportNodes, sourceNode) {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Node mismatch: current_node=%s export_nodes=%s", currentNode, strings.Join(exportNodes, ","))
+ logger.Warning("SAFE cluster restore: VM/CT configs not found for current node %s in export; available nodes: %s", currentNode, strings.Join(exportNodes, ", "))
+ if len(exportNodes) == 1 {
+ sourceNode = exportNodes[0]
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Auto-select source node: %s", sourceNode)
+ logger.Info("SAFE cluster restore: using exported node %s as VM/CT source, applying to current node %s", sourceNode, currentNode)
+ } else {
+ for _, node := range exportNodes {
+ qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node)
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Export node candidate: %s (qemu=%d, lxc=%d)", node, qemuCount, lxcCount)
+ }
+ selected, selErr := ui.SelectExportNode(ctx, exportRoot, currentNode, exportNodes)
+ if selErr != nil {
+ return selErr
+ }
+ if strings.TrimSpace(selected) == "" {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "User selected: skip VM/CT apply (no source node)")
+ logger.Info("Skipping VM/CT apply (no source node selected)")
+ sourceNode = ""
+ } else {
+ sourceNode = selected
+ logging.DebugStep(logger, "safe cluster apply (ui)", "User selected source node: %s", sourceNode)
+ logger.Info("SAFE cluster restore: selected exported node %s as VM/CT source, applying to current node %s", sourceNode, currentNode)
+ }
+ }
+ }
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Selected VM/CT source node: %q (current_node=%q)", sourceNode, currentNode)
+
+ var vmEntries []vmEntry
+ if strings.TrimSpace(sourceNode) != "" {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Scan VM/CT configs in export (source_node=%s)", sourceNode)
+ vmEntries, err = scanVMConfigs(exportRoot, sourceNode)
+ if err != nil {
+ logger.Warning("Failed to scan VM configs: %v", err)
+ vmEntries = nil
+ } else {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "VM/CT configs found=%d (source_node=%s)", len(vmEntries), sourceNode)
+ }
+ }
+
+ if len(vmEntries) > 0 {
+ applyVMs, promptErr := ui.ConfirmApplyVMConfigs(ctx, sourceNode, currentNode, len(vmEntries))
+ if promptErr != nil {
+ return promptErr
+ }
+ logging.DebugStep(logger, "safe cluster apply (ui)", "User choice: apply_vms=%v (entries=%d)", applyVMs, len(vmEntries))
+ if applyVMs {
+ applied, failed := applyVMConfigs(ctx, vmEntries, logger)
+ logger.Info("VM/CT apply completed: ok=%d failed=%d", applied, failed)
+ } else {
+ logger.Info("Skipping VM/CT apply")
+ }
+ } else {
+ if strings.TrimSpace(sourceNode) == "" {
+ logger.Info("No VM/CT configs applied (no source node selected)")
+ } else {
+ logger.Info("No VM/CT configs found for node %s in export", sourceNode)
+ }
+ }
+
+ skipStorageDatacenter := plan != nil && plan.HasCategoryID("storage_pve")
+ if skipStorageDatacenter {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Skip storage/datacenter apply: handled by storage_pve staged restore")
+ logger.Info("Skipping storage/datacenter apply (handled by storage_pve staged restore)")
+ } else {
+ storageCfg := filepath.Join(exportRoot, "etc/pve/storage.cfg")
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Check export: storage.cfg (%s)", storageCfg)
+ storageInfo, storageErr := restoreFS.Stat(storageCfg)
+ if storageErr == nil && !storageInfo.IsDir() {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "storage.cfg found (size=%d)", storageInfo.Size())
+ applyStorage, promptErr := ui.ConfirmApplyStorageCfg(ctx, storageCfg)
+ if promptErr != nil {
+ return promptErr
+ }
+ logging.DebugStep(logger, "safe cluster apply (ui)", "User choice: apply_storage=%v", applyStorage)
+ if applyStorage {
+ applied, failed, err := applyStorageCfg(ctx, storageCfg, logger)
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Storage apply result: ok=%d failed=%d err=%v", applied, failed, err)
+ if err != nil {
+ logger.Warning("Storage apply encountered errors: %v", err)
+ }
+ logger.Info("Storage apply completed: ok=%d failed=%d", applied, failed)
+ } else {
+ logger.Info("Skipping storage.cfg apply")
+ }
+ } else {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "storage.cfg not found (err=%v)", storageErr)
+ logger.Info("No storage.cfg found in export")
+ }
+
+ dcCfg := filepath.Join(exportRoot, "etc/pve/datacenter.cfg")
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Check export: datacenter.cfg (%s)", dcCfg)
+ dcInfo, dcErr := restoreFS.Stat(dcCfg)
+ if dcErr == nil && !dcInfo.IsDir() {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "datacenter.cfg found (size=%d)", dcInfo.Size())
+ applyDC, promptErr := ui.ConfirmApplyDatacenterCfg(ctx, dcCfg)
+ if promptErr != nil {
+ return promptErr
+ }
+ logging.DebugStep(logger, "safe cluster apply (ui)", "User choice: apply_datacenter=%v", applyDC)
+ if applyDC {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "Apply datacenter.cfg via pvesh")
+ if err := runPvesh(ctx, logger, []string{"set", "/cluster/config", "-conf", dcCfg}); err != nil {
+ logger.Warning("Failed to apply datacenter.cfg: %v", err)
+ } else {
+ logger.Info("datacenter.cfg applied successfully")
+ }
+ } else {
+ logger.Info("Skipping datacenter.cfg apply")
+ }
+ } else {
+ logging.DebugStep(logger, "safe cluster apply (ui)", "datacenter.cfg not found (err=%v)", dcErr)
+ logger.Info("No datacenter.cfg found in export")
+ }
+ }
+
+ // Apply pool membership after VM configs and storage/datacenter apply.
+ if applyPools && len(pools) > 0 {
+ applied, failed, applyErr := applyPVEPoolsMembership(ctx, logger, pools, allowPoolMove)
+ if applyErr != nil {
+ logger.Warning("Pools apply (membership) encountered errors: %v", applyErr)
+ }
+ logger.Info("Pools apply (membership) completed: ok=%d failed=%d", applied, failed)
+ }
+
+ return nil
+}
+
+func smartMergeFstabWithUI(ctx context.Context, logger *logging.Logger, ui RestoreWorkflowUI, currentFstabPath, backupFstabPath string, dryRun bool) error {
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+ logger.Info("")
+ logger.Step("Smart Filesystem Configuration Merge")
+ logger.Debug("[FSTAB_MERGE] Starting analysis of %s vs backup %s...", currentFstabPath, backupFstabPath)
+
+ currentEntries, currentRaw, err := parseFstab(currentFstabPath)
+ if err != nil {
+ return fmt.Errorf("failed to parse current fstab: %w", err)
+ }
+ backupEntries, _, err := parseFstab(backupFstabPath)
+ if err != nil {
+ return fmt.Errorf("failed to parse backup fstab: %w", err)
+ }
+
+ remappedCount := 0
+ backupRoot := fstabBackupRootFromPath(backupFstabPath)
+ if backupRoot != "" {
+ if remapped, count := remapFstabDevicesFromInventory(logger, backupEntries, backupRoot); count > 0 {
+ backupEntries = remapped
+ remappedCount = count
+ logger.Info("Fstab device remap: converted %d entry(ies) from /dev/* to stable UUID/PARTUUID/LABEL based on ProxSave inventory", count)
+ } else {
+ backupEntries = remapped
+ }
+ }
+
+ analysis := analyzeFstabMerge(logger, currentEntries, backupEntries)
+ if len(analysis.ProposedMounts) == 0 {
+ logger.Info("No new safe mounts found to restore. Keeping current fstab.")
+ return nil
+ }
+
+ defaultYes := analysis.RootComparable && analysis.RootMatch && (!analysis.SwapComparable || analysis.SwapMatch)
+
+ var msg strings.Builder
+ msg.WriteString("ProxSave found missing mounts in /etc/fstab.\n\n")
+ if analysis.RootComparable && !analysis.RootMatch {
+ msg.WriteString("⚠ Root UUID mismatch: the backup appears to come from a different machine.\n")
+ }
+ if analysis.SwapComparable && !analysis.SwapMatch {
+ msg.WriteString("⚠ Swap mismatch: the current swap configuration will be kept.\n")
+ }
+ if remappedCount > 0 {
+ fmt.Fprintf(&msg, "✓ Remapped %d fstab entry(ies) from /dev/* to stable UUID/PARTUUID/LABEL using ProxSave inventory.\n", remappedCount)
+ }
+ msg.WriteString("\nProposed mounts (safe):\n")
+ for _, mount := range analysis.ProposedMounts {
+ fmt.Fprintf(&msg, " - %s -> %s (%s)\n", mount.Device, mount.MountPoint, mount.Type)
+ }
+ if len(analysis.SkippedMounts) > 0 {
+ msg.WriteString("\nMounts found but not auto-proposed:\n")
+ for _, mount := range analysis.SkippedMounts {
+ fmt.Fprintf(&msg, " - %s -> %s (%s)\n", mount.Device, mount.MountPoint, mount.Type)
+ }
+ msg.WriteString("\nHint: verify disks/UUIDs and options (nofail/_netdev) before adding them.\n")
+ }
+
+ confirmMsg := "Do you want to add the missing mounts (NFS/CIFS and data mounts with verified UUID/LABEL)?"
+ if strings.TrimSpace(confirmMsg) != "" {
+ msg.WriteString("\n")
+ msg.WriteString(confirmMsg)
+ }
+
+ confirmed, err := ui.ConfirmFstabMerge(ctx, "Smart fstab merge", msg.String(), 90*time.Second, defaultYes)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ logger.Info("Fstab merge skipped by user.")
+ return nil
+ }
+
+ return applyFstabMerge(ctx, logger, currentRaw, currentFstabPath, analysis.ProposedMounts, dryRun)
+}
diff --git a/internal/orchestrator/restore_workflow_ui_helpers_test.go b/internal/orchestrator/restore_workflow_ui_helpers_test.go
new file mode 100644
index 0000000..dc03218
--- /dev/null
+++ b/internal/orchestrator/restore_workflow_ui_helpers_test.go
@@ -0,0 +1,118 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+type fakeRestoreWorkflowUI struct {
+ mode RestoreMode
+ categories []Category
+ confirmRestore bool
+ confirmCompatible bool
+ clusterMode ClusterRestoreMode
+ continueNoSafety bool
+ continuePBSServices bool
+ confirmFstabMerge bool
+ exportNode string
+ applyVMConfigs bool
+ applyStorageCfg bool
+ applyDatacenterCfg bool
+ confirmAction bool
+ networkCommit bool
+
+ modeErr error
+ categoriesErr error
+ confirmRestoreErr error
+ confirmCompatibleErr error
+ clusterModeErr error
+ continueNoSafetyErr error
+ continuePBSServicesErr error
+ confirmFstabMergeErr error
+ confirmActionErr error
+ repairNICNamesErr error
+ networkCommitErr error
+}
+
+func (f *fakeRestoreWorkflowUI) RunTask(ctx context.Context, title, initialMessage string, run func(ctx context.Context, report ProgressReporter) error) error {
+ return run(ctx, nil)
+}
+
+func (f *fakeRestoreWorkflowUI) ShowMessage(ctx context.Context, title, message string) error { return nil }
+func (f *fakeRestoreWorkflowUI) ShowError(ctx context.Context, title, message string) error { return nil }
+
+func (f *fakeRestoreWorkflowUI) SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error) {
+ return decryptPathOption{}, fmt.Errorf("unexpected SelectBackupSource call")
+}
+
+func (f *fakeRestoreWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) {
+ return nil, fmt.Errorf("unexpected SelectBackupCandidate call")
+}
+
+func (f *fakeRestoreWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) {
+ return "", fmt.Errorf("unexpected PromptDecryptSecret call")
+}
+
+func (f *fakeRestoreWorkflowUI) SelectRestoreMode(ctx context.Context, systemType SystemType) (RestoreMode, error) {
+ return f.mode, f.modeErr
+}
+
+func (f *fakeRestoreWorkflowUI) SelectCategories(ctx context.Context, available []Category, systemType SystemType) ([]Category, error) {
+ return f.categories, f.categoriesErr
+}
+
+func (f *fakeRestoreWorkflowUI) ShowRestorePlan(ctx context.Context, config *SelectiveRestoreConfig) error { return nil }
+
+func (f *fakeRestoreWorkflowUI) ConfirmRestore(ctx context.Context) (bool, error) {
+ return f.confirmRestore, f.confirmRestoreErr
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmCompatibility(ctx context.Context, warning error) (bool, error) {
+ return f.confirmCompatible, f.confirmCompatibleErr
+}
+
+func (f *fakeRestoreWorkflowUI) SelectClusterRestoreMode(ctx context.Context) (ClusterRestoreMode, error) {
+ return f.clusterMode, f.clusterModeErr
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmContinueWithoutSafetyBackup(ctx context.Context, cause error) (bool, error) {
+ return f.continueNoSafety, f.continueNoSafetyErr
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmContinueWithPBSServicesRunning(ctx context.Context) (bool, error) {
+ return f.continuePBSServices, f.continuePBSServicesErr
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmFstabMerge(ctx context.Context, title, message string, timeout time.Duration, defaultYes bool) (bool, error) {
+ return f.confirmFstabMerge, f.confirmFstabMergeErr
+}
+
+func (f *fakeRestoreWorkflowUI) SelectExportNode(ctx context.Context, exportRoot, currentNode string, exportNodes []string) (string, error) {
+ return f.exportNode, nil
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmApplyVMConfigs(ctx context.Context, sourceNode, currentNode string, count int) (bool, error) {
+ return f.applyVMConfigs, nil
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmApplyStorageCfg(ctx context.Context, storageCfgPath string) (bool, error) {
+ return f.applyStorageCfg, nil
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmApplyDatacenterCfg(ctx context.Context, datacenterCfgPath string) (bool, error) {
+ return f.applyDatacenterCfg, nil
+}
+
+func (f *fakeRestoreWorkflowUI) ConfirmAction(ctx context.Context, title, message, yesLabel, noLabel string, timeout time.Duration, defaultYes bool) (bool, error) {
+ return f.confirmAction, f.confirmActionErr
+}
+
+func (f *fakeRestoreWorkflowUI) RepairNICNames(ctx context.Context, archivePath string) (*nicRepairResult, error) {
+ return nil, f.repairNICNamesErr
+}
+
+func (f *fakeRestoreWorkflowUI) PromptNetworkCommit(ctx context.Context, remaining time.Duration, health networkHealthReport, nicRepair *nicRepairResult, diagnosticsDir string) (bool, error) {
+ return f.networkCommit, f.networkCommitErr
+}
+
diff --git a/internal/orchestrator/restore_workflow_ui_tfa_test.go b/internal/orchestrator/restore_workflow_ui_tfa_test.go
new file mode 100644
index 0000000..647de55
--- /dev/null
+++ b/internal/orchestrator/restore_workflow_ui_tfa_test.go
@@ -0,0 +1,52 @@
+package orchestrator
+
+import (
+ "context"
+ "testing"
+)
+
+func TestMaybeAddRecommendedCategoriesForTFA_AddsNetworkAndSSLWhenConfirmed(t *testing.T) {
+ ui := &fakeRestoreWorkflowUI{confirmAction: true}
+
+ available := []Category{
+ {ID: "pve_access_control", Name: "PVE Access Control", IsAvailable: true},
+ {ID: "network", Name: "Network", IsAvailable: true},
+ {ID: "ssl", Name: "SSL", IsAvailable: true},
+ }
+ selected := []Category{
+ {ID: "pve_access_control", Name: "PVE Access Control", IsAvailable: true},
+ }
+
+ got, err := maybeAddRecommendedCategoriesForTFA(context.Background(), ui, newTestLogger(), selected, available)
+ if err != nil {
+ t.Fatalf("maybeAddRecommendedCategoriesForTFA error: %v", err)
+ }
+ if !hasCategoryID(got, "network") {
+ t.Fatalf("expected network category to be added, got=%v", got)
+ }
+ if !hasCategoryID(got, "ssl") {
+ t.Fatalf("expected ssl category to be added, got=%v", got)
+ }
+}
+
+func TestMaybeAddRecommendedCategoriesForTFA_DoesNotAddWhenDeclined(t *testing.T) {
+ ui := &fakeRestoreWorkflowUI{confirmAction: false}
+
+ available := []Category{
+ {ID: "pbs_access_control", Name: "PBS Access Control", IsAvailable: true},
+ {ID: "network", Name: "Network", IsAvailable: true},
+ {ID: "ssl", Name: "SSL", IsAvailable: true},
+ }
+ selected := []Category{
+ {ID: "pbs_access_control", Name: "PBS Access Control", IsAvailable: true},
+ }
+
+ got, err := maybeAddRecommendedCategoriesForTFA(context.Background(), ui, newTestLogger(), selected, available)
+ if err != nil {
+ t.Fatalf("maybeAddRecommendedCategoriesForTFA error: %v", err)
+ }
+ if hasCategoryID(got, "network") || hasCategoryID(got, "ssl") {
+ t.Fatalf("expected no categories to be added, got=%v", got)
+ }
+}
+
diff --git a/internal/orchestrator/restore_workflow_warnings_test.go b/internal/orchestrator/restore_workflow_warnings_test.go
index b7aeb1e..3da4b6b 100644
--- a/internal/orchestrator/restore_workflow_warnings_test.go
+++ b/internal/orchestrator/restore_workflow_warnings_test.go
@@ -1,7 +1,6 @@
package orchestrator
import (
- "bufio"
"context"
"errors"
"io/fs"
@@ -35,21 +34,19 @@ func (f failWritePathFS) WriteFile(path string, data []byte, perm fs.FileMode) e
func TestRunRestoreWorkflow_FstabMergeFails_ContinuesWithWarnings(t *testing.T) {
origRestoreFS := restoreFS
origRestoreCmd := restoreCmd
- origRestorePrompter := restorePrompter
origRestoreSystem := restoreSystem
origRestoreTime := restoreTime
origCompatFS := compatFS
- origPrepare := prepareDecryptedBackupFunc
+ origPrepare := prepareRestoreBundleFunc
origSafetyFS := safetyFS
origSafetyNow := safetyNow
t.Cleanup(func() {
restoreFS = origRestoreFS
restoreCmd = origRestoreCmd
- restorePrompter = origRestorePrompter
restoreSystem = origRestoreSystem
restoreTime = origRestoreTime
compatFS = origCompatFS
- prepareDecryptedBackupFunc = origPrepare
+ prepareRestoreBundleFunc = origPrepare
safetyFS = origSafetyFS
safetyNow = origSafetyNow
})
@@ -92,13 +89,7 @@ func TestRunRestoreWorkflow_FstabMergeFails_ContinuesWithWarnings(t *testing.T)
t.Fatalf("fakeFS.WriteFile(/bundle.tar): %v", err)
}
- restorePrompter = fakeRestorePrompter{
- mode: RestoreModeCustom,
- categories: []Category{mustCategoryByID(t, "filesystem")},
- confirmed: true,
- }
-
- prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) {
+ prepareRestoreBundleFunc = func(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string, ui RestoreWorkflowUI) (*decryptCandidate, *preparedBundle, error) {
cand := &decryptCandidate{
DisplayBase: "test",
Manifest: &backup.Manifest{
@@ -123,50 +114,17 @@ func TestRunRestoreWorkflow_FstabMergeFails_ContinuesWithWarnings(t *testing.T)
failErr: errors.New("disk full"),
}
- // Provide input to accept defaultYes (blank line).
- oldIn := os.Stdin
- oldOut := os.Stdout
- oldErr := os.Stderr
- t.Cleanup(func() {
- os.Stdin = oldIn
- os.Stdout = oldOut
- os.Stderr = oldErr
- })
- inR, inW, err := os.Pipe()
- if err != nil {
- t.Fatalf("os.Pipe: %v", err)
- }
- out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- errOut, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o666)
- if err != nil {
- _ = inR.Close()
- _ = inW.Close()
- _ = out.Close()
- t.Fatalf("OpenFile(%s): %v", os.DevNull, err)
- }
- os.Stdin = inR
- os.Stdout = out
- os.Stderr = errOut
- t.Cleanup(func() {
- _ = inR.Close()
- _ = out.Close()
- _ = errOut.Close()
- })
- if _, err := inW.WriteString("\n"); err != nil {
- t.Fatalf("WriteString: %v", err)
- }
- _ = inW.Close()
-
logger := logging.New(types.LogLevelWarning, false)
cfg := &config.Config{BaseDir: "/base"}
+ ui := &fakeRestoreWorkflowUI{
+ mode: RestoreModeCustom,
+ categories: []Category{mustCategoryByID(t, "filesystem")},
+ confirmRestore: true,
+ confirmFstabMerge: true,
+ }
- if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil {
- t.Fatalf("RunRestoreWorkflow error: %v", err)
+ if err := runRestoreWorkflowWithUI(context.Background(), cfg, logger, "vtest", ui); err != nil {
+ t.Fatalf("runRestoreWorkflowWithUI error: %v", err)
}
if !logger.HasWarnings() {
t.Fatalf("expected warnings")
diff --git a/internal/orchestrator/selective.go b/internal/orchestrator/selective.go
index f46c96a..18f7583 100644
--- a/internal/orchestrator/selective.go
+++ b/internal/orchestrator/selective.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"os"
+ "path"
"sort"
"strconv"
"strings"
@@ -117,6 +118,12 @@ func pathMatchesPattern(archivePath, pattern string) bool {
normPattern = "./" + normPattern
}
+ if strings.ContainsAny(normPattern, "*?[") && !strings.HasSuffix(normPattern, "/") {
+ if ok, err := path.Match(normPattern, normArchive); err == nil && ok {
+ return true
+ }
+ }
+
// Exact match
if normArchive == normPattern {
return true
@@ -139,21 +146,27 @@ func pathMatchesPattern(archivePath, pattern string) bool {
// ShowRestoreModeMenu displays the restore mode selection menu
func ShowRestoreModeMenu(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) {
- reader := bufio.NewReader(os.Stdin)
+ return ShowRestoreModeMenuWithReader(ctx, bufio.NewReader(os.Stdin), logger, systemType)
+}
+
+func ShowRestoreModeMenuWithReader(ctx context.Context, reader *bufio.Reader, logger *logging.Logger, systemType SystemType) (RestoreMode, error) {
+ if reader == nil {
+ reader = bufio.NewReader(os.Stdin)
+ }
fmt.Println()
fmt.Println("Select restore mode:")
fmt.Println(" [1] FULL restore - Restore everything from backup")
if systemType == SystemTypePVE {
- fmt.Println(" [2] STORAGE only - PVE cluster + storage configuration + VM configs + jobs")
+ fmt.Println(" [2] STORAGE only - PVE cluster + storage + jobs + mounts")
} else if systemType == SystemTypePBS {
- fmt.Println(" [2] DATASTORE only - PBS config + datastore definitions + sync/verify/prune jobs")
+ fmt.Println(" [2] DATASTORE only - PBS datastore definitions + sync/verify/prune jobs + mounts")
} else {
fmt.Println(" [2] STORAGE/DATASTORE only - Storage or datastore configuration")
}
- fmt.Println(" [3] SYSTEM BASE only - Network + SSL + SSH + services")
+ fmt.Println(" [3] SYSTEM BASE only - Network + SSL + SSH + services + filesystem")
fmt.Println(" [4] CUSTOM selection - Choose specific categories")
fmt.Println(" [0] Cancel")
fmt.Print("Choice: ")
@@ -187,7 +200,13 @@ func ShowRestoreModeMenu(ctx context.Context, logger *logging.Logger, systemType
// ShowCategorySelectionMenu displays an interactive category selection menu
func ShowCategorySelectionMenu(ctx context.Context, logger *logging.Logger, availableCategories []Category, systemType SystemType) ([]Category, error) {
- reader := bufio.NewReader(os.Stdin)
+ return ShowCategorySelectionMenuWithReader(ctx, bufio.NewReader(os.Stdin), logger, availableCategories, systemType)
+}
+
+func ShowCategorySelectionMenuWithReader(ctx context.Context, reader *bufio.Reader, logger *logging.Logger, availableCategories []Category, systemType SystemType) ([]Category, error) {
+ if reader == nil {
+ reader = bufio.NewReader(os.Stdin)
+ }
// Filter categories by system type
relevantCategories := make([]Category, 0)
@@ -370,12 +389,12 @@ func ShowRestorePlan(logger *logging.Logger, config *SelectiveRestoreConfig) {
modeName = "FULL restore (all categories)"
case RestoreModeStorage:
if config.SystemType == SystemTypePVE {
- modeName = "STORAGE only (PVE cluster + storage + jobs)"
+ modeName = "STORAGE only (cluster + storage + jobs + mounts)"
} else {
- modeName = "DATASTORE only (PBS config + datastores + jobs)"
+ modeName = "DATASTORE only (datastores + jobs + mounts)"
}
case RestoreModeBase:
- modeName = "SYSTEM BASE only (network + SSL + SSH + services)"
+ modeName = "SYSTEM BASE only (network + SSL + SSH + services + filesystem)"
case RestoreModeCustom:
modeName = fmt.Sprintf("CUSTOM selection (%d categories)", len(config.SelectedCategories))
}
@@ -409,12 +428,22 @@ func ShowRestorePlan(logger *logging.Logger, config *SelectiveRestoreConfig) {
fmt.Println(" • Existing files at these locations will be OVERWRITTEN")
fmt.Println(" • A safety backup will be created before restoration")
fmt.Println(" • Services may need to be restarted after restoration")
+ if (hasCategoryID(config.SelectedCategories, "pve_access_control") || hasCategoryID(config.SelectedCategories, "pbs_access_control")) &&
+ (!hasCategoryID(config.SelectedCategories, "network") || !hasCategoryID(config.SelectedCategories, "ssl")) {
+ fmt.Println(" • TFA/WebAuthn: for best 1:1 compatibility keep the same UI origin (FQDN/hostname and port) and restore 'network' + 'ssl'")
+ }
fmt.Println()
}
// ConfirmRestoreOperation asks for user confirmation before proceeding
func ConfirmRestoreOperation(ctx context.Context, logger *logging.Logger) (bool, error) {
- reader := bufio.NewReader(os.Stdin)
+ return ConfirmRestoreOperationWithReader(ctx, bufio.NewReader(os.Stdin), logger)
+}
+
+func ConfirmRestoreOperationWithReader(ctx context.Context, reader *bufio.Reader, logger *logging.Logger) (bool, error) {
+ if reader == nil {
+ reader = bufio.NewReader(os.Stdin)
+ }
for {
fmt.Println("═══════════════════════════════════════════════════════════════")
fmt.Print("Type 'RESTORE' to proceed or 'cancel' to abort: ")
diff --git a/internal/orchestrator/staging.go b/internal/orchestrator/staging.go
index 6e5bd5f..b3ebdaf 100644
--- a/internal/orchestrator/staging.go
+++ b/internal/orchestrator/staging.go
@@ -11,7 +11,21 @@ var restoreStageSequence uint64
func isStagedCategoryID(id string) bool {
switch strings.TrimSpace(id) {
- case "network", "datastore_pbs", "pbs_jobs":
+ case "network",
+ "datastore_pbs",
+ "pbs_jobs",
+ "pbs_remotes",
+ "pbs_host",
+ "pbs_tape",
+ "storage_pve",
+ "pve_jobs",
+ "pve_notifications",
+ "pbs_notifications",
+ "pve_access_control",
+ "pbs_access_control",
+ "pve_firewall",
+ "pve_ha",
+ "pve_sdn":
return true
default:
return false
diff --git a/internal/orchestrator/unescape_proc_path_test.go b/internal/orchestrator/unescape_proc_path_test.go
new file mode 100644
index 0000000..86c7352
--- /dev/null
+++ b/internal/orchestrator/unescape_proc_path_test.go
@@ -0,0 +1,34 @@
+package orchestrator
+
+import "testing"
+
+func TestUnescapeProcPath(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {name: "no escapes", in: "/mnt/pbs", want: "/mnt/pbs"},
+ {name: "space", in: `/mnt/pbs\040datastore`, want: "/mnt/pbs datastore"},
+ {name: "tab", in: `/mnt/pbs\011datastore`, want: "/mnt/pbs\tdatastore"},
+ {name: "newline", in: `/mnt/pbs\012datastore`, want: "/mnt/pbs\ndatastore"},
+ {name: "backslash", in: `/mnt/pbs\134datastore`, want: `/mnt/pbs\datastore`},
+ {name: "multiple", in: `a\040b\134c`, want: `a b\c`},
+ {name: "incomplete", in: `a\0b`, want: `a\0b`},
+ {name: "non octal digit", in: `a\08b`, want: `a\08b`},
+ {name: "out of range preserved", in: `a\777b`, want: `a\777b`},
+ {name: "null byte", in: `a\000b`, want: "a\x00b"},
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := unescapeProcPath(tt.in); got != tt.want {
+ t.Fatalf("unescapeProcPath(%q)=%q want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/orchestrator/workflow_ui.go b/internal/orchestrator/workflow_ui.go
new file mode 100644
index 0000000..e951c5c
--- /dev/null
+++ b/internal/orchestrator/workflow_ui.go
@@ -0,0 +1,76 @@
+package orchestrator
+
+import (
+ "context"
+ "time"
+)
+
+// ProgressReporter is used by long-running operations (e.g., cloud scans) to provide
+// user-facing progress updates.
+type ProgressReporter func(message string)
+
+// TaskRunner runs a function while presenting progress feedback to the user (CLI/TUI).
+// Implementations may provide a cancel action that cancels the provided context.
+type TaskRunner interface {
+ RunTask(ctx context.Context, title, initialMessage string, run func(ctx context.Context, report ProgressReporter) error) error
+}
+
+type ExistingPathDecision int
+
+const (
+ PathDecisionOverwrite ExistingPathDecision = iota
+ PathDecisionNewPath
+ PathDecisionCancel
+)
+
+// BackupSelectionUI groups prompts used to pick a backup source and a specific backup.
+type BackupSelectionUI interface {
+ TaskRunner
+ ShowMessage(ctx context.Context, title, message string) error
+ ShowError(ctx context.Context, title, message string) error
+ SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error)
+ SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error)
+}
+
+// DecryptWorkflowUI groups prompts used by the decrypt workflow.
+type DecryptWorkflowUI interface {
+ BackupSelectionUI
+ PromptDestinationDir(ctx context.Context, defaultDir string) (string, error)
+ ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error)
+ PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error)
+}
+
+type ClusterRestoreMode int
+
+const (
+ ClusterRestoreAbort ClusterRestoreMode = iota
+ ClusterRestoreSafe
+ ClusterRestoreRecovery
+)
+
+// RestoreWorkflowUI groups prompts used by the restore workflow.
+type RestoreWorkflowUI interface {
+ BackupSelectionUI
+
+ PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error)
+ SelectRestoreMode(ctx context.Context, systemType SystemType) (RestoreMode, error)
+ SelectCategories(ctx context.Context, available []Category, systemType SystemType) ([]Category, error)
+
+ ShowRestorePlan(ctx context.Context, config *SelectiveRestoreConfig) error
+ ConfirmRestore(ctx context.Context) (bool, error)
+ ConfirmCompatibility(ctx context.Context, warning error) (bool, error)
+ SelectClusterRestoreMode(ctx context.Context) (ClusterRestoreMode, error)
+ ConfirmContinueWithoutSafetyBackup(ctx context.Context, cause error) (bool, error)
+ ConfirmContinueWithPBSServicesRunning(ctx context.Context) (bool, error)
+
+ ConfirmFstabMerge(ctx context.Context, title, message string, timeout time.Duration, defaultYes bool) (bool, error)
+
+ SelectExportNode(ctx context.Context, exportRoot, currentNode string, exportNodes []string) (string, error)
+ ConfirmApplyVMConfigs(ctx context.Context, sourceNode, currentNode string, count int) (bool, error)
+ ConfirmApplyStorageCfg(ctx context.Context, storageCfgPath string) (bool, error)
+ ConfirmApplyDatacenterCfg(ctx context.Context, datacenterCfgPath string) (bool, error)
+
+ ConfirmAction(ctx context.Context, title, message, yesLabel, noLabel string, timeout time.Duration, defaultYes bool) (bool, error)
+ RepairNICNames(ctx context.Context, archivePath string) (*nicRepairResult, error)
+ PromptNetworkCommit(ctx context.Context, remaining time.Duration, health networkHealthReport, nicRepair *nicRepairResult, diagnosticsDir string) (bool, error)
+}
diff --git a/internal/orchestrator/workflow_ui_cli.go b/internal/orchestrator/workflow_ui_cli.go
new file mode 100644
index 0000000..940c3b1
--- /dev/null
+++ b/internal/orchestrator/workflow_ui_cli.go
@@ -0,0 +1,300 @@
+package orchestrator
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/tis24dev/proxsave/internal/input"
+ "github.com/tis24dev/proxsave/internal/logging"
+)
+
+type cliWorkflowUI struct {
+ reader *bufio.Reader
+ logger *logging.Logger
+}
+
+func newCLIWorkflowUI(reader *bufio.Reader, logger *logging.Logger) *cliWorkflowUI {
+ if reader == nil {
+ reader = bufio.NewReader(os.Stdin)
+ }
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+ return &cliWorkflowUI{reader: reader, logger: logger}
+}
+
+func (u *cliWorkflowUI) RunTask(ctx context.Context, title, initialMessage string, run func(ctx context.Context, report ProgressReporter) error) error {
+ title = strings.TrimSpace(title)
+ if title != "" {
+ fmt.Fprintf(os.Stderr, "%s\n", title)
+ }
+ initialMessage = strings.TrimSpace(initialMessage)
+ if initialMessage != "" {
+ fmt.Fprintf(os.Stderr, "%s\n", initialMessage)
+ }
+
+ var lastPrinted time.Time
+ var lastMessage string
+ report := func(message string) {
+ message = strings.TrimSpace(message)
+ if message == "" {
+ return
+ }
+ now := time.Now()
+ if message == lastMessage && now.Sub(lastPrinted) < 2*time.Second {
+ return
+ }
+ lastPrinted = now
+ lastMessage = message
+ fmt.Fprintf(os.Stderr, "%s\n", message)
+ }
+
+ return run(ctx, report)
+}
+
+func (u *cliWorkflowUI) ShowMessage(ctx context.Context, title, message string) error {
+ if strings.TrimSpace(title) != "" {
+ fmt.Printf("\n%s\n", title)
+ }
+ if strings.TrimSpace(message) != "" {
+ fmt.Println(message)
+ }
+ return nil
+}
+
+func (u *cliWorkflowUI) ShowError(ctx context.Context, title, message string) error {
+ if strings.TrimSpace(title) != "" {
+ fmt.Fprintf(os.Stderr, "\n%s\n", title)
+ }
+ if strings.TrimSpace(message) != "" {
+ fmt.Fprintln(os.Stderr, message)
+ }
+ return nil
+}
+
+func (u *cliWorkflowUI) SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error) {
+ return promptPathSelection(ctx, u.reader, options)
+}
+
+func (u *cliWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) {
+ return promptCandidateSelection(ctx, u.reader, candidates)
+}
+
+func (u *cliWorkflowUI) PromptDestinationDir(ctx context.Context, defaultDir string) (string, error) {
+ defaultDir = strings.TrimSpace(defaultDir)
+ if defaultDir == "" {
+ defaultDir = "./decrypt"
+ }
+ fmt.Printf("Enter destination directory (default: %s): ", defaultDir)
+ line, err := input.ReadLineWithContext(ctx, u.reader)
+ if err != nil {
+ return "", err
+ }
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ trimmed = defaultDir
+ }
+ return filepath.Clean(trimmed), nil
+}
+
+func (u *cliWorkflowUI) ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) {
+ if strings.TrimSpace(failure) != "" {
+ fmt.Fprintf(os.Stderr, "%s\n", strings.TrimSpace(failure))
+ }
+
+ current := filepath.Clean(path)
+ desc := strings.TrimSpace(description)
+ if desc == "" {
+ desc = "file"
+ }
+
+ fmt.Printf("%s %s already exists.\n", titleCaser.String(desc), current)
+ fmt.Println(" [1] Overwrite")
+ fmt.Println(" [2] Enter a different path")
+ fmt.Println(" [0] Exit")
+
+ for {
+ fmt.Print("Choice: ")
+ inputLine, err := input.ReadLineWithContext(ctx, u.reader)
+ if err != nil {
+ return PathDecisionCancel, "", err
+ }
+ switch strings.TrimSpace(inputLine) {
+ case "1":
+ return PathDecisionOverwrite, "", nil
+ case "2":
+ fmt.Print("Enter new path: ")
+ newPath, err := input.ReadLineWithContext(ctx, u.reader)
+ if err != nil {
+ return PathDecisionCancel, "", err
+ }
+ trimmed := strings.TrimSpace(newPath)
+ if trimmed == "" {
+ continue
+ }
+ return PathDecisionNewPath, filepath.Clean(trimmed), nil
+ case "0":
+ return PathDecisionCancel, "", ErrDecryptAborted
+ default:
+ fmt.Println("Please enter 1, 2 or 0.")
+ }
+ }
+}
+
+func (u *cliWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) {
+ if strings.TrimSpace(previousError) != "" {
+ fmt.Fprintln(os.Stderr, strings.TrimSpace(previousError))
+ }
+
+ displayName = strings.TrimSpace(displayName)
+ if displayName != "" {
+ fmt.Printf("Enter decryption key or passphrase for %s (0 = exit): ", displayName)
+ } else {
+ fmt.Print("Enter decryption key or passphrase (0 = exit): ")
+ }
+
+ inputBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd()))
+ fmt.Println()
+ if err != nil {
+ return "", err
+ }
+
+ trimmed := strings.TrimSpace(string(inputBytes))
+ zeroBytes(inputBytes)
+
+ if trimmed == "" {
+ return "", nil
+ }
+ if trimmed == "0" {
+ return "", ErrDecryptAborted
+ }
+ return trimmed, nil
+}
+
+func (u *cliWorkflowUI) SelectRestoreMode(ctx context.Context, systemType SystemType) (RestoreMode, error) {
+ return ShowRestoreModeMenuWithReader(ctx, u.reader, u.logger, systemType)
+}
+
+func (u *cliWorkflowUI) SelectCategories(ctx context.Context, available []Category, systemType SystemType) ([]Category, error) {
+ return ShowCategorySelectionMenuWithReader(ctx, u.reader, u.logger, available, systemType)
+}
+
+func (u *cliWorkflowUI) ShowRestorePlan(ctx context.Context, config *SelectiveRestoreConfig) error {
+ ShowRestorePlan(u.logger, config)
+ return nil
+}
+
+func (u *cliWorkflowUI) ConfirmRestore(ctx context.Context) (bool, error) {
+ confirmed, err := ConfirmRestoreOperationWithReader(ctx, u.reader, u.logger)
+ if err != nil {
+ return false, err
+ }
+ if !confirmed {
+ return false, nil
+ }
+
+ fmt.Println()
+ fmt.Print("This operation will overwrite existing configuration files on this system.\n\nProceed with overwrite? (yes/no): ")
+ for {
+ line, err := input.ReadLineWithContext(ctx, u.reader)
+ if err != nil {
+ return false, err
+ }
+ switch strings.TrimSpace(strings.ToLower(line)) {
+ case "yes", "y":
+ return true, nil
+ case "no", "n", "":
+ return false, nil
+ default:
+ fmt.Print("Please type 'yes' or 'no': ")
+ }
+ }
+}
+
+func (u *cliWorkflowUI) ConfirmCompatibility(ctx context.Context, warning error) (bool, error) {
+ fmt.Println()
+ fmt.Printf("⚠ %v\n\n", warning)
+ fmt.Print("Do you want to continue anyway? This may cause system instability. (yes/no): ")
+
+ line, err := input.ReadLineWithContext(ctx, u.reader)
+ if err != nil {
+ return false, err
+ }
+ return strings.TrimSpace(strings.ToLower(line)) == "yes", nil
+}
+
+func (u *cliWorkflowUI) SelectClusterRestoreMode(ctx context.Context) (ClusterRestoreMode, error) {
+ choice, err := promptClusterRestoreMode(ctx, u.reader)
+ if err != nil {
+ return ClusterRestoreAbort, err
+ }
+ switch choice {
+ case 1:
+ return ClusterRestoreSafe, nil
+ case 2:
+ return ClusterRestoreRecovery, nil
+ default:
+ return ClusterRestoreAbort, nil
+ }
+}
+
+func (u *cliWorkflowUI) ConfirmContinueWithoutSafetyBackup(ctx context.Context, cause error) (bool, error) {
+ fmt.Println()
+ fmt.Printf("Safety backup failed: %v\n", cause)
+ fmt.Print("Continue without safety backup? (yes/no): ")
+ line, err := input.ReadLineWithContext(ctx, u.reader)
+ if err != nil {
+ return false, err
+ }
+ return strings.TrimSpace(strings.ToLower(line)) == "yes", nil
+}
+
+func (u *cliWorkflowUI) ConfirmContinueWithPBSServicesRunning(ctx context.Context) (bool, error) {
+ fmt.Println()
+ fmt.Println("⚠ PBS services are still running. Continuing restore may lead to inconsistent state.")
+ return promptYesNo(ctx, u.reader, "Continue restore with PBS services still running? (y/N): ")
+}
+
+func (u *cliWorkflowUI) ConfirmFstabMerge(ctx context.Context, title, message string, timeout time.Duration, defaultYes bool) (bool, error) {
+ title = strings.TrimSpace(title)
+ if title != "" {
+ fmt.Printf("\n%s\n", title)
+ }
+ message = strings.TrimSpace(message)
+ if message != "" {
+ fmt.Println(message)
+ fmt.Println()
+ }
+ return promptYesNoWithCountdown(ctx, u.reader, u.logger, "Apply fstab merge?", timeout, defaultYes)
+}
+
+func (u *cliWorkflowUI) SelectExportNode(ctx context.Context, exportRoot, currentNode string, exportNodes []string) (string, error) {
+ return promptExportNodeSelection(ctx, u.reader, exportRoot, currentNode, exportNodes)
+}
+
+func (u *cliWorkflowUI) ConfirmApplyVMConfigs(ctx context.Context, sourceNode, currentNode string, count int) (bool, error) {
+ fmt.Println()
+ if strings.TrimSpace(sourceNode) == strings.TrimSpace(currentNode) {
+ fmt.Printf("Found %d VM/CT configs for node %s\n", count, currentNode)
+ } else {
+ fmt.Printf("Found %d VM/CT configs for exported node %s (will apply to current node %s)\n", count, sourceNode, currentNode)
+ }
+ return promptYesNo(ctx, u.reader, "Apply all VM/CT configs via pvesh? (y/N): ")
+}
+
+func (u *cliWorkflowUI) ConfirmApplyStorageCfg(ctx context.Context, storageCfgPath string) (bool, error) {
+ fmt.Println()
+ fmt.Printf("Storage configuration found: %s\n", strings.TrimSpace(storageCfgPath))
+ return promptYesNo(ctx, u.reader, "Apply storage.cfg via pvesh? (y/N): ")
+}
+
+func (u *cliWorkflowUI) ConfirmApplyDatacenterCfg(ctx context.Context, datacenterCfgPath string) (bool, error) {
+ fmt.Println()
+ fmt.Printf("Datacenter configuration found: %s\n", strings.TrimSpace(datacenterCfgPath))
+ return promptYesNo(ctx, u.reader, "Apply datacenter.cfg via pvesh? (y/N): ")
+}
diff --git a/internal/orchestrator/workflow_ui_tui_decrypt.go b/internal/orchestrator/workflow_ui_tui_decrypt.go
new file mode 100644
index 0000000..88c83b3
--- /dev/null
+++ b/internal/orchestrator/workflow_ui_tui_decrypt.go
@@ -0,0 +1,484 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/tis24dev/proxsave/internal/logging"
+ "github.com/tis24dev/proxsave/internal/tui"
+ "github.com/tis24dev/proxsave/internal/tui/components"
+)
+
+type tuiWorkflowUI struct {
+ configPath string
+ buildSig string
+ logger *logging.Logger
+ buildPage func(title, configPath, buildSig string, content tview.Primitive) tview.Primitive
+
+ selectedBackupSummary string
+}
+
+func newTUIWorkflowUI(configPath, buildSig string, logger *logging.Logger) *tuiWorkflowUI {
+ if strings.TrimSpace(buildSig) == "" {
+ buildSig = "n/a"
+ }
+ if logger == nil {
+ logger = logging.GetDefaultLogger()
+ }
+ return &tuiWorkflowUI{
+ configPath: configPath,
+ buildSig: buildSig,
+ logger: logger,
+ buildPage: buildWizardPage,
+ }
+}
+
+func newTUIRestoreWorkflowUI(configPath, buildSig string, logger *logging.Logger) *tuiWorkflowUI {
+ ui := newTUIWorkflowUI(configPath, buildSig, logger)
+ ui.buildPage = buildRestoreWizardPage
+ return ui
+}
+
+func (u *tuiWorkflowUI) RunTask(ctx context.Context, title, initialMessage string, run func(ctx context.Context, report ProgressReporter) error) error {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ taskCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ app := newTUIApp()
+
+ messageView := tview.NewTextView().
+ SetText(strings.TrimSpace(initialMessage)).
+ SetTextAlign(tview.AlignCenter).
+ SetTextColor(tcell.ColorWhite).
+ SetDynamicColors(true)
+
+ form := components.NewForm(app)
+ form.SetOnCancel(func() {
+ cancel()
+ app.Stop()
+ })
+ form.AddCancelButton("Cancel")
+ enableFormNavigation(form, nil)
+
+ content := tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(messageView, 0, 1, false).
+ AddItem(form.Form, 3, 0, true)
+
+ page := u.buildPage(title, u.configPath, u.buildSig, content)
+ form.SetParentView(page)
+
+ done := make(chan struct{})
+ var runErr error
+
+ report := func(message string) {
+ message = strings.TrimSpace(message)
+ if message == "" {
+ return
+ }
+ app.QueueUpdateDraw(func() {
+ messageView.SetText(message)
+ })
+ }
+
+ go func() {
+ runErr = run(taskCtx, report)
+ close(done)
+ app.QueueUpdateDraw(func() {
+ app.Stop()
+ })
+ }()
+
+ if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
+ cancel()
+ <-done
+ return err
+ }
+
+ cancel()
+ <-done
+ return runErr
+}
+
+func (u *tuiWorkflowUI) ShowMessage(ctx context.Context, title, message string) error {
+ return u.showOKModal(title, message, tui.ProxmoxOrange)
+}
+
+func (u *tuiWorkflowUI) ShowError(ctx context.Context, title, message string) error {
+ return u.showOKModal(title, fmt.Sprintf("%s %s", tui.SymbolError, message), tui.ErrorRed)
+}
+
+func (u *tuiWorkflowUI) showOKModal(title, message string, borderColor tcell.Color) error {
+ app := newTUIApp()
+
+ modal := tview.NewModal().
+ SetText(fmt.Sprintf("%s\n\n[yellow]Press ENTER to continue[white]", strings.TrimSpace(message))).
+ AddButtons([]string{"OK"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ app.Stop()
+ })
+
+ modal.SetBorder(true).
+ SetTitle(fmt.Sprintf(" %s ", strings.TrimSpace(title))).
+ SetTitleAlign(tview.AlignCenter).
+ SetTitleColor(borderColor).
+ SetBorderColor(borderColor).
+ SetBackgroundColor(tcell.ColorBlack)
+
+ page := u.buildPage(title, u.configPath, u.buildSig, modal)
+ return app.SetRoot(page, true).SetFocus(modal).Run()
+}
+
+func (u *tuiWorkflowUI) SelectBackupSource(ctx context.Context, options []decryptPathOption) (decryptPathOption, error) {
+ app := newTUIApp()
+ var (
+ selected decryptPathOption
+ aborted bool
+ )
+
+ list := tview.NewList().ShowSecondaryText(false)
+ list.SetMainTextColor(tcell.ColorWhite).
+ SetSelectedTextColor(tcell.ColorWhite).
+ SetSelectedBackgroundColor(tui.ProxmoxOrange)
+
+ for _, opt := range options {
+ label := fmt.Sprintf("%s (%s)", opt.Label, opt.Path)
+ list.AddItem(label, "", 0, nil)
+ }
+
+ list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
+ if index < 0 || index >= len(options) {
+ return
+ }
+ selected = options[index]
+ app.Stop()
+ })
+ list.SetDoneFunc(func() {
+ aborted = true
+ app.Stop()
+ })
+
+ form := components.NewForm(app)
+ listHeight := len(options)
+ if listHeight < 8 {
+ listHeight = 8
+ }
+ if listHeight > 14 {
+ listHeight = 14
+ }
+ form.Form.AddFormItem(
+ components.NewListFormItem(list).
+ SetLabel("Available backup sources").
+ SetFieldHeight(listHeight),
+ )
+ form.Form.SetFocus(0)
+ form.SetOnCancel(func() {
+ aborted = true
+ })
+ form.AddCancelButton("Cancel")
+ enableFormNavigation(form, nil)
+
+ page := u.buildPage("Select backup source", u.configPath, u.buildSig, form.Form)
+ form.SetParentView(page)
+ if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
+ return decryptPathOption{}, err
+ }
+ if aborted || strings.TrimSpace(selected.Path) == "" {
+ return decryptPathOption{}, ErrDecryptAborted
+ }
+ return selected, nil
+}
+
+func (u *tuiWorkflowUI) SelectBackupCandidate(ctx context.Context, candidates []*decryptCandidate) (*decryptCandidate, error) {
+ app := newTUIApp()
+ var (
+ selected *decryptCandidate
+ aborted bool
+ )
+
+ list := tview.NewList().ShowSecondaryText(false)
+ list.SetMainTextColor(tcell.ColorWhite).
+ SetSelectedTextColor(tcell.ColorWhite).
+ SetSelectedBackgroundColor(tui.ProxmoxOrange)
+
+ type row struct {
+ created string
+ mode string
+ tool string
+ targets string
+ compression string
+ }
+
+ rows := make([]row, len(candidates))
+ var maxMode, maxTool, maxTargets, maxComp int
+
+ for idx, cand := range candidates {
+ created := ""
+ if cand != nil && cand.Manifest != nil {
+ created = cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05")
+ }
+
+ mode := strings.ToUpper(statusFromManifest(cand.Manifest))
+ if mode == "" {
+ mode = "UNKNOWN"
+ }
+
+ toolVersion := "unknown"
+ if cand != nil && cand.Manifest != nil {
+ if v := strings.TrimSpace(cand.Manifest.ScriptVersion); v != "" {
+ toolVersion = v
+ }
+ }
+ tool := "Tool " + toolVersion
+
+ targets := "Targets: unknown"
+ if cand != nil && cand.Manifest != nil {
+ targets = buildTargetInfo(cand.Manifest)
+ }
+
+ comp := ""
+ if cand != nil && cand.Manifest != nil {
+ if c := strings.TrimSpace(cand.Manifest.CompressionType); c != "" {
+ comp = strings.ToUpper(c)
+ }
+ }
+
+ rows[idx] = row{
+ created: created,
+ mode: mode,
+ tool: tool,
+ targets: targets,
+ compression: comp,
+ }
+
+ if len(mode) > maxMode {
+ maxMode = len(mode)
+ }
+ if len(tool) > maxTool {
+ maxTool = len(tool)
+ }
+ if len(targets) > maxTargets {
+ maxTargets = len(targets)
+ }
+ if len(comp) > maxComp {
+ maxComp = len(comp)
+ }
+ }
+
+ for idx, r := range rows {
+ line := fmt.Sprintf(
+ "%2d) %s %-*s %-*s %-*s",
+ idx+1,
+ r.created,
+ maxMode, r.mode,
+ maxTool, r.tool,
+ maxTargets, r.targets,
+ )
+ if maxComp > 0 {
+ line = fmt.Sprintf("%s %-*s", line, maxComp, r.compression)
+ }
+ list.AddItem(line, "", 0, nil)
+ }
+
+ list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
+ if index < 0 || index >= len(candidates) {
+ return
+ }
+ selected = candidates[index]
+ u.selectedBackupSummary = backupSummaryForUI(selected)
+ app.Stop()
+ })
+ list.SetDoneFunc(func() {
+ aborted = true
+ app.Stop()
+ })
+
+ form := components.NewForm(app)
+ listHeight := len(candidates)
+ if listHeight < 8 {
+ listHeight = 8
+ }
+ if listHeight > 14 {
+ listHeight = 14
+ }
+ form.Form.AddFormItem(
+ components.NewListFormItem(list).
+ SetLabel("Available backups").
+ SetFieldHeight(listHeight),
+ )
+ form.Form.SetFocus(0)
+ form.SetOnCancel(func() {
+ aborted = true
+ })
+ form.AddCancelButton("Cancel")
+ enableFormNavigation(form, nil)
+
+ page := u.buildPage("Select backup", u.configPath, u.buildSig, form.Form)
+ form.SetParentView(page)
+ if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
+ return nil, err
+ }
+ if aborted || selected == nil {
+ return nil, ErrDecryptAborted
+ }
+ return selected, nil
+}
+
+func (u *tuiWorkflowUI) PromptDestinationDir(ctx context.Context, defaultDir string) (string, error) {
+ app := newTUIApp()
+ var (
+ destDir string
+ cancelled bool
+ )
+
+ defaultDir = strings.TrimSpace(defaultDir)
+ if defaultDir == "" {
+ defaultDir = "./decrypt"
+ }
+
+ form := components.NewForm(app)
+ label := "Destination directory"
+ form.AddInputFieldWithValidation(label, defaultDir, 48, func(value string) error {
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("destination directory cannot be empty")
+ }
+ return nil
+ })
+ form.SetOnSubmit(func(values map[string]string) error {
+ destDir = strings.TrimSpace(values[label])
+ return nil
+ })
+ form.SetOnCancel(func() {
+ cancelled = true
+ })
+ form.AddSubmitButton("Continue")
+ form.AddCancelButton("Cancel")
+ enableFormNavigation(form, nil)
+
+ page := u.buildPage("Destination directory", u.configPath, u.buildSig, form.Form)
+ form.SetParentView(page)
+ if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
+ return "", err
+ }
+ if cancelled {
+ return "", ErrDecryptAborted
+ }
+ return filepath.Clean(destDir), nil
+}
+
+func (u *tuiWorkflowUI) ResolveExistingPath(ctx context.Context, path, description, failure string) (ExistingPathDecision, string, error) {
+ action, err := promptOverwriteActionFunc(path, description, failure, u.configPath, u.buildSig)
+ if err != nil {
+ return PathDecisionCancel, "", err
+ }
+ switch action {
+ case pathActionOverwrite:
+ return PathDecisionOverwrite, "", nil
+ case pathActionNew:
+ newPath, err := promptNewPathInputFunc(path, u.configPath, u.buildSig)
+ if err != nil {
+ return PathDecisionCancel, "", err
+ }
+ return PathDecisionNewPath, filepath.Clean(newPath), nil
+ default:
+ return PathDecisionCancel, "", ErrDecryptAborted
+ }
+}
+
+func (u *tuiWorkflowUI) PromptDecryptSecret(ctx context.Context, displayName, previousError string) (string, error) {
+ app := newTUIApp()
+ var (
+ secret string
+ cancelled bool
+ )
+
+ name := strings.TrimSpace(displayName)
+ if name == "" {
+ name = "selected backup"
+ }
+
+ infoMessage := fmt.Sprintf("Provide the AGE secret key or passphrase used for [yellow]%s[white].", name)
+ if strings.TrimSpace(previousError) != "" {
+ infoMessage = fmt.Sprintf("%s\n\n[red]%s[white]", infoMessage, strings.TrimSpace(previousError))
+ }
+
+ infoText := tview.NewTextView().
+ SetText(infoMessage).
+ SetWrap(true).
+ SetTextColor(tcell.ColorWhite).
+ SetDynamicColors(true)
+
+ form := components.NewForm(app)
+ label := "Key or passphrase:"
+ form.AddPasswordField(label, 64)
+ form.SetOnSubmit(func(values map[string]string) error {
+ raw := strings.TrimSpace(values[label])
+ if raw == "" {
+ return fmt.Errorf("key or passphrase cannot be empty")
+ }
+ if raw == "0" {
+ cancelled = true
+ return nil
+ }
+ secret = raw
+ return nil
+ })
+ form.SetOnCancel(func() {
+ cancelled = true
+ })
+ form.AddSubmitButton("Continue")
+ form.AddCancelButton("Cancel")
+ enableFormNavigation(form, nil)
+
+ content := tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(infoText, 0, 2, false).
+ AddItem(form.Form, 0, 1, true)
+
+ page := u.buildPage("Decrypt key", u.configPath, u.buildSig, content)
+ form.SetParentView(page)
+ if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
+ return "", err
+ }
+ if cancelled {
+ return "", ErrDecryptAborted
+ }
+ return secret, nil
+}
+
+func backupSummaryForUI(cand *decryptCandidate) string {
+ if cand == nil {
+ return ""
+ }
+
+ base := strings.TrimSpace(cand.DisplayBase)
+ if base == "" {
+ switch {
+ case strings.TrimSpace(cand.BundlePath) != "":
+ base = filepath.Base(strings.TrimSpace(cand.BundlePath))
+ case strings.TrimSpace(cand.RawArchivePath) != "":
+ base = filepath.Base(strings.TrimSpace(cand.RawArchivePath))
+ }
+ }
+
+ created := ""
+ if cand.Manifest != nil {
+ created = cand.Manifest.CreatedAt.Format("2006-01-02 15:04:05")
+ }
+
+ if base == "" {
+ return created
+ }
+ if created == "" {
+ return base
+ }
+ return fmt.Sprintf("%s (%s)", base, created)
+}
diff --git a/internal/orchestrator/workflow_ui_tui_restore.go b/internal/orchestrator/workflow_ui_tui_restore.go
new file mode 100644
index 0000000..7c53681
--- /dev/null
+++ b/internal/orchestrator/workflow_ui_tui_restore.go
@@ -0,0 +1,150 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/tis24dev/proxsave/internal/tui"
+ "github.com/tis24dev/proxsave/internal/tui/components"
+)
+
+func (u *tuiWorkflowUI) SelectRestoreMode(ctx context.Context, systemType SystemType) (RestoreMode, error) {
+ return selectRestoreModeTUI(systemType, u.configPath, u.buildSig, strings.TrimSpace(u.selectedBackupSummary))
+}
+
+func (u *tuiWorkflowUI) SelectCategories(ctx context.Context, available []Category, systemType SystemType) ([]Category, error) {
+ return selectCategoriesTUI(available, systemType, u.configPath, u.buildSig)
+}
+
+func (u *tuiWorkflowUI) ShowRestorePlan(ctx context.Context, config *SelectiveRestoreConfig) error {
+ return showRestorePlanTUI(config, u.configPath, u.buildSig)
+}
+
+func (u *tuiWorkflowUI) ConfirmRestore(ctx context.Context) (bool, error) {
+ return confirmRestoreTUI(u.configPath, u.buildSig)
+}
+
+func (u *tuiWorkflowUI) ConfirmCompatibility(ctx context.Context, warning error) (bool, error) {
+ return promptCompatibilityTUI(u.configPath, u.buildSig, warning)
+}
+
+func (u *tuiWorkflowUI) SelectClusterRestoreMode(ctx context.Context) (ClusterRestoreMode, error) {
+ choice, err := promptClusterRestoreModeTUI(u.configPath, u.buildSig)
+ if err != nil {
+ return ClusterRestoreAbort, err
+ }
+ switch choice {
+ case 1:
+ return ClusterRestoreSafe, nil
+ case 2:
+ return ClusterRestoreRecovery, nil
+ default:
+ return ClusterRestoreAbort, nil
+ }
+}
+
+func (u *tuiWorkflowUI) ConfirmContinueWithoutSafetyBackup(ctx context.Context, cause error) (bool, error) {
+ return promptContinueWithoutSafetyBackupTUI(u.configPath, u.buildSig, cause)
+}
+
+func (u *tuiWorkflowUI) ConfirmContinueWithPBSServicesRunning(ctx context.Context) (bool, error) {
+ return promptContinueWithPBSServicesTUI(u.configPath, u.buildSig)
+}
+
+func (u *tuiWorkflowUI) ConfirmFstabMerge(ctx context.Context, title, message string, timeout time.Duration, defaultYes bool) (bool, error) {
+ recommended := "Recommended action: Skip"
+ if defaultYes {
+ recommended = "Recommended action: Apply"
+ }
+
+ msg := strings.TrimSpace(message)
+ if msg != "" {
+ msg = fmt.Sprintf("%s\n\n%s", recommended, msg)
+ } else {
+ msg = recommended
+ }
+
+ return promptYesNoTUIWithCountdown(ctx, u.logger, title, u.configPath, u.buildSig, msg, "Apply", "Skip", timeout)
+}
+
+func (u *tuiWorkflowUI) SelectExportNode(ctx context.Context, exportRoot, currentNode string, exportNodes []string) (string, error) {
+ app := newTUIApp()
+ var selected string
+ var cancelled bool
+
+ list := tview.NewList().ShowSecondaryText(true)
+ list.SetMainTextColor(tcell.ColorWhite).
+ SetSelectedTextColor(tcell.ColorWhite).
+ SetSelectedBackgroundColor(tui.ProxmoxOrange)
+
+ for _, node := range exportNodes {
+ qemuCount, lxcCount := countVMConfigsForNode(exportRoot, node)
+ list.AddItem(node, fmt.Sprintf("qemu=%d lxc=%d", qemuCount, lxcCount), 0, nil)
+ }
+ list.AddItem("Skip VM/CT apply", "Do not apply VM/CT configs via API", 0, nil)
+
+ list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
+ if index >= 0 && index < len(exportNodes) {
+ selected = exportNodes[index]
+ } else {
+ selected = ""
+ }
+ app.Stop()
+ })
+ list.SetDoneFunc(func() {
+ cancelled = true
+ app.Stop()
+ })
+
+ form := components.NewForm(app)
+ listItem := components.NewListFormItem(list).
+ SetLabel(fmt.Sprintf("Current node: %s", strings.TrimSpace(currentNode))).
+ SetFieldHeight(8)
+ form.Form.AddFormItem(listItem)
+ form.Form.SetFocus(0)
+
+ form.SetOnCancel(func() {
+ cancelled = true
+ })
+ form.AddCancelButton("Cancel")
+ enableFormNavigation(form, nil)
+
+ page := buildRestoreWizardPage("Select export node", u.configPath, u.buildSig, form.Form)
+ form.SetParentView(page)
+
+ if err := app.SetRoot(page, true).SetFocus(form.Form).Run(); err != nil {
+ return "", err
+ }
+ if cancelled {
+ return "", nil
+ }
+ return selected, nil
+}
+
+func (u *tuiWorkflowUI) ConfirmApplyVMConfigs(ctx context.Context, sourceNode, currentNode string, count int) (bool, error) {
+ sourceNode = strings.TrimSpace(sourceNode)
+ currentNode = strings.TrimSpace(currentNode)
+ message := ""
+ if sourceNode == "" || sourceNode == currentNode {
+ message = fmt.Sprintf("Found %d VM/CT configs for node %s.\n\nApply them via pvesh now?", count, currentNode)
+ } else {
+ message = fmt.Sprintf("Found %d VM/CT configs for exported node %s.\nThey will be applied to current node %s.\n\nApply them via pvesh now?", count, sourceNode, currentNode)
+ }
+ return promptYesNoTUIFunc("Apply VM/CT configs", u.configPath, u.buildSig, message, "Apply via API", "Skip")
+}
+
+func (u *tuiWorkflowUI) ConfirmApplyStorageCfg(ctx context.Context, storageCfgPath string) (bool, error) {
+ message := fmt.Sprintf("Storage configuration found:\n\n%s\n\nApply storage.cfg via pvesh now?", strings.TrimSpace(storageCfgPath))
+ return promptYesNoTUIFunc("Apply storage.cfg", u.configPath, u.buildSig, message, "Apply via API", "Skip")
+}
+
+func (u *tuiWorkflowUI) ConfirmApplyDatacenterCfg(ctx context.Context, datacenterCfgPath string) (bool, error) {
+ message := fmt.Sprintf("Datacenter configuration found:\n\n%s\n\nApply datacenter.cfg via pvesh now?", strings.TrimSpace(datacenterCfgPath))
+ return promptYesNoTUIFunc("Apply datacenter.cfg", u.configPath, u.buildSig, message, "Apply via API", "Skip")
+}
+