Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ Thank you so much!
## Recognitions
<a href="https://www.xda-developers.com/i-use-this-free-tool-with-proxmox-backup-server/" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/XDA%20Developers-Article-blue?logo=android" style="height:25px;"/></a>

## Repo Activity
## Release Testing & Feedback
A special thanks to the community members who help by testing releases and reporting issues. 💙

<table align="left">
<tr>
<td align="center" width="160">
<a href="https://github.com/NukeThemTillTheyGlow">
<img src="https://github.com/NukeThemTillTheyGlow.png?size=96" width="56" alt="@NukeThemTillTheyGlow" />
</a>
<br />
<a href="https://github.com/NukeThemTillTheyGlow"><sub><b>@NukeThemTillTheyGlow</b></sub></a>
<br />
<sub>release testing</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/marc6901">
<img src="https://github.com/marc6901.png?size=96" width="56" alt="@marc6901" />
</a>
<br />
<a href="https://github.com/marc6901"><sub><b>@marc6901</b></sub></a>
<br />
<sub>release testing</sub>
</td>
</tr>
</table>

<br clear="all" />

## Repo Activity
![Alt](https://repobeats.axiom.co/api/embed/d9565d6d1ed8222a5da5fedf25c18a9c8beab382.svg "Repobeats analytics image")
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ BACKUP_PVE_FIREWALL=true # PVE firewall configuration
BACKUP_VZDUMP_CONFIG=true # /etc/vzdump.conf

# Access control lists
BACKUP_PVE_ACL=true # User permissions
BACKUP_PVE_ACL=true # Access control (users/roles/groups/ACL; realms when configured)

# Scheduled jobs
BACKUP_PVE_JOBS=true # Backup jobs configuration
Expand Down
18 changes: 9 additions & 9 deletions docs/RESTORE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Restore operations are organized into **20–22 categories** (PBS = 20, PVE = 22
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`)
- **Staged**: extracted to `/tmp/proxsave/restore-stage-*` and then applied in a controlled way (file copy/validation or `pvesh`); when staged files are written to system paths, ProxSave applies them **atomically** and enforces the final permissions/ownership (i.e. not left to `umask`)
- **Export-only**: extracted to an export directory for manual review (never written to system paths)

### PVE-Specific Categories (11 categories)
Expand All @@ -98,7 +98,7 @@ Each category is handled in one of three ways:
| `storage_pve` | PVE Storage Configuration | **Staged** storage definitions (applied via API) + VZDump config | `./etc/pve/storage.cfg`<br>`./etc/pve/datacenter.cfg`<br>`./etc/vzdump.conf` |
| `pve_jobs` | PVE Backup Jobs | **Staged** scheduled backup jobs (applied via API) | `./etc/pve/jobs.cfg`<br>`./etc/pve/vzdump.cron` |
| `pve_notifications` | PVE Notifications | **Staged** notification targets and matchers (applied via API) | `./etc/pve/notifications.cfg`<br>`./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`<br>`./etc/pve/domains.cfg`<br>`./etc/pve/priv/shadow.cfg`<br>`./etc/pve/priv/token.cfg`<br>`./etc/pve/priv/tfa.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`<br>`./etc/pve/domains.cfg` (when present)<br>`./etc/pve/priv/shadow.cfg`<br>`./etc/pve/priv/token.cfg`<br>`./etc/pve/priv/tfa.cfg` |
| `pve_firewall` | PVE Firewall | **Staged** firewall rules and node host firewall (pmxcfs file apply + rollback timer) | `./etc/pve/firewall/`<br>`./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`<br>`./etc/pve/ha/groups.cfg`<br>`./etc/pve/ha/rules.cfg` |
| `pve_sdn` | PVE SDN | **Staged** SDN definitions (pmxcfs file apply; definitions only) | `./etc/pve/sdn/`<br>`./etc/pve/sdn.cfg` |
Expand Down Expand Up @@ -145,8 +145,9 @@ Not all categories are available in every backup. The restore workflow:
### 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`
- `/etc/pve/user.cfg` (includes users/roles/groups/ACL)
- `/etc/pve/domains.cfg` (realms, when present)
- `/etc/pve/priv/{shadow,token,tfa}.cfg` (when present)

**Root safety rail**:
- `root@pam` is preserved from the fresh install (not imported from the backup).
Expand Down Expand Up @@ -2668,11 +2669,10 @@ tar -xzf /path/to/decrypted.tar.gz ./specific/file/path

**Q: Does restore preserve file permissions and ownership?**

A: Yes, completely:
- **Ownership**: UID/GID preserved
- **Permissions**: Mode bits preserved
- **Timestamps**: mtime and atime preserved
- **ctime**: Cannot be set (kernel-managed)
A: Yes:
- **Extraction**: ProxSave preserves UID/GID, mode bits and timestamps (mtime/atime) for extracted entries.
- **Staged categories**: files are extracted under `/tmp/proxsave/restore-stage-*` and then applied to system paths using atomic replace; ProxSave explicitly applies mode bits (not left to `umask`) and preserves/derives ownership/group to match expected system defaults (important on PBS, where `proxmox-backup-proxy` runs as `backup`).
- **ctime**: Cannot be set (kernel-managed).

---

Expand Down
26 changes: 26 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,32 @@ MIN_DISK_SPACE_PRIMARY_GB=5 # Lower threshold
- If it says **DISARMED/CLEARED**, reconnect using the **post-apply IP** (new config remains active).
- Check the rollback log path printed in the footer for details.

#### PBS UI/API fails after restore: `proxmox-backup-proxy` permission denied

**Symptoms**:
- PBS web UI login fails or API requests return authentication errors
- `systemctl status proxmox-backup-proxy` shows a restart loop
- Journal contains errors like:
- `unable to read "/etc/proxmox-backup/user.cfg" - Permission denied (os error 13)`
- `unable to read "/etc/proxmox-backup/authkey.pub" - Permission denied (os error 13)`
- `configuration directory '/etc/proxmox-backup' permission problem`

**Cause**:
- PBS services (notably `proxmox-backup-proxy`) run as user `backup` and require specific ownership/permissions under `/etc/proxmox-backup`.
- If staged restore (or manual file copy) rewrites these files with the wrong owner/group/mode, the services cannot read them and may refuse to start.

**What to do**:
1. Ensure you're running the latest ProxSave build and rerun restore for the staged PBS categories you selected (e.g. `pbs_access_control`, `pbs_jobs`, `pbs_remotes`, `pbs_host`, `pbs_notifications`, `datastore_pbs`). ProxSave applies staged files atomically and enforces final permissions/ownership (not left to `umask`).
2. If PBS is already broken and you need a quick recovery:
- Identify the blocking path component with `namei -l /etc/proxmox-backup/user.cfg`.
- Restore package defaults (recommended): reinstall `proxmox-backup-server` and restart services. Example:
```bash
apt-get update
apt-get install --reinstall proxmox-backup-server
systemctl restart proxmox-backup proxmox-backup-proxy
```
- Or fix ownership/permissions to match a clean install of your PBS version (verify `/etc/proxmox-backup` and the files referenced in the journal are readable by user `backup`).

#### Error during network preflight: `addr_add_dry_run() got an unexpected keyword argument 'nodad'`

**Symptoms**:
Expand Down
17 changes: 13 additions & 4 deletions internal/backup/collector_pve.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ func (c *Collector) populatePVEManifest() {
disableHint string
log bool
countNotFound bool
// suppressNotFoundLog suppresses any log output when a file is not found.
// This is useful for optional files that may not exist on a default installation.
suppressNotFoundLog bool
}

logEntry := func(opts manifestLogOpts, entry ManifestEntry) {
Expand All @@ -186,6 +189,9 @@ func (c *Collector) populatePVEManifest() {
case StatusSkipped:
c.logger.Info(" %s: skipped (excluded)", opts.description)
case StatusNotFound:
if opts.suppressNotFoundLog {
return
}
if strings.TrimSpace(opts.disableHint) != "" {
c.logger.Warning(" %s: not configured. If unused, set %s=false to disable.", opts.description, opts.disableHint)
} else {
Expand Down Expand Up @@ -268,10 +274,13 @@ func (c *Collector) populatePVEManifest() {
countNotFound: true,
})
record(filepath.Join(pveConfigPath, "domains.cfg"), c.config.BackupPVEACL, manifestLogOpts{
description: "Domain configuration",
disableHint: "BACKUP_PVE_ACL",
log: true,
countNotFound: true,
description: "Domain configuration",
disableHint: "BACKUP_PVE_ACL",
log: true,
// domains.cfg may not exist on a default standalone PVE install (built-in realms only).
// Don't warn or count as "missing" in that case.
countNotFound: false,
suppressNotFoundLog: true,
})

// Scheduled jobs.
Expand Down
2 changes: 1 addition & 1 deletion internal/config/templates/backup.env
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ METRICS_PATH=${BASE_DIR}/metrics
BACKUP_CLUSTER_CONFIG=true
BACKUP_PVE_FIREWALL=true
BACKUP_VZDUMP_CONFIG=true
BACKUP_PVE_ACL=true
BACKUP_PVE_ACL=true # Access control (users/roles/groups/ACL; realms when configured)
BACKUP_PVE_JOBS=true
BACKUP_PVE_SCHEDULES=true
BACKUP_PVE_REPLICATION=true
Expand Down
3 changes: 0 additions & 3 deletions internal/orchestrator/.backup.lock

This file was deleted.

176 changes: 176 additions & 0 deletions internal/orchestrator/fs_atomic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package orchestrator

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
)

type uidGid struct {
uid int
gid int
ok bool
}

func uidGidFromFileInfo(info os.FileInfo) uidGid {
if info == nil {
return uidGid{}
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok || st == nil {
return uidGid{}
}
return uidGid{uid: int(st.Uid), gid: int(st.Gid), ok: true}
}

func modeBits(mode os.FileMode) os.FileMode {
return mode & 0o7777
}

func findNearestExistingDirMeta(dir string) (uidGid, os.FileMode) {
dir = filepath.Clean(strings.TrimSpace(dir))
if dir == "" || dir == "." {
return uidGid{}, 0o755
}

candidate := dir
for {
info, err := restoreFS.Stat(candidate)
if err == nil && info != nil && info.IsDir() {
inheritMode := modeBits(info.Mode())
if inheritMode == 0 {
inheritMode = 0o755
}
return uidGidFromFileInfo(info), inheritMode
}

parent := filepath.Dir(candidate)
if parent == candidate || parent == "." || parent == "" {
break
}
candidate = parent
}

return uidGid{}, 0o755
}

func ensureDirExistsWithInheritedMeta(dir string) error {
dir = filepath.Clean(strings.TrimSpace(dir))
if dir == "" || dir == "." || dir == string(os.PathSeparator) {
return nil
}

if info, err := restoreFS.Stat(dir); err == nil {
if info != nil && info.IsDir() {
return nil
}
return fmt.Errorf("path exists but is not a directory: %s", dir)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat %s: %w", dir, err)
}

owner, perm := findNearestExistingDirMeta(filepath.Dir(dir))
if err := restoreFS.MkdirAll(dir, perm); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}

f, err := restoreFS.Open(dir)
if err != nil {
return fmt.Errorf("open dir %s: %w", dir, err)
}
defer f.Close()

if os.Geteuid() == 0 && owner.ok {
if err := f.Chown(owner.uid, owner.gid); err != nil {
return fmt.Errorf("chown dir %s: %w", dir, err)
}
}
if err := f.Chmod(perm); err != nil {
return fmt.Errorf("chmod dir %s: %w", dir, err)
}
return nil
}

func desiredOwnershipForAtomicWrite(destPath string) uidGid {
destPath = filepath.Clean(strings.TrimSpace(destPath))
if destPath == "" || destPath == "." {
return uidGid{}
}

if info, err := restoreFS.Stat(destPath); err == nil && info != nil && !info.IsDir() {
return uidGidFromFileInfo(info)
}

parent := filepath.Dir(destPath)
if info, err := restoreFS.Stat(parent); err == nil && info != nil && info.IsDir() {
parentOwner := uidGidFromFileInfo(info)
if parentOwner.ok {
return uidGid{uid: 0, gid: parentOwner.gid, ok: true}
}
}

return uidGid{}
}

func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return fmt.Errorf("invalid path")
}
perm = modeBits(perm)
if perm == 0 {
perm = 0o644
}

dir := filepath.Dir(path)
if err := ensureDirExistsWithInheritedMeta(dir); err != nil {
return err
}

owner := desiredOwnershipForAtomicWrite(path)

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, 0o600)
if err != nil {
return err
}

writeErr := func() error {
if len(data) == 0 {
return nil
}
_, err := f.Write(data)
return err
}()
if writeErr == nil {
if os.Geteuid() == 0 && owner.ok {
if err := f.Chown(owner.uid, owner.gid); err != nil {
writeErr = err
}
}
if writeErr == nil {
if err := f.Chmod(perm); err != nil {
writeErr = 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
}
Loading
Loading