Compare commits

..

4 Commits

Author SHA1 Message Date
Lierfang Support Team
e2bab5b49d Add crossbuild 2025-07-31 12:37:32 +00:00
Lierfang Support Team
406987a79c fix some error 2025-07-31 09:25:46 +00:00
Lierfang Support Team
65025459b0 change default sources 2025-07-27 00:56:01 +08:00
Lierfang Support Team
b7d1f2c14e add pxvirt support 2025-07-27 00:49:42 +08:00
309 changed files with 10998 additions and 15206 deletions

View File

@ -1,8 +1,32 @@
[source]
[source.debian-packages]
directory = "/usr/share/cargo/registry"
[source.crates-io]
replace-with = "debian-packages"
replace-with = "dh-cargo-registry"
[source.dh-cargo-registry]
directory = "/root/proxmox-backup/debian/cargo_registry"
[build]
rustflags = ['-C', 'debuginfo=2', '-C', 'strip=none', '--cap-lints', 'warn', '--remap-path-prefix', '/root/proxmox-backup=/usr/share/cargo/registry/proxmox-backup-4.0.6', '--remap-path-prefix', '/root/proxmox-backup/debian/cargo_registry=/usr/share/cargo/registry']
[profile.release]
debug=true
debug = true
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
ar = "x86_64-linux-gnu-ar"
rustflags = [
"-L", "/usr/lib/x86_64-linux-gnu",
]
[target.riscv64gc-unknown-linux-gnu]
linker = "riscv64-linux-gnu-gcc"
ar = "riscv64-linux-gnu-ar"
rustflags = [
"-L", "/usr/lib/riscv64-linux-gnu",
]
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
ar = "aarch64-linux-gnu-ar"
rustflags = [
"-L", "/usr/lib/aarch64-linux-gnu",
]

View File

@ -1,5 +1,5 @@
[workspace.package]
version = "4.2.0"
version = "4.0.6"
authors = [
"Dietmar Maurer <dietmar@proxmox.com>",
"Dominik Csapak <d.csapak@proxmox.com>",
@ -53,39 +53,33 @@ path = "src/lib.rs"
[workspace.dependencies]
# proxmox workspace
proxmox-apt = { version = "0.99.7", features = [ "cache" ] }
proxmox-apt-api-types = "2.0.5"
proxmox-apt = { version = "0.99", features = [ "cache" ] }
proxmox-apt-api-types = "2"
proxmox-async = "0.5"
proxmox-auth-api = "1.0.5"
proxmox-auth-api = "1.0.2"
proxmox-base64 = "1"
proxmox-borrow = "1"
proxmox-compression = "1.0.1"
proxmox-compression = "1"
proxmox-config-digest = "1"
proxmox-daemon = "1"
proxmox-fuse = "3"
proxmox-docgen = "1"
proxmox-http = { version = "1.0.2", features = [ "client", "http-helpers", "api-types", "websocket" ] } # see below
proxmox-fuse = "1"
proxmox-http = { version = "1", features = [ "client", "http-helpers", "websocket" ] } # see below
proxmox-human-byte = "1"
proxmox-io = "1.0.1" # tools and client use "tokio" feature
proxmox-lang = "1.1"
proxmox-log = "1"
proxmox-ldap = "1"
proxmox-metrics = "1"
proxmox-network-api = "1"
proxmox-network-types = "1.0.1"
proxmox-notify = "1"
proxmox-openid = "1"
proxmox-parallel-handler = "1"
proxmox-product-config = "1"
proxmox-rate-limiter = "1.0.0"
proxmox-rest-server = { version = "1.0.5", features = [ "templates" ] }
proxmox-rest-server = { version = "1.0.1", features = [ "templates" ] }
# some use "cli", some use "cli" and "server", pbs-config uses nothing
proxmox-router = { version = "3.2.2", default-features = false }
proxmox-rrd = "1"
proxmox-rrd-api-types = "1.0.2"
proxmox-s3-client = { version = "1.4", features = [ "impl" ] }
proxmox-s3-client = { version = "1.0.5", features = [ "impl" ] }
# everything but pbs-config and pbs-client use "api-macro"
proxmox-schema = "5"
proxmox-schema = "4"
proxmox-section-config = "3"
proxmox-serde = "1"
proxmox-shared-cache = "1"
@ -93,19 +87,17 @@ proxmox-shared-memory = "1"
proxmox-sortable-macro = "1"
proxmox-subscription = { version = "1", features = [ "api-types" ] }
proxmox-sys = "1"
proxmox-systemd = "1.0.1"
proxmox-tfa = { version = "6.0.3", features = [ "api", "api-types" ] }
proxmox-systemd = "1"
proxmox-tfa = { version = "6", features = [ "api", "api-types" ] }
proxmox-time = "2"
proxmox-upgrade-checks = "1"
proxmox-uuid = { version = "1", features = [ "serde" ] }
proxmox-worker-task = "1"
pbs-api-types = "1.0.15"
pbs-api-types = "1.0.2"
# other proxmox crates
pathpatterns = "1"
proxmox-acme = "1.1"
proxmox-acme-api = { version = "1.0.2", features = [ "impl" ] }
pxar = "1.0.1"
proxmox-acme = "1"
pxar = "1"
# PBS workspace
pbs-buildcfg = { path = "pbs-buildcfg" }
@ -147,7 +139,6 @@ nom = "7"
num-traits = "0.2"
once_cell = "1.3.1"
openssl = "0.10.40"
parking_lot = "0.12"
percent-encoding = "2.1"
pin-project-lite = "0.2"
regex = "1.5.5"
@ -157,7 +148,6 @@ serde_json = "1.0"
siphasher = "0.3"
syslog = "6"
tar = "0.4"
tempfile = "3.15.0"
termcolor = "1.1.2"
thiserror = "2"
tokio = "1.6"
@ -224,7 +214,6 @@ proxmox-base64.workspace = true
proxmox-compression.workspace = true
proxmox-config-digest.workspace = true
proxmox-daemon.workspace = true
proxmox-docgen.workspace = true
proxmox-http = { workspace = true, features = [ "body", "client-trait", "proxmox-async", "rate-limited-stream" ] } # pbs-client doesn't use these
proxmox-human-byte.workspace = true
proxmox-io.workspace = true
@ -232,13 +221,8 @@ proxmox-lang.workspace = true
proxmox-log.workspace = true
proxmox-ldap.workspace = true
proxmox-metrics.workspace = true
proxmox-network-api = { workspace = true, features = [ "impl" ] }
proxmox-network-types.workspace = true
proxmox-notify = { workspace = true, features = [ "pbs-context" ] }
proxmox-openid.workspace = true
proxmox-product-config.workspace = true
proxmox-parallel-handler.workspace = true
proxmox-rate-limiter = { workspace = true, features = [ "shared-rate-limiter" ] }
proxmox-rest-server = { workspace = true, features = [ "rate-limited-stream" ] }
proxmox-router = { workspace = true, features = [ "cli", "server"] }
proxmox-s3-client.workspace = true
@ -253,14 +237,12 @@ proxmox-sys = { workspace = true, features = [ "timer" ] }
proxmox-systemd.workspace = true
proxmox-tfa.workspace = true
proxmox-time.workspace = true
proxmox-upgrade-checks.workspace = true
proxmox-uuid.workspace = true
proxmox-worker-task.workspace = true
pbs-api-types.workspace = true
# in their respective repo
proxmox-acme.workspace = true
proxmox-acme-api.workspace = true
pxar.workspace = true
# proxmox-backup workspace/internal crates
@ -279,8 +261,6 @@ proxmox-rrd-api-types.workspace = true
[patch.crates-io]
#pbs-api-types = { path = "../proxmox/pbs-api-types" }
#proxmox-acme = { path = "../proxmox/proxmox-acme" }
#proxmox-acme-api = { path = "../proxmox/proxmox-acme-api" }
#proxmox-api-macro = { path = "../proxmox/proxmox-api-macro" }
#proxmox-apt = { path = "../proxmox/proxmox-apt" }
#proxmox-apt-api-types = { path = "../proxmox/proxmox-apt-api-types" }
#proxmox-async = { path = "../proxmox/proxmox-async" }
@ -290,32 +270,23 @@ proxmox-rrd-api-types.workspace = true
#proxmox-compression = { path = "../proxmox/proxmox-compression" }
#proxmox-config-digest = { path = "../proxmox/proxmox-config-digest" }
#proxmox-daemon = { path = "../proxmox/proxmox-daemon" }
#proxmox-docgen = { path = "../proxmox/proxmox-docgen" }
#proxmox-fuse = { path = "../proxmox-fuse" }
#proxmox-http = { path = "../proxmox/proxmox-http" }
#proxmox-http-error = { path = "../proxmox/proxmox-http-error" }
#proxmox-human-byte = { path = "../proxmox/proxmox-human-byte" }
#proxmox-io = { path = "../proxmox/proxmox-io" }
#proxmox-lang = { path = "../proxmox/proxmox-lang" }
#proxmox-ldap = { path = "../proxmox/proxmox-ldap" }
#proxmox-log = { path = "../proxmox/proxmox-log" }
#proxmox-ldap = { path = "../proxmox/proxmox-ldap" }
#proxmox-metrics = { path = "../proxmox/proxmox-metrics" }
#proxmox-network-api = { path = "../proxmox/proxmox-network-api" }
#proxmox-network-types = { path = "../proxmox/proxmox-network-types" }
#proxmox-notify = { path = "../proxmox/proxmox-notify" }
#proxmox-openid = { path = "../proxmox/proxmox-openid" }
#proxmox-parallel-handler = { path = "../proxmox/proxmox-parallel-handler" }
#proxmox-product-config = { path = "../proxmox/proxmox-product-config" }
#proxmox-rate-limiter = { path = "../proxmox/proxmox-rate-limiter" }
#proxmox-rest-server = { path = "../proxmox/proxmox-rest-server" }
#proxmox-router = { path = "../proxmox/proxmox-router" }
#proxmox-rrd = { path = "../proxmox/proxmox-rrd" }
#proxmox-rrd-api-types = { path = "../proxmox/proxmox-rrd-api-types" }
#proxmox-s3-client = { path = "../proxmox/proxmox-s3-client" }
#proxmox-schema = { path = "../proxmox/proxmox-schema" }
#proxmox-section-config = { path = "../proxmox/proxmox-section-config" }
#proxmox-sendmail = { path = "../proxmox/proxmox-sendmail" }
#proxmox-serde = { path = "../proxmox/proxmox-serde" }
#proxmox-shared-cache = { path = "../proxmox/proxmox-shared-cache" }
#proxmox-shared-memory = { path = "../proxmox/proxmox-shared-memory" }
#proxmox-sortable-macro = { path = "../proxmox/proxmox-sortable-macro" }
#proxmox-subscription = { path = "../proxmox/proxmox-subscription" }
@ -323,11 +294,9 @@ proxmox-rrd-api-types.workspace = true
#proxmox-systemd = { path = "../proxmox/proxmox-systemd" }
#proxmox-tfa = { path = "../proxmox/proxmox-tfa" }
#proxmox-time = { path = "../proxmox/proxmox-time" }
#proxmox-upgrade-checks = { path = "../proxmox/proxmox-upgrade-checks" }
#proxmox-uuid = { path = "../proxmox/proxmox-uuid" }
#proxmox-worker-task = { path = "../proxmox/proxmox-worker-task" }
#proxmox-fuse = {path = "../proxmox-fuse" }
#pathpatterns = {path = "../pathpatterns" }
#pxar = { path = "../pxar" }

View File

@ -3,7 +3,8 @@ include /usr/share/rustc/architecture.mk
include defines.mk
PACKAGE := proxmox-backup
ARCH := $(DEB_BUILD_ARCH)
ARCH := $(DEB_HOST_ARCH)
CPU := $(DEB_HOST_GNU_CPU)
export DEB_HOST_RUST_TYPE
SUBDIRS := etc www docs templates
@ -89,7 +90,7 @@ DESTDIR=
tests ?= --workspace
all: proxmox-backup-client-static $(SUBDIRS)
all: $(SUBDIRS)
.PHONY: $(SUBDIRS)
$(SUBDIRS):
@ -109,6 +110,10 @@ build:
rm -rf build
mkdir build
git rev-parse HEAD > build/.repoid
sed -i "s/x86_64/$(CPU)/g" debian/proxmox-backup-server.install
sed -i "s/x86_64/$(CPU)/g" debian/proxmox-backup-file-restore.postinst
sed -i "s/x86_64/$(CPU)/g" debian/proxmox-backup-file-restore.install
sed -i "s/x86_64/$(CPU)/g" debian/lintian-overrides
cp -a debian \
Cargo.toml src \
$(SUBCRATES) \
@ -124,21 +129,21 @@ build:
.PHONY: proxmox-backup-docs
$(DOC_DEB) $(DEBS): proxmox-backup-docs
proxmox-backup-docs: build
cd build; dpkg-buildpackage -b -us -uc --no-pre-clean
cd build; dpkg-buildpackage -b -us -uc --no-pre-clean -a$(ARCH)
lintian $(DOC_DEB)
.PHONY: deb dsc deb-nodoc deb-nostrip
deb-nodoc: build
cd build; dpkg-buildpackage -b -us -uc --no-pre-clean --build-profiles=nodoc
cd build; dpkg-buildpackage -b -us -uc --no-pre-clean --build-profiles=nodoc -a$(ARCH)
lintian $(DEBS)
deb-nostrip: build
cd build; DEB_BUILD_OPTIONS=nostrip dpkg-buildpackage -b -us -uc
cd build; DEB_BUILD_OPTIONS=nostrip dpkg-buildpackage -b -us -uc -a$(ARCH)
lintian $(DEBS) $(DOC_DEB)
$(DEBS): deb
deb: build
cd build; dpkg-buildpackage -b -us -uc
cd build; dpkg-buildpackage -b -us -uc -a$(ARCH)
lintian $(DEBS) $(DOC_DEB)
.PHONY: dsc
@ -207,6 +212,7 @@ $(COMPILED_BINS) $(COMPILEDIR)/dump-catalog-shell-cli $(COMPILEDIR)/docgen &:
.PHONY: proxmox-backup-client-static
proxmox-backup-client-static:
export CRATE_CC_NO_DEFAULTS=0
$(MAKE) $(STATIC_BINS)
$(STATIC_BINS) &:
@ -242,8 +248,8 @@ install: $(COMPILED_BINS) $(STATIC_BINS)
install -m4755 -o root -g root $(COMPILEDIR)/sg-tape-cmd $(DESTDIR)$(LIBEXECDIR)/proxmox-backup/sg-tape-cmd
$(foreach i,$(SERVICE_BIN), \
install -m755 $(COMPILEDIR)/$(i) $(DESTDIR)$(LIBEXECDIR)/proxmox-backup/ ;)
install -m755 $(STATIC_COMPILEDIR)/proxmox-backup-client $(DESTDIR)$(BINDIR)/proxmox-backup-client-static
install -m755 $(STATIC_COMPILEDIR)/pxar $(DESTDIR)$(BINDIR)/pxar-static
# install -m755 $(STATIC_COMPILEDIR)/proxmox-backup-client $(DESTDIR)$(BINDIR)/proxmox-backup-client-static
# install -m755 $(STATIC_COMPILEDIR)/pxar $(DESTDIR)$(BINDIR)/pxar-static
$(MAKE) -C www install
$(MAKE) -C docs install
$(MAKE) -C templates install
@ -254,7 +260,7 @@ upload: $(SERVER_DEB) $(CLIENT_DEB) $(RESTORE_DEB) $(DOC_DEB) $(STATIC_CLIENT_DE
# check if working directory is clean
git diff --exit-code --stat && git diff --exit-code --stat --staged
tar cf - $(SERVER_DEB) $(SERVER_DBG_DEB) $(DOC_DEB) $(CLIENT_DEB) $(CLIENT_DBG_DEB) \
| ssh -X repoman@repo.proxmox.com upload --product pbs --dist $(UPLOAD_DIST) --arch $(DEB_HOST_ARCH)
tar cf - $(CLIENT_DEB) $(CLIENT_DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product "pve,pmg,pbs-client" --dist $(UPLOAD_DIST) --arch $(DEB_HOST_ARCH)
tar cf - $(STATIC_CLIENT_DEB) $(STATIC_CLIENT_DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product "pbs-client" --dist $(UPLOAD_DIST) --arch $(DEB_HOST_ARCH)
tar cf - $(RESTORE_DEB) $(RESTORE_DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product "pve" --dist $(UPLOAD_DIST) --arch $(DEB_HOST_ARCH)
| ssh -X repoman@repo.proxmox.com upload --product pbs --dist $(UPLOAD_DIST)
tar cf - $(CLIENT_DEB) $(CLIENT_DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product "pve,pmg,pbs-client" --dist $(UPLOAD_DIST)
tar cf - $(STATIC_CLIENT_DEB) $(STATIC_CLIENT_DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product "pbs-client" --dist $(UPLOAD_DIST)
tar cf - $(RESTORE_DEB) $(RESTORE_DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product "pve" --dist $(UPLOAD_DIST)

View File

@ -65,23 +65,6 @@ You are now able to build using the Makefile or cargo itself, e.g.::
# # or for a non-package build
# cargo build --all --release
Building the online documentation
=================================
The online documentation can be build in HTML format as follows. First build the
required binaries::
make docs
The previous step is only necessary once. Then the online documentation can be
built or regenerated with::
make -C docs DEB_HOST_RUST_TYPE=x86_64-unknown-linux-gnu html
With the `rust target <https://doc.rust-lang.org/rustc/platform-support.html>`__
of the host. The resulting documentation will be at ``docs/output/html/`` and
can be open with your browser of choice or, e.g. ``xdg-open``.
Design Notes
************
@ -146,7 +129,7 @@ a backup::
CHUNK_COUNT = BACKUP_SIZE / ACS
Here are some staticics from my developer workstation::
Here are some staticics from my developer worstation::
Disk Usage: 65 GB
Directories: 58971

886
debian/changelog vendored
View File

@ -1,861 +1,3 @@
rust-proxmox-backup (4.2.0-1) trixie; urgency=medium
* bump version for 4.2 release.
* ui: datastore list summary: guard against missing history field, which
can happen on fresh installations.
-- Proxmox Support Team <support@proxmox.com> Tue, 28 Apr 2026 23:51:16 +0200
rust-proxmox-backup (4.1.13-1) trixie; urgency=medium
* fix #5247: client: sanitize relative paths in pxar exclude patterns.
Patterns starting with ./ or containing /../ silently never matched any
files during a backup; they now go through the same sanitization as the
paths they are matched against, and emit a warning if rewritten.
* sync: rename the internal manifest field 'change-detection-fingerprint' to
'sync-source-signature'. Avoids confusion with the unrelated client
'change-detection-mode' parameter, especially in log output. Read access
falls back to the legacy field name for already-synced snapshots.
* pull sync: fix subsequent decrypting pulls re-downloading an unchanged
snapshot for the chain signed backup -> encrypting push -> decrypting
pull. Decrypt-pull strips the 'sign-only' crypt-mode marker on the local
target so the source-side signature check no longer reproduces; now also
accept the target-side signature as proof the source did not change.
* pull sync: store and check a sync-source-signature on decrypt-on-pull, so
subsequent pulls of an unchanged source snapshot can be skipped without
re-downloading and re-decrypting the snapshot's files.
* client: pxar: rework the metadata-mode incremental-backup fix from 4.1.12
to avoid metadata-archive index bloat. Use a tighter chunk-identity check
at the chunk-reuse decision sites instead of forcing injection on every
cache-range hole; preserves the PXAR_PAYLOAD_REF monotonicity guarantee
without over-injecting on innocent holes (e.g. vanished tiny files) like
the 4.1.12 form did.
* datastore: dynamic-index reader: read ctime from the index header instead
of the current time. Accidentally regressed by a 2020 refactor; reachable
only via the proxmox-backup-debug inspect tool, which now shows the actual
creation time of an inspected .didx file.
* proxmox-backup-debug: produce deterministic output when inspecting index
files (sort the chunk-digest list).
* ui: datastore content: reliably refresh the view after namespace
operations. Always reload after a partial namespace move so
successfully-moved groups disappear from the source view, and fix
double-click navigation into a namespace that was just added by a manual
reload.
* ui: datastore content: widen the actions column so all icons (including
the file-browser icon) remain visible after the namespace/group move
feature added another icon.
* datastore: when a backup group lock cannot be acquired, log the on-disk
paths of the group and lock file instead of an internal BackupGroup struct
debug dump.
* datastore: move group / move namespace: polish operator-facing log and
error output. Use human-readable snapshot timestamps (not raw epoch
seconds) in overlap errors, emit the "moving group X" context log line
before the pre-move check so subsequent warnings have visible context, use
"snapshot time mismatch" consistently instead of mixing "overlap" and
"mismatch", and normalize "namespace(s)"/"group(s)" pluralization.
-- Proxmox Support Team <support@proxmox.com> Tue, 28 Apr 2026 18:32:42 +0200
rust-proxmox-backup (4.1.12-1) trixie; urgency=medium
* pull sync: surface CRC errors on the source manifest as actual errors
instead of silently treating them as a vanished snapshot.
* pull sync: bail with a snapshot-prefixed 'archive missing on source' error
when fetching a referenced archive returns no data, instead of silently
continuing and failing later in the index parser with a confusing message.
* pull sync: handle vanished snapshots uniformly between local and remote
sources, so a concurrent prune during a local-source sync no longer aborts
the whole group with 'No such file or directory' but skips the affected
snapshot like the remote path already did.
* pull sync: clean up leftover .tmp files and the otherwise empty
freshly-created snapshot directory when a source snapshot vanishes
mid-sync or a referenced archive is missing, and drop the misleading 'sync
done' log line for snapshots that never actually synced.
* pull sync: emit an explicit 'skipped' log line for snapshots that fail the
verified-only or encrypted-only post-filter check, so the encrypted-only
rejection case is no longer silent.
* fix client metadata-mode incremental backups breaking with strict
payload-offset rejections introduced in pxar 1.0.1: hold-back chunks could
survive across cache-range discontinuities and produce non-monotonic
PXAR_PAYLOAD_REF offsets on disk. Inject any held chunk explicitly when a
discontinuity is detected, and re-encode files with non-monotonic previous
offsets on the next backup so already affected archives self-heal without
needing a --change-detection-mode=data run.
* client: show the full error context on upload failures in single-line
form, restoring information that was lost when the metadata and payload
upload streams were split.
* ui: sync/verify view: show the correct duration of finished tasks in the
task log window, instead of computing the duration against the current
time.
-- Proxmox Support Team <support@proxmox.com> Tue, 28 Apr 2026 03:47:18 +0200
rust-proxmox-backup (4.1.11-1) trixie; urgency=medium
* pull sync: avoid double-decoding blob files on the decrypt-on-pull path,
which broke any pull sync job that should have decrypted snapshot
manifests, catalogs or other blob files using a configured decryption key.
The decrypt-on-pull feature was introduced in 4.1.9.
* pull sync: also fetch the client log for local sync jobs, where the
implementation had been a no-op stub since the trait was first introduced.
Skip silently if the source snapshot has no client log, matching the
existing behavior of remote sync jobs.
* pull sync: decrypt the client log on the fly when a matching decryption
key is configured, mirroring the existing decrypt-on-pull behavior for the
rest of a snapshot's data.
* ui: encryption keys: always enable the tape key restore button and rename
it to 'Restore Tape Key'. Restoring loads a new key, so an existing tape
key does not need to be selected first; this matches the existing behavior
in the tape backup view.
-- Proxmox Support Team <support@proxmox.com> Sun, 26 Apr 2026 01:52:39 +0200
rust-proxmox-backup (4.1.10-1) trixie; urgency=medium
* api: add new '/nodes/localhost/identity' endpoint that returns a stable,
unique instance ID derived from /etc/machine-id, for uniquely identifying
a PBS node. Primarily useful for matching PBS storages in a PVE cluster to
PBS remotes in PDM.
* client, manager: add 'server-identity' CLI subcommand to query the
instance ID from the backup client and admin tools.
* cli: migrate the 'move-group' and 'move-namespace' commands introduced in
4.1.9 from the manager to the backup client, exposed as 'group move' and
'namespace move' respectively, where they fit better alongside the other
group and namespace subcommands. This also implicitly fixes any permission
issue of the manager code having called the API handler directly without
going over the daemon.
* api: namespace move: create the target namespace automatically if it does
not exist yet (its parent namespace must still exist), matching what a
plain file 'mv' does.
* fix: sync: pull: distinguish signed-only from fully encrypted snapshots
when deciding whether to decrypt on pull. The mere presence of a key
fingerprint on the source manifest was previously taken as proof of
encryption, which could cause pull sync jobs with a matching decryption
key to fail on signed-only snapshots. Pull sync now decrypts only if all
registered files are actually encrypted, and falls back to a regular pull
with a warning otherwise.
* partially fix #6372: acme: also pick a shorter renewal lead of half the
certificate lifetime for short-lived certificates (under ten days total),
following the Let's Encrypt integration guide. The previous 1/3 fraction
still applies for longer-lived certificates.
* ui: datastore create: drop the now outdated 'technology preview' label for
S3-backed datastores.
* ui: tape: tune vertical alignment of the tape icon in tree views so it
sits better next to the surrounding text.
* docs: describe the S3 request counters, how they can trigger
notifications, and the periodic threshold reset schedule.
* docs: list previously missing entries under the configuration subtree with
a short description of each.
-- Proxmox Support Team <support@proxmox.com> Fri, 24 Apr 2026 22:59:22 +0200
rust-proxmox-backup (4.1.9-1) trixie; urgency=medium
* s3: graduate the S3 datastore backend from technology preview to a fully
supported feature; drop the corresponding preview note from the docs.
* fix #7251: sync jobs: support server-side encryption of snapshots using
encryption keys stored on the datastore server. Push sync jobs can encrypt
previously unencrypted source snapshots on the fly while uploading to the
remote; pull sync jobs can decrypt snapshots with a matching key and store
them unencrypted locally. Adds a new 'encryption-keys' section-config plus
CRUD API for managing keys, and sync job properties for referencing one
active encryption key and an optional list of associated (decryption-only)
keys.
* config: add 'encryption-keys' as ACL subpath under 'system' for more
granular access control over individual encryption keys.
* ui: expose encryption key management via a new menu (list, create,
archive/unarchive and delete keys) and allow assigning an active
encryption key and associated decryption keys in the sync job editors.
* fix: sync: pull: also upload the client log to s3-backed target datastores
in the "no data changes" fast-path; this was previously only done on full
resync.
* docs: add chapter on server-side encryption for sync jobs, covering key
management, associated keys and requirements for removing keys.
* ui: sync job edit: show a direction-specific hint in the encryption panel,
since pull sync jobs only use configured keys to decrypt and never
encrypt, which is easy to miss without an explicit note.
* api/ui: sync job: reject setting the active encryption key on pull sync
jobs (including switching a push job with an active key over to pull
without clearing it first), since the active key is only consumed by the
push side and would otherwise be silently ignored.
* add move operations for backup groups and entire namespace subtrees within
the same datastore, covering both filesystem and S3 backends and
preserving group notes. Snapshots can be merged into an existing target
group when ownership matches and source snapshots are strictly newer.
Exposed via new API endpoints, UI actions on groups and namespaces, and
'proxmox-backup-manager datastore move-group' / 'move-namespace' CLI
commands.
* datastore: add a per-datastore move journal consumed by the mark phase of
garbage collection, closing a race where a concurrent move-group or
move-namespace could cause the mark phase to miss a relocated index, which
would let the sweep phase remove its still-referenced chunks.
* docs: add section on moving namespaces and groups.
* ui: show empty backup groups. Previously, removing the last snapshot of a
group also removed the group itself, discarding group-level metadata like
notes; it also prevented cleaning up groups left behind by a partially
failed move on an S3-backed datastore without going through the API.
* partially fix #6372: acme: scale certificate renewal check cadence with
certificate lifetime. Start renewing once 2/3 of the total lifetime have
passed instead of the hard-coded 30 days, with a minimum of 3 days
remaining to still cope with transient failures. This lets short-lived
(e.g. 7-day STAR) certificates be renewed in time, while matching the
previous behavior for common 90-day certificates. The certificate is now
parsed only once per renewal check, and a warning is logged when falling
back to the 30-day default because the lifetime could not be determined.
* docs: sync: document the new 'worker-threads' option for parallel sync
jobs.
* docs: storage: document the 'gc-on-unmount' flag for removable datastores.
-- Proxmox Support Team <support@proxmox.com> Thu, 23 Apr 2026 22:26:59 +0200
rust-proxmox-backup (4.1.8-1) trixie; urgency=medium
* fix #4182: sync jobs: allow pulling or pushing backup groups in parallel,
configurable via the new 'worker-threads' sync job property (1 to 32,
defaults to 1 for sequential). Significantly improves throughput on
high-latency connections where the single HTTP/2 connection to the source
was limited by head-of-line blocking.
* sync jobs: in parallel mode prefix log messages by group and buffer them
briefly, so lines from concurrent groups stay readable and do not
interleave excessively.
* ui: sync job edit: expose the new parallel group worker count setting.
* api: datastore: add 'gc-on-unmount' option for removable datastores,
which runs garbage collection automatically before unmounting. This gives
setups relying on auto-unmount a natural point to run GC. The unmount
waits for the GC run, or an already running one, to complete. Rejected
on non-removable datastores.
* ui: datastore options: expose the new 'gc-on-unmount' toggle for
removable datastores.
* tools: move ParallelHandler to the new proxmox-parallel-handler crate in
the proxmox-rs workspace, for reuse across Proxmox products.
* bump proxmox-shared-memory to 1.0.2, which immediately flushes the
contents of newly created mmap files, avoiding a possible race between
a file being initialized and another consumer opening it while checking
its header.
* client: add --server, --port, --datastore, --auth-id and --ns as
per-field alternatives to the compound --repository URL across all
client tools (proxmox-backup-client, proxmox-file-restore,
proxmox-backup-debug). Each has a matching PBS_SERVER, PBS_PORT,
PBS_DATASTORE, PBS_AUTH_ID environment variable that merges with the
CLI per-field, with CLI options overriding the environment.
* fix #5340: client: add PBS_NAMESPACE environment variable as a
per-tool fallback for --ns, consistent with the other PBS_* atom
environment variables.
* docs: document the new per-field client repository options, their
mutual exclusion with --repository, and the corresponding PBS_*
environment variables.
* fix #7337: ui: preserve the target deep link when redirecting through
an OpenID login round-trip. The hash was previously dropped, sending
users to the dashboard instead of their originally requested view
after logging in.
* fix #7400: handle corrupted or missing job statefiles gracefully. The
API no longer errors out when the statefile cannot be parsed (a
default schedule status is returned instead), and the scheduler loops
actively overwrite corrupted statefiles on the next run so affected
jobs resume normally.
* pbs-key-config: fsync() newly stored encryption keys to match the
replace path, so a crash between key creation and the next filesystem
flush cannot leave the key partially written on disk.
-- Proxmox Support Team <support@proxmox.com> Wed, 22 Apr 2026 20:00:25 +0200
rust-proxmox-backup (4.1.7-2) trixie; urgency=medium
* bump pxar dependency, adding a consistency check for payload reference
offsets in split archives.
* fix #7382: correctly anchor nested paths for include/exclude patterns.
Anchored patterns in subdirectories were not matched because the leading
slash was not prepended to the match path.
* fix #7329: api: avoid overwriting existing IPv6 config method. Setting
IPv4 configuration could silently reset the IPv6 method to manual, causing
the inet6 stanza to be dropped from the interfaces file.
* system report: adapt to the ZFS arcstat CLI tool getting renamed to
zarcstat.
* fix #7412: ui: dashboard: persist datastore usage column configuration
across page reloads and navigations.
* ui: task descriptions: use title case also for 'Format Media' task.
* s3: bump proxmox-s3-client to 1.4.0, fixing the Content-Type for object
upload and copy requests to `application/octet-stream` instead of the
non-standard `binary/octet`.
* fix #6716: pass node http proxy config to s3 backend, so that S3
operations respect the configured HTTP proxy.
* s3: add request statistics with configurable notification thresholds and
scheduled counter resets, allowing to monitor and alert on S3 backend
traffic.
* partially fix #6563: ui: expose s3 rrd charts in datastore summary.
* api: metrics: support sub-path metric object IDs, so that S3 sub-metrics
inherit their parent datastore's permissions.
* api: datastore: include backend type in listing response, allowing the UI
to distinguish backends without loading the full config.
* ui: s3: hide S3-only options for non-S3 datastores.
* ui: datastore summary improvements:
- give notes panel its own full row.
- use backend-type from datastore list API for immediate S3 detection.
-- Proxmox Support Team <support@proxmox.com> Wed, 15 Apr 2026 16:22:30 +0200
rust-proxmox-backup (4.1.6-1) trixie; urgency=medium
* fix #3723: api: remote: optionally use node proxy config for http client
* fix #7078: ui: S3: expose "DeleteObjects via DeleteObject" provider quirk
* api: status/datastore: expose backend type in datastore status/list
* ui: Set datastore usage related titles based on backend
* bump proxmox-fuse to 3
* fix #7017: cache token.shadow verification results
-- Proxmox Support Team <support@proxmox.com> Thu, 19 Mar 2026 12:38:43 +0100
rust-proxmox-backup (4.1.5-2) trixie; urgency=medium
* api: backup: don't verify total file size for incremental backups to fix a
critical regression with the previous version bump and incremental qemu
backups. As there, the submitted value is currently the upload size
instead of the total size.
* api: backup: disallow incremental backups with different index length
again.
Currently, fixed index files are only reused for qemu backups where the
total size never changes at the moment.
-- Proxmox Support Team <support@proxmox.com> Tue, 10 Mar 2026 19:33:30 +0100
rust-proxmox-backup (4.1.5-1) trixie; urgency=medium
* fix #7331: ui: tape: allow up to 256 drives per changer, matching the
backend capability that was previously only limited in the web interface.
* chunk store: s3: mute spurious warning on overwriting empty chunks in the
local cache during normal cache eviction.
* datastore: don't skip empty namespaces on s3 refresh, ensuring they are
properly synchronized to the local cache.
* fix #3847: client: support fifo pipe inputs for image backups, allowing
users to pipe data of unknown size as fixed-index backup input. The backup
API no longer requires the file size upfront for fixed-index uploads.
* client: fail early if the same pipe is specified for multiple inputs, as
the files with repeated pipes will always be empty.
* datastore: support writing fidx (fixed-index) files on systems with larger
page size, fixing undefined behavior on ARM systems with 64KB pages.
* datastore: support incremental fidx uploads with different size, allowing
incremental backups even when the image size changed between runs.
* fixed index: use errors instead of panics, so a corrupted or unexpected
index state fails the single backup task instead of crashing the service.
* docs: update ZFS swap documentation to reflect Debian Trixie no longer
reading /etc/sysctl.conf.
* docs: maintenance: add list of supported maintenance modes.
-- Proxmox Support Team <support@proxmox.com> Mon, 09 Mar 2026 16:11:55 +0100
rust-proxmox-backup (4.1.4-1) trixie; urgency=medium
* update proxmox-rest-server to 1.0.5 addressing a minor regression that
resulted in some requests with auth issues being answered with HTTP code
400 "bad request" instead of the correct HTTP code 401 "unauthorized".
The new rest-server also defaults to only checking the forwarded header in
the non-public RPC environment.
* proxy daemon: support configuration of real peer-ip header and allowed
source through the PROXY_REAL_IP_HEADER and PROXY_REAL_IP_ALLOW_FROM
environment variables. These variables can currently be set through
overriding the proxmox-backup-proxy.service systemd unit.
For example, to check the "X-Real-IP" header if the client is connecting
from a local (reverse) proxy, one would add the following override:
```
[Service]
Environment=PROXY_REAL_IP_HEADER=X-Real-IP
Environment=PROXY_REAL_IP_ALLOW_FROM=127.0.0.1,::1
```
-- Proxmox Support Team <support@proxmox.com> Tue, 17 Feb 2026 18:56:29 +0100
rust-proxmox-backup (4.1.3-1) trixie; urgency=medium
* fix #7311: fix recent regression in ACME certificate renewal through the
proxmox-daily-update service.
* docs: add example for using the proxmox-backup-manager CLI tool to run a
sync-job manually.
* fix #7303: tape: allow padding on either side of the termination byte when
parsing SCSI strings.
When dealing with ASCII fields in answers from tape drives and changers,
some odd hardware pad the string with spaces after the final NUL byte
denoting the end of the string, not before that final NUL byte as the spec
suggests.
* fix #7305: cli client: restore: filter out unfinished (running) snapshots
when automatically selecting the most recent snapshot when the user only
passed a backup group as restore source.
-- Proxmox Support Team <support@proxmox.com> Mon, 16 Feb 2026 11:03:06 +0100
rust-proxmox-backup (4.1.2-1) trixie; urgency=medium
* fix #7149: ui: user create: do not leak changing the realm for a new user
into the pre-selected realm of the currently logged in user, as that might
cause some UX friction and confusion on their next login.
* fix recent regression that failed s3 refresh.
* fix #7219: client: mount: align encryption key loading behavior to
fallback to the default key location, just like all other commands.
* partially fix #6049: datastore: implement various optimizations for
handling large datastore configs, including a cache for the datastore
config to avoid re-parsing the whole config if not necessary.
* chunk store: various code clean-ups for the chunk store iterator, chunk
filename checks and "bad chunk" handling.
* add basic multiarch support to resulting package and executable locations.
* switch ACME implementation over to factored out one.
* fix #6939: acme: support servers returning 204 for nonce requests.
-- Proxmox Support Team <support@proxmox.com> Thu, 29 Jan 2026 13:59:20 +0100
rust-proxmox-backup (4.1.1-1) trixie; urgency=medium
* datastore: s3: use owner from local store cache instead of refetching to
reduce the time and amount of request needed for listing backup contents.
* garbage collection: add task-cancellation points during phase 2 for s3
stores after each processed s3 object key.
* tape: cope with drives that aligned buffer sizes when setting up
encryption.
* api: notification: correctly enumerate known push-sync-jobs.
* api: config: fix push sync jobs cleanup on datastore destroy.
* update proxmox-apt and related api-types for more robust package name
handling.
-- Proxmox Support Team <support@proxmox.com> Mon, 22 Dec 2025 16:58:06 +0100
rust-proxmox-backup (4.1.0-1) trixie; urgency=medium
* ui: traffic-control: increase dialog height to fit "Add Timeframe" button.
* ui: datastore tuning options: reorder the fields for default verification
readers and workers for consistency with the verification job edit window.
* S3 garbage-collection: fix cleaning up the markers files for referenced
but locally unavailable chunks.
-- Proxmox Support Team <support@proxmox.com> Tue, 25 Nov 2025 15:39:39 +0100
rust-proxmox-backup (4.0.22-1) trixie; urgency=medium
* s3 garbage-collection: fix local marker cleanup for unreferenced chunks
that are only available on the s3 storage itself.
* provide and enable dedicated mount unit for /run/proxmox-backup, avoiding
the (inode) limits of /run.
* datastore config: add default-verification-workers and
default-verification-readers to the tuning options.
* ui, docs: integrate new verification related thread-count options.
* ui: traffic-control: rename add-timeframe button to "Add Timeframe" to
avoid having two buttons with an "Add" label visible at the same time.
-- Proxmox Support Team <support@proxmox.com> Mon, 24 Nov 2025 15:28:10 +0100
rust-proxmox-backup (4.0.21-1) trixie; urgency=medium
* task tracking: fix adding new entry if other PID is tracked.
* GC: S3: reduce number of open FDs for to-be-deleted objects to avoid
running into soft FD count limits, as we cannot just increase that due to
some libraries still potentially using the ancient select syscall.
* system report: better hardware and system utilization overview.
* pulling sync: ensure that all chunks of a synced index files can be
downloaded from the remote.
* pulling sync: mark chunks referenced in last backup snapshot as reusable.
* pulling sync: keep one "encountered chunks" map for a full backup group to
avoid frequently reinstating that map.
* docs: storage: fix "persistent" typo
* traffic-control: add user-specific rule matching and precedence. Introduce
user-aware rule lookup by accepting an optional user and preferring rules
that specify 'users' over IP-only matches.
* ui: traffic-control: add users field in edit form and list.
* s3 datastore: add support for configuring bandwidth rate-limiting for
clients, reusing the token bucket filter that the API server uses for
rate limiting.
* ui: expose rate and burst limits for s3 endpoints.
-- Proxmox Support Team <support@proxmox.com> Fri, 21 Nov 2025 19:34:49 +0100
rust-proxmox-backup (4.0.20-1) trixie; urgency=medium
* verify: distinguish s3 object fetching and chunk loading error, as loading
chunks from the object store might be cause by transient issues, and must
therefore handled so they do not incorrectly mark chunks as corrupt.
* api: admin: rework waiting on active operations and maintenance locking.
* api: datastore: wait for active operations to clear before s3 refresh.
* notifications/pbsXtoX: adapt to proxmox-apt making old_version optional.
* api: access: add vncticket verification endpoint.
* api: node shell: allow access for tokens.
* ui: replace Proxmox logo with slightly bigger SVG version to make it
crisper on HiDPI displays.
* datastore: GC: drop overly verbose info message during s3 chunk sweep.
* datastore: various concurrency fixes for GC and handling corrupt chunks.
* verify: never hold mutex lock in async scope on corrupt chunk rename to
avoid rare deadlock potential.
* cleanup chunk markers from cache in phase 3 on s3 backends.
* verification: add option to set number of {read,verify}-threads for jobs,
allowing one to potentially increase verification throughput as long as
the system does not gets IO starved.
* api: datastore: auto-unmount after mount-triggered sync for a removable
datastore. This can be configured through a new 'unmount-on-done' option
for sync jobs.
-- Proxmox Support Team <support@proxmox.com> Fri, 14 Nov 2025 22:56:36 +0100
rust-proxmox-backup (4.0.19-1) trixie; urgency=medium
* sync: pull: instantiate backend only once per sync job to avoid
establishing a new connection for each chunk that gets inserted.
* api, datastore: ensure that the exclusive lock on the backup group is
acquired before updating the notes, in order to avoid possible race
conditions, e.g. with backup group destruction.
* backup log upload: avoid calling a blocking function in async context.
* verify: improve logs and behavior on errors.
* datastore: avoid potential loss of concurrent config changes that happened
during datastore deletion.
* fix #4995: pxar: Include symlinks in zip file creation.
* chunk store: fix theoretical race window between chunk stat and GC
cleanup, for a case where a chunk must be older than the cutoff time and
not be referenced by any index file and also uploaded at the exact same
time that the GC passed over it in phase 2.
* various code style fixes, partially automated through rust clippy,
upgrading from rust 1.85 to 1.90..
-- Proxmox Support Team <support@proxmox.com> Fri, 07 Nov 2025 16:32:39 +0100
rust-proxmox-backup (4.0.18-1) trixie; urgency=medium
* api: chunk upload: extend consistency checks for s3 backend
-- Proxmox Support Team <support@proxmox.com> Wed, 22 Oct 2025 13:23:12 +0200
rust-proxmox-backup (4.0.17-1) trixie; urgency=medium
* fix #6665: never rename chunks on s3 client fetch errors
* fix #6398: api: allow non-pam users to access shell
* api: admin: datastore: optimize `groups` api call
* sync job: pull: avoid blocking future during thread pool completion
* refactor GC helpers
* S3 chunk cache: rework fetching and caching logic
* fix #6906: s3-client: conditionally set Content-Length header
* GC: refactor chunk removal helper
-- Proxmox Support Team <support@proxmox.com> Wed, 15 Oct 2025 14:58:11 +0200
rust-proxmox-backup (4.0.16-1) trixie; urgency=medium
* sync: pull: fix regression in resync of corrupt snapshots
* fix #6750: api: avoid possible deadlock on datastores with s3 backend
* fix #6566: backup: api: conditionally drop group and snapshot locks
* GC: rework locking logic to avoid deadlock with s3 backend
* verify: silence incorrect backend error message for filesystem stores
-- Proxmox Support Team <support@proxmox.com> Thu, 02 Oct 2025 14:31:26 +0200
rust-proxmox-backup (4.0.15-1) trixie; urgency=medium
* pbs3to4: sync to changes made on bookworm PBS 3 branch.
* notification: don't deserialize notification config when there is nothing
to send.
* api: do not block current thread during s3 backup log upload.
* s3: pass now optional default request timeout for s3 api calls.
* fix #6714: docs: remove wrong `--drive` parameter from cli example.
* fix #6624: ui: improve task log output for protected entry.
* update proxmox-s3-client to 1.2.2 to benefit from the higher request
timeout of 30 minutes and the new retry logic for transient client errors
using an exponentially increasing back-off time and a maximum of 3 tries
for the retries.
* api: allow creation of removable datastore at device's root.
* docs: network-management: use correct syntax for code-blocks.
* docs: network-management: link to systemd.link documentation.
* fix #6592: ui: add custom sort function to comment column.
-- Proxmox Support Team <support@proxmox.com> Mon, 15 Sep 2025 21:53:05 +0200
rust-proxmox-backup (4.0.14-1) trixie; urgency=medium
* ui: webauthn view: make sure renderers are html encoded
-- Proxmox Support Team <support@proxmox.com> Wed, 13 Aug 2025 13:26:47 +0200
rust-proxmox-backup (4.0.13-1) trixie; urgency=medium
* bin: pbs3to4: adapt boot-loader checks to trixie
* datastore: cleanup newly created group dir if setting owner failed
-- Proxmox Support Team <support@proxmox.com> Mon, 11 Aug 2025 15:04:56 +0200
rust-proxmox-backup (4.0.12-1) trixie; urgency=medium
* update proxmox-tfa dependency to 0.6.3 to also keep old passkeys created
with PBS 3 or earlier working, where the backup eligible state was not yet
exposed.
* api: openid connect: allow users of OIDC realms to opt into the HttpOnly
cookies.
* ui: opt OpenID connect authentication flows into the new http only flow.
* proxy: avoid exiting connection acceptor loop in error case, like some
monitoring and health checks of reverse proxies or load balancer cause.
-- Proxmox Support Team <support@proxmox.com> Thu, 07 Aug 2025 18:38:49 +0200
rust-proxmox-backup (4.0.11-4) trixie; urgency=medium
* re-build with proxmox-tfa 6.0.2 to cope with both possible states of
backup-eligibility for WebAuthn credentials to fix a regression with
(hardware) security keys from the previous version.
-- Proxmox Support Team <support@proxmox.com> Wed, 06 Aug 2025 16:16:12 +0200
rust-proxmox-backup (4.0.11-3) trixie; urgency=medium
* re-build with proxmox-tfa 6.0.1 to enable backup-eligibility for WebAuthn
credentials to fix issues with some passkeys, like the ones from
Bitwarden.
-- Proxmox Support Team <support@proxmox.com> Wed, 06 Aug 2025 14:47:40 +0200
rust-proxmox-backup (4.0.11-2) trixie; urgency=medium
* ui: login: opt the tfa challenge handling into the new HttpOnly
authentication flow to avoid getting logged out soon after login again
depending on timing with other API calls.
-- Proxmox Support Team <support@proxmox.com> Wed, 06 Aug 2025 12:20:44 +0200
rust-proxmox-backup (4.0.11-1) trixie; urgency=medium
* drop beta label and bump for 4.0 release.
* re-build with newer proxmox-auth-api to ensure correct PAM return value
gets logged on error.
* docs: update release EOL table in FAQ.
* docs: add link to Proxmox Backup Server 3 to 4 upgrade guide.
-- Proxmox Support Team <support@proxmox.com> Tue, 05 Aug 2025 18:54:02 +0200
rust-proxmox-backup (4.0.10-1) trixie; urgency=medium
* notifications templates: adapt to whitespace handling changes with
new handlebars version to ensure lists are not placed on a single line.
* S3 Endpoints: allow one to set quirks to adapt to provider specific
behavior, like not understanding the If-None-Match HTTP header.
* metric collection: use ip link for determining the type of interfaces.
* d/control: recommend proxmox-network-interface-pinning package for the
Proxmox Backup Server package.
* docs: add documentation for proxmox-network-interface-pinning.
-- Proxmox Support Team <support@proxmox.com> Tue, 05 Aug 2025 17:21:18 +0200
rust-proxmox-backup (4.0.9-1) trixie; urgency=medium
* sync: conditionally pre-filter source list by verified or encrypted state.
* api: return s3 client errors during datastore create with context.
* acls: add s3-endpoint as valid 'system' subpath and expose on the web UI.
* s3 endpoint: make required permissions specific to acl sub-paths in
'/system/s3-endpoint/{id}' to allowing one to provide only access to a
specific S3 endpoint.
-- Proxmox Support Team <support@proxmox.com> Mon, 04 Aug 2025 22:47:30 +0200
rust-proxmox-backup (4.0.8-1) trixie; urgency=medium
* add pbs-network-config-commit systemd service to apply pending network
changes on boot.
* datastore: delete all objects on datastore destroy when the remove data
flag was set.
* ui: switch datastore destroy label text depending on the backend type.
* docs: s3: extend recommendations and limitations for local cache.
* datastore: destroy: delete s3 in-use marker unconditionally to make
re-adding the bucket as datastore easier.
* datastore: wrap bucket name, as in is now optional in the s3 client.
* api: admin s3: make store prefix for check command optional.
* api: config s3: add bucket list api endpoint.
* ui: replace bucket field by bucket selector
-- Proxmox Support Team <support@proxmox.com> Thu, 31 Jul 2025 14:52:02 +0200
rust-proxmox-backup (4.0.7-1) trixie; urgency=medium
* s3 api types: make region regex less strict to allow more providers, like
e.g., Hetzner, to work.
* tell client to remove authentication cookies via http header on
unauthorized request.
* allow log-in via valid parameters even if there is a pre-existing HttpOnly
cookie with a (now) invalid ticket, as that should not block valid new
logins.
* don't set `Expire` for HttpOnly cookies anymore as heuristic to try to
make browsers clear these login-ticket cookies on exit.
-- Proxmox Support Team <support@proxmox.com> Fri, 25 Jul 2025 17:47:33 +0200
rust-proxmox-backup (4.0.6-2) trixie; urgency=medium
* d/postinst: fix setting up pbs-test repo on fresh installation during the
@ -869,13 +11,13 @@ rust-proxmox-backup (4.0.6-1) trixie; urgency=medium
* d/postinst: drop migration steps from PBS 2.x times, there is no
single-step upgrade path from PBS 2 to PBS 4, one will always need to
upgrade to PBS 3 in between.
upgrade to PBS 3 inbetween.
* d/postinst: add pbs-test repo on fresh installation during the beta.
* api: datastore: fix cache store creation when reusing s3 backend.
* client: benchmark: fix no-cache flag backwards compatibility.
* client: benchmark: fix no-cache flag backwards comaptibility.
* api: admin s3: switch request method from GET to PUT for s3 check.
@ -1054,7 +196,7 @@ rust-proxmox-backup (3.4.0-1) bookworm; urgency=medium
* ui: set error mask: ensure that message is html-encoded to avoid visual
glitches.
* api server: increase maximal request body size from 64 kiB to 512 kiB,
* api server: increase maximal request body size fro 64 kiB to 512 kiB,
similar to a recent change for our perl based projects.
* notifications: include Content-Length header for broader compatibility in
@ -1289,7 +431,7 @@ rust-proxmox-backup (3.3.1-1) bookworm; urgency=medium
worker, causing a reference count issue where an old daemon could keep
running forever.
* ui: check that store is set before trying to select anything in the garbage
* ui: check that store is set before trying to select anythin in the garbage
collection (GC) job view.
-- Proxmox Support Team <support@proxmox.com> Tue, 03 Dec 2024 18:11:04 +0100
@ -1551,7 +693,7 @@ rust-proxmox-backup (3.2.8-1) bookworm; urgency=medium
* tfa: webauthn: serialize OriginUrl following RFC6454
* factor out apt and apt-repository handling into a new library crate for
reuse in other projects. There should be no functional change.
re-use in other projects. There should be no functional change.
* fix various typos all over the place found using the rust based `typos`
tool.
@ -1762,7 +904,7 @@ rust-proxmox-backup (3.2.0-1) bookworm; urgency=medium
* ui: sync view: rename column 'Max. Recursion' -> 'Max. Depth'
* api: assert that maintenance mode transitions are valid, e.g., do
not allow clearing the special "delete" maintenance mode
not allow clearing the special "delete" maitenance mode
* fix #3217: ui: add global prune and GC job view for an overview over
all datastores
@ -2226,7 +1368,7 @@ rust-proxmox-backup (2.4.1-1) bullseye; urgency=medium
* fix #4521: api/tasks: replace upid as filename for task log downloads
* docs: expand PBS to Proxmox Backup (Server)
* docs: exand PBS to Proxmox Backup (Server)
* ui: dark mode: add dark mode colors to the datastore usage charts
@ -2594,7 +1736,7 @@ rust-proxmox-backup (2.2.2-1) bullseye; urgency=medium
* api: status: include empty entry for stores with ns-only privs
* ui: datastore options: avoid breakage if rrd store or active-ops cannot
* ui: datastore options: avoid breakage if rrd store ore active-ops cannot
be queried
* ui: datastore content: only mask the inner treeview, not the top bar on
@ -2801,7 +1943,7 @@ rust-proxmox-backup (2.1.6-1) bullseye; urgency=medium
* fix #3856: tape: encryption key's password hint parameter is not optional
* reuse PROXMOX_DEBUG environment variable to control log level filter
* re-use PROXMOX_DEBUG environment variable to control log level filter
* ui: WebAuthn: fix stopping store upgrades on destroy and decrease interval
@ -3087,7 +2229,7 @@ rust-proxmox-backup (2.0.10-1) bullseye; urgency=medium
* server: add proxmox-backup-debug binary with chunk/file inspection, an API
shell with completion support
* restructured code base to reduce linkage and library ABI version
* restructured code base to reduce linkage and libraray ABI version
constraints for all non-server binaries (client, pxar, file-restore)
* zsh: fix passign parameters in auto-completion scripts
@ -3169,7 +2311,7 @@ rust-proxmox-backup (2.0.6-1) bullseye; urgency=medium
command that can write the secret to standard out.
* pull in new proxmox library version to improve the file system
compatibility on creation of atomic files, e.g., lock files.
comaptibility on creation of atomic files, e.g., lock files.
-- Proxmox Support Team <support@proxmox.com> Thu, 22 Jul 2021 10:22:19 +0200
@ -3227,7 +2369,7 @@ rust-proxmox-backup (2.0.3-1) bullseye; urgency=medium
showing, among other things, uptime, Kernel version, CPU info and
repository status.
* ui: administration/dashboard: auto-scale columns count and add
* ui: adminsitration/dashboard: auto-scale columns count and add
browser-local setting to override that to a fixed value of columns.
* fix #3212: api, ui: add support for notes on backup groups
@ -3571,7 +2713,7 @@ rust-proxmox-backup (1.1.0-1) unstable; urgency=medium
* tape: cleanup media catalog on tape reuse
* zfs: reuse underlying pool wide IO stats for datasets
* zfs: re-use underlying pool wide IO stats for datasets
* api daemon: only log error from accepting new connections to avoid opening
to many file descriptors
@ -3973,7 +3115,7 @@ rust-proxmox-backup (0.9.4-1) unstable; urgency=medium
* ui: add widget to view the effective permissions of a user or token
* ui: datastore summary: handle error when having zero snapshot of any type
* ui: datastore summary: handle error when havin zero snapshot of any type
* ui: move user, token and permissions into an access control tab panel

92
debian/control vendored
View File

@ -1,11 +1,11 @@
Source: rust-proxmox-backup
Section: admin
Priority: optional
Build-Depends: debhelper (>= 12~),
Build-Depends: bash-completion,
cargo:native (>= 0.65.0~),
debhelper (>= 12~),
debhelper-compat (= 13),
dh-cargo (>= 24),
bash-completion,
cargo:native (>= 0.65.0~),
fonts-dejavu-core <!nodoc>,
fonts-lato <!nodoc>,
fonts-open-sans <!nodoc>,
@ -57,34 +57,32 @@ Build-Depends: debhelper (>= 12~),
librust-once-cell-1+default-dev (>= 1.3.1-~~),
librust-openssl-0.10+default-dev (>= 0.10.40-~~),
librust-pathpatterns-1+default-dev,
librust-pbs-api-types-1+default-dev (>= 1.0.15-~~),
librust-pbs-api-types-1+default-dev (>= 1.0.2~~),
librust-percent-encoding-2+default-dev (>= 2.1-~~),
librust-pin-project-lite-0.2+default-dev,
librust-proxmox-acme-1+default-dev (>= 1.1-~~),
librust-proxmox-acme-api-1+default-dev (>= 1.0.2-~~),
librust-proxmox-acme-api-1+impl-dev (>= 1.0.2-~~),
librust-proxmox-acme-1+default-dev,
librust-proxmox-apt-0.99+cache-dev,
librust-proxmox-apt-0.99+default-dev (>= 0.99.7-~~),
librust-proxmox-apt-api-types-2+default-dev (>= 2.0.5-~~),
librust-proxmox-apt-0.99+default-dev,
librust-proxmox-apt-api-types-2+default-dev,
librust-proxmox-async-0.5+default-dev,
librust-proxmox-auth-api-1+api-dev (>= 1.0.5-~~),
librust-proxmox-auth-api-1+default-dev (>= 1.0.5-~~),
librust-proxmox-auth-api-1+pam-authenticator-dev (>= 1.0.5-~~),
librust-proxmox-auth-api-1+api-dev ,
librust-proxmox-auth-api-1+default-dev,
librust-proxmox-auth-api-1+pam-authenticator-dev,
librust-proxmox-base64-1+default-dev,
librust-proxmox-borrow-1+default-dev,
librust-proxmox-compression-1+default-dev (>= 1.0.1-~~),
librust-proxmox-compression-1+default-dev,
librust-proxmox-config-digest-1+default-dev,
librust-proxmox-daemon-1+default-dev,
librust-proxmox-docgen-1+default-dev,
librust-proxmox-fuse-3+default-dev,
librust-proxmox-http-1+body-dev (>= 1.0.2-~~),
librust-proxmox-http-1+client-dev (>= 1.0.2-~~),
librust-proxmox-http-1+client-trait-dev (>= 1.0.2-~~),
librust-proxmox-http-1+default-dev (>= 1.0.2-~~),
librust-proxmox-http-1+http-helpers-dev (>= 1.0.2-~~),
librust-proxmox-http-1+proxmox-async-dev (>= 1.0.2-~~),
librust-proxmox-http-1+rate-limited-stream-dev (>= 1.0.2-~~),
librust-proxmox-http-1+websocket-dev (>= 1.0.2-~~),
librust-proxmox-fuse-1+default-dev,
librust-proxmox-http-1+body-dev,
librust-proxmox-http-1+client-dev,
librust-proxmox-http-1+client-trait-dev,
librust-proxmox-http-1+default-dev,
librust-proxmox-http-1+http-helpers-dev,
librust-proxmox-http-1+proxmox-async-dev,
librust-proxmox-http-1+rate-limited-stream-dev,
librust-proxmox-http-1+rate-limiter-dev,
librust-proxmox-http-1+websocket-dev,
librust-proxmox-human-byte-1+default-dev,
librust-proxmox-io-1+default-dev (>= 1.0.1-~~),
librust-proxmox-io-1+tokio-dev (>= 1.0.1-~~),
@ -92,26 +90,19 @@ Build-Depends: debhelper (>= 12~),
librust-proxmox-ldap-1+default-dev,
librust-proxmox-log-1+default-dev,
librust-proxmox-metrics-1+default-dev,
librust-proxmox-network-api-1+default-dev,
librust-proxmox-network-api-1+impl-dev,
librust-proxmox-network-types-1+default-dev (>= 1.0.1-~~),
librust-proxmox-notify-1+default-dev,
librust-proxmox-notify-1+pbs-context-dev,
librust-proxmox-openid-1+default-dev,
librust-proxmox-product-config-1+default-dev,
librust-proxmox-rate-limiter-1+default-dev (>= 1.0.0-~~),
librust-proxmox-rest-server-1+default-dev (>= 1.0.5-~~),
librust-proxmox-rest-server-1+rate-limited-stream-dev (>= 1.0.5-~~),
librust-proxmox-rest-server-1+templates-dev (>= 1.0.5-~~),
librust-proxmox-router-3+cli-dev (>= 3.2.2-~~),
librust-proxmox-router-3+server-dev (>= 3.2.2-~~),
librust-proxmox-rest-server-1+default-dev (>= 1.0.1),
librust-proxmox-rest-server-1+rate-limited-stream-dev,
librust-proxmox-rest-server-1+templates-dev,
librust-proxmox-router-3+cli-dev (>= 3.2.2-~),
librust-proxmox-router-3+server-dev,
librust-proxmox-rrd-1+default-dev,
librust-proxmox-rrd-api-types-1+default-dev (>= 1.0.2-~~),
librust-proxmox-parallel-handler-1+default-dev,
librust-proxmox-s3-client-1+default-dev (>= 1.4.0-~~),
librust-proxmox-s3-client-1+impl-dev (>= 1.4.0-~~),
librust-proxmox-schema-5+api-macro-dev,
librust-proxmox-schema-5+default-dev,
librust-proxmox-s3-client-1-dev (>= 1.0.5),
librust-proxmox-schema-4+api-macro-dev,
librust-proxmox-schema-4+default-dev,
librust-proxmox-section-config-3+default-dev,
librust-proxmox-serde-1+default-dev,
librust-proxmox-serde-1+serde-json-dev,
@ -125,16 +116,15 @@ Build-Depends: debhelper (>= 12~),
librust-proxmox-sys-1+default-dev,
librust-proxmox-sys-1+logrotate-dev,
librust-proxmox-sys-1+timer-dev,
librust-proxmox-systemd-1+default-dev (>= 1.0.1-~~),
librust-proxmox-tfa-6+api-dev (>= 6.0.3-~~),
librust-proxmox-tfa-6+api-types-dev (>= 6.0.3-~~),
librust-proxmox-tfa-6+default-dev (>= 6.0.3-~~),
librust-proxmox-systemd-1+default-dev,
librust-proxmox-tfa-6+api-dev,
librust-proxmox-tfa-6+api-types-dev,
librust-proxmox-tfa-6+default-dev,
librust-proxmox-time-2+default-dev,
librust-proxmox-upgrade-checks-1+default-dev,
librust-proxmox-uuid-1+default-dev,
librust-proxmox-uuid-1+serde-dev,
librust-proxmox-worker-task-1+default-dev,
librust-pxar-1+default-dev (>= 1.0.1-~~),
librust-pxar-1+default-dev,
librust-regex-1+default-dev (>= 1.5.5-~~),
librust-rustyline-14+default-dev,
librust-serde-1+default-dev,
@ -143,7 +133,6 @@ Build-Depends: debhelper (>= 12~),
librust-syslog-6+default-dev,
librust-tar-0.4+default-dev,
librust-termcolor-1+default-dev (>= 1.1.2-~~),
librust-tempfile-3+default-dev (>= 3.15.0),
librust-thiserror-2+default-dev,
librust-tokio-1+default-dev (>= 1.6-~~),
librust-tokio-1+fs-dev (>= 1.6-~~),
@ -172,16 +161,14 @@ Build-Depends: debhelper (>= 12~),
librust-zstd-0.13+default-dev,
librust-zstd-safe-7+default-dev,
libsgutils2-dev,
libstd-rust-dev,
libsystemd-dev (>= 246-~~),
patchelf,
proxmox-biome,
proxmox-widget-toolkit-dev <!nodoc>,
python3-docutils,
python3-pygments,
python3-sphinx <!nodoc>,
rsync,
rustc:native (>= 1.81),
rustc:native,
texlive-fonts-extra <!nodoc>,
texlive-fonts-recommended <!nodoc>,
texlive-xetex <!nodoc>,
@ -196,10 +183,8 @@ Rules-Requires-Root: binary-targets
Package: proxmox-backup-server
Architecture: any
Depends: dmidecode,
fonts-font-awesome,
Depends: fonts-font-awesome,
gdisk,
iproute2,
libjs-extjs (>= 7~),
libjs-qrcodejs (>= 1.20201119),
libproxmox-acme-plugins,
@ -208,12 +193,10 @@ Depends: dmidecode,
lvm2,
openssh-server,
pbs-i18n,
pciutils,
postfix | mail-transport-agent,
proxmox-backup-docs,
proxmox-mini-journalreader,
proxmox-termproxy (>= 2.0.3),
proxmox-widget-toolkit (>= 5.1.1),
proxmox-widget-toolkit (>= 4.3.3),
pve-xtermjs (>= 4.7.0-1),
sg3-utils,
smartmontools,
@ -221,7 +204,6 @@ Depends: dmidecode,
${shlibs:Depends},
Recommends: ifupdown2,
proxmox-mail-forward,
proxmox-network-interface-pinning,
proxmox-offline-mirror-helper,
zfsutils-linux,
Description: Proxmox Backup Server daemon with tools and GUI

31
debian/copyright vendored
View File

@ -1,19 +1,16 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Copyright (C) 2019 - 2025 Proxmox Server Solutions GmbH
Files: *
Copyright: 2019-2026 Proxmox Server Solutions GmbH <support@proxmox.com>
License: AGPL-3+
This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
License: AGPL-3+
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@ -1,8 +1,8 @@
proxmox-backup-server: elevated-privileges 4755 root/root [usr/lib/*/proxmox-backup/sg-tape-cmd]
proxmox-backup-server: elevated-privileges 4755 root/root [usr/lib/x86_64-linux-gnu/proxmox-backup/sg-tape-cmd]
proxmox-backup-server: mail-transport-agent-dependency-does-not-specify-default-mta
proxmox-backup-server: package-installs-apt-sources [etc/apt/sources.list.d/pbs-enterprise.sources]
proxmox-backup-server: package-installs-apt-sources [etc/apt/sources.list.d/pbs-sources.list]
proxmox-backup-server: systemd-service-file-refers-to-unusual-wantedby-target getty.target [usr/lib/systemd/system/proxmox-backup-banner.service]
proxmox-backup-server: uses-dpkg-database-directly [usr/lib/*/proxmox-backup/proxmox-backup-api]
proxmox-backup-server: uses-dpkg-database-directly [usr/lib/*/proxmox-backup/proxmox-backup-proxy]
proxmox-backup-server: uses-dpkg-database-directly [usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-backup-api]
proxmox-backup-server: uses-dpkg-database-directly [usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-backup-proxy]
proxmox-backup-server: uses-dpkg-database-directly [usr/sbin/pbs3to4]
proxmox-backup-server: uses-dpkg-database-directly [usr/sbin/proxmox-backup-debug]

9
debian/postinst vendored
View File

@ -11,10 +11,11 @@ case "$1" in
# FIXME: remove after beta is over and add hunk to actively remove the repo
BETA_SOURCES="/etc/apt/sources.list.d/pbs-test-for-beta.sources"
if test -f "$BETA_SOURCES" && dpkg --compare-versions "$2" 'lt' '4.0.11-1' && dpkg --compare-versions "$2" 'gt' '4.0~~'; then
printf "\nNOTE: Remove the pbs-test repository, which was added during the beta phase.\nYou can (re-)add repositories on the web UI (Administration -> Repositories)\n\n"
rm -v "$BETA_SOURCES" || true
fi
if test -e /proxmox_install_mode && ! test -f "$BETA_SOURCES"; then
echo "Adding pbs-test repo to '$BETA_SOURCES' to enable updates during Proxmox Backup Server 4.0 BETA"
printf 'Types: deb\nURIs: http://download.proxmox.com/debian/pbs\nSuites: trixie\nComponents: pbs-test\nSigned-By: /usr/share/keyrings/proxmox-archive-keyring.gpg\n' \
| tee "$BETA_SOURCES"
fi
# modeled after dh_systemd_start output
systemctl --system daemon-reload >/dev/null || true

View File

@ -1,4 +1,4 @@
usr/bin/proxmox-file-restore
usr/lib/${DEB_HOST_MULTIARCH}/proxmox-backup/file-restore/proxmox-restore-daemon
usr/lib/riscv64-linux-gnu/proxmox-backup/file-restore/proxmox-restore-daemon
usr/share/man/man1/proxmox-file-restore.1
usr/share/zsh/vendor-completions/_proxmox-file-restore

View File

@ -3,20 +3,8 @@
set -e
update_initramfs() {
ARCH="${DPKG_MAINTSCRIPT_ARCH:-$(dpkg --print-architecture)}"
case "$ARCH" in
amd64) HOST_MULTIARCH="x86_64-linux-gnu" ;;
arm64) HOST_MULTIARCH="aarch64-linux-gnu" ;;
riscv64) HOST_MULTIARCH="riscv64-linux-gnu" ;;
*)
echo "Error: Unsupported architecture '$ARCH', amd64, arm64, and riscv64 are supported." >&2
exit 1
;;
esac
# regenerate initramfs for single file restore VM
INST_PATH="/usr/lib/${HOST_MULTIARCH}/proxmox-backup/file-restore"
INST_PATH="/usr/lib/x86_64-linux-gnu/proxmox-backup/file-restore"
CACHE_PATH="/var/cache/proxmox-backup/file-restore-initramfs.img"
CACHE_PATH_DBG="/var/cache/proxmox-backup/file-restore-initramfs-debug.img"

View File

@ -1,20 +1,18 @@
etc/pbs-enterprise.sources /etc/apt/sources.list.d/
etc/pbs-network-config-commit.service /usr/lib/systemd/system/
etc/pbs-sources.list /etc/apt/sources.list.d/
etc/proxmox-backup-banner.service /usr/lib/systemd/system/
etc/proxmox-backup-daily-update.service /usr/lib/systemd/system/
etc/proxmox-backup-daily-update.timer /usr/lib/systemd/system/
etc/proxmox-backup-proxy.service /usr/lib/systemd/system/
etc/proxmox-backup.service /usr/lib/systemd/system/
etc/removable-device-attach@.service /usr/lib/systemd/system/
etc/run-proxmox\x2dbackup.mount /usr/lib/systemd/system/
usr/bin/pmt
usr/bin/pmtx
usr/bin/proxmox-tape
usr/lib/${DEB_HOST_MULTIARCH}/proxmox-backup/proxmox-backup-api
usr/lib/${DEB_HOST_MULTIARCH}/proxmox-backup/proxmox-backup-banner
usr/lib/${DEB_HOST_MULTIARCH}/proxmox-backup/proxmox-backup-proxy
usr/lib/${DEB_HOST_MULTIARCH}/proxmox-backup/proxmox-daily-update
usr/lib/${DEB_HOST_MULTIARCH}/proxmox-backup/sg-tape-cmd
usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-backup-api
usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-backup-banner
usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-backup-proxy
usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-daily-update
usr/lib/x86_64-linux-gnu/proxmox-backup/sg-tape-cmd
usr/sbin/pbs3to4
usr/sbin/proxmox-backup-debug
usr/sbin/proxmox-backup-manager
@ -67,8 +65,6 @@ usr/share/proxmox-backup/templates/default/tape-load-body.txt.hbs
usr/share/proxmox-backup/templates/default/tape-load-subject.txt.hbs
usr/share/proxmox-backup/templates/default/test-body.txt.hbs
usr/share/proxmox-backup/templates/default/test-subject.txt.hbs
usr/share/proxmox-backup/templates/default/thresholds-exceeded-body.txt.hbs
usr/share/proxmox-backup/templates/default/thresholds-exceeded-subject.txt.hbs
usr/share/proxmox-backup/templates/default/verify-err-body.txt.hbs
usr/share/proxmox-backup/templates/default/verify-err-subject.txt.hbs
usr/share/proxmox-backup/templates/default/verify-ok-body.txt.hbs

47
debian/rules vendored
View File

@ -22,6 +22,50 @@ export CARGO=/usr/share/cargo/bin/cargo
export CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
export DEB_HOST_RUST_TYPE DEB_HOST_GNU_TYPE
export CRATE_CC_NO_DEFAULTS=1
# Set cross-compilation environment variables for OpenSSL and C libraries
# ifeq ($(DEB_HOST_ARCH),riscv64)
# export CC_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-gcc
# export CXX_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-g++
# export AR_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-ar
# export CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc
# export CRATE_CC_NO_DEFAULTS=1
# export CFLAGS_riscv64gc_unknown_linux_gnu=-fPIC
# export CXXFLAGS_riscv64gc_unknown_linux_gnu=-fPIC
# export PKG_CONFIG_riscv64gc_unknown_linux_gnu=riscv64-linux-gnu-pkg-config
# export PKG_CONFIG_PATH_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig
# export PKG_CONFIG_LIBDIR_riscv64gc_unknown_linux_gnu=/usr/lib/riscv64-linux-gnu/pkgconfig
# export RISCV64GC_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR=/usr/lib/riscv64-linux-gnu
# export RISCV64GC_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR=/usr/include
# endif
# ifeq ($(DEB_HOST_ARCH),arm64)
# export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
# export CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
# export AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar
# export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
# export CFLAGS_aarch64_unknown_linux_gnu=-fPIC
# export CXXFLAGS_aarch64_unknown_linux_gnu=-fPIC
# export PKG_CONFIG_aarch64_unknown_linux_gnu=aarch64-linux-gnu-pkg-config
# export PKG_CONFIG_PATH_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig
# export PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig
# export AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR=/usr/lib/aarch64-linux-gnu
# export AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR=/usr/include
# endif
# ifeq ($(DEB_HOST_ARCH),loong64)
# export CC_loongarch64_unknown_linux_gnu=loongarch64-linux-gnu-gcc
# export CXX_loongarch64_unknown_linux_gnu=loongarch64-linux-gnu-g++
# export AR_loongarch64_unknown_linux_gnu=loongarch64-linux-gnu-ar
# export CARGO_TARGET_LOONGARCH64_UNKNOWN_LINUX_GNU_LINKER=loongarch64-linux-gnu-gcc
# export CFLAGS_loongarch64_unknown_linux_gnu=-fPIC
# export CXXFLAGS_loongarch64_unknown_linux_gnu=-fPIC
# export PKG_CONFIG_loongarch64_unknown_linux_gnu=loongarch64-linux-gnu-pkg-config
# export PKG_CONFIG_PATH_loongarch64_unknown_linux_gnu=/usr/lib/loongarch64-linux-gnu/pkgconfig
# export PKG_CONFIG_LIBDIR_loongarch64_unknown_linux_gnu=/usr/lib/loongarch64-linux-gnu/pkgconfig
# export LOONGARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR=/usr/lib/loongarch64-linux-gnu
# export LOONGARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR=/usr/include
# endif
export CARGO_HOME = $(CURDIR)/debian/cargo_home
export DEB_CARGO_CRATE=proxmox-backup_$(DEB_VERSION_UPSTREAM)
@ -60,9 +104,6 @@ override_dh_auto_install:
dh_auto_install -- \
PROXY_USER=backup \
LIBDIR=/usr/lib/$(DEB_HOST_MULTIARCH)
mkdir -p debian/proxmox-backup-client-static/usr/bin
mv debian/tmp/usr/bin/proxmox-backup-client-static debian/proxmox-backup-client-static/usr/bin/proxmox-backup-client
mv debian/tmp/usr/bin/pxar-static debian/proxmox-backup-client-static/usr/bin/pxar
override_dh_installsystemd:
dh_installsystemd -pproxmox-backup-server proxmox-backup-daily-update.timer

View File

@ -6,8 +6,6 @@
<title>Proxmox Backup Server API Documentation</title>
<link rel="stylesheet" type="text/css" href="extjs/theme-crisp/resources/theme-crisp-all.css">
<link rel="stylesheet" type="text/css" href="/fontawesome/css/font-awesome.css" />
<link rel="stylesheet" type="text/css" href="/widgettoolkit/css/ext6-pmx.css" />
<link rel="stylesheet" type="text/css" media="(prefers-color-scheme: dark)" href="/widgettoolkit/themes/theme-proxmox-dark.css" />
<script type="text/javascript" src="extjs/ext-all.js"></script>
<script type="text/javascript" src="apidoc.js"></script>

View File

@ -28,50 +28,6 @@ brackets (for example, `[fe80::01]`).
You can pass the repository with the ``--repository`` command-line option, or
by setting the ``PBS_REPOSITORY`` environment variable.
Alternatively, you can specify the repository components as separate
command-line options:
``--server <host>``
Backup server address (hostname or IP address). Defaults to ``localhost``.
Requires ``--datastore`` to be set as well.
``--port <number>``
Backup server port. Defaults to ``8007``.
``--datastore <name>``
Name of the target datastore. Required when using component options instead
of ``--repository``.
``--auth-id <user@realm[!token]>``
Authentication identity, either a user (``user@realm``) or an API token
(``user@realm!tokenname``). Defaults to ``root@pam``.
These options are mutually exclusive with ``--repository``. Both forms resolve
to the same internal representation, so cached login tickets and other session
state are shared between them. For example, logging in with ``--repository``
and then running a backup with ``--server``/``--datastore`` (or vice versa)
reuses the same ticket, as long as the server address and user match.
When component options are used on the command line, they are merged with the
corresponding ``PBS_*`` environment variables on a per-field basis: CLI options
take precedence, while unspecified fields fall back to their environment
variable. For example, with ``PBS_SERVER`` and ``PBS_DATASTORE`` set in the
environment, passing ``--auth-id 'other@pam'`` on the command line overrides
just the identity while inheriting the server and datastore from the
environment.
The component options make it easy to change individual parts of the
connection, for example switching to a different datastore or server without
having to rewrite the entire repository string:
.. code-block:: console
# proxmox-backup-client backup root.pxar:/ \
--auth-id 'user@pbs!backup' --server pbs.example.com --datastore store1
.. Note:: Remember to quote API token identifiers on the shell, since the
exclamation mark (``!``) is a special character in most shells.
The web interface provides copyable repository text in the datastore summary
with the `Show Connection Information` button.
@ -79,7 +35,7 @@ Below are some examples of valid repositories and their corresponding real
values:
================================ ================== ================== ===========
Example Repository Auth-ID Host:Port Datastore
Example User Host:Port Datastore
================================ ================== ================== ===========
mydatastore ``root@pam`` localhost:8007 mydatastore
myhostname:mydatastore ``root@pam`` myhostname:8007 mydatastore
@ -114,26 +70,6 @@ Environment Variables
``PBS_REPOSITORY``
The default backup repository.
``PBS_SERVER``
Backup server address. Provides a default that can be overridden by
``--server``. Requires ``PBS_DATASTORE`` to be set as well (unless
``--datastore`` is given on the command line). Not used when ``--repository``
or ``PBS_REPOSITORY`` is set.
``PBS_PORT``
Backup server port. Defaults to ``8007`` if unset.
``PBS_DATASTORE``
Datastore name. Provides a default that can be overridden by ``--datastore``.
Not used when ``--repository`` or ``PBS_REPOSITORY`` is set.
``PBS_AUTH_ID``
Authentication identity (``user@realm`` or ``user@realm!tokenname``).
Defaults to ``root@pam`` if unset.
``PBS_NAMESPACE``
Backup namespace. Used as a fallback when ``--ns`` is not given.
``PBS_PASSWORD``
When set, this value is used as the password for the backup server.
You can also set this to an API token secret.

View File

@ -4,7 +4,7 @@ Certificate Management
----------------------
Access to the API and thus the web-based administration interface is always
encrypted through ``https``. Each `Proxmox Backup`_ Server host creates by default its
encrypted through ``https``. Each `Proxmox Backup`_ host creates by default its
own (self-signed) certificate. This certificate is used for encrypted
communication with the hosts ``proxmox-backup-proxy`` service, for any API
call between a user or backup-client and the web-interface.
@ -18,7 +18,7 @@ configuration, or by using certificates, signed by a trusted certificate authori
Certificates for the API and SMTP
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Proxmox Backup Server stores its certificate and key in:
Proxmox Backup stores its certificate and key in:
- ``/etc/proxmox-backup/proxy.pem``
@ -59,9 +59,9 @@ Note that any certificate key files must not be password protected.
Trusted certificates via Lets Encrypt (ACME)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Proxmox Backup Server includes an implementation of the **A**\ utomatic
Proxmox Backup includes an implementation of the **A**\ utomatic
**C**\ ertificate **M**\ anagement **E**\ nvironment (**ACME**)
protocol, allowing Proxmox Backup Server admins to use an ACME provider
protocol, allowing Proxmox Backup admins to use an ACME provider
like Lets Encrypt for easy setup of TLS certificates, which are
accepted and trusted by modern operating systems and web browsers out of
the box.
@ -127,7 +127,7 @@ DNS record in the domains zone.
:align: right
:alt: Create ACME Account
Proxmox Backup Server supports both of those challenge types out of the
Proxmox Backup supports both of those challenge types out of the
box, you can configure plugins either over the web interface under
``Certificates -> ACME Challenges``, or using the
``proxmox-backup-manager acme plugin add`` command.
@ -178,7 +178,7 @@ with Lets Encrypts ACME.
- There **must** be no other listener on port 80.
- The requested (sub)domain needs to resolve to a public IP of the
Proxmox Backup Server host.
Proxmox Backup host.
.. _sysadmin_certs_acme_dns_challenge:
@ -195,7 +195,7 @@ allows provisioning of ``TXT`` records via an API.
Configuring ACME DNS APIs for validation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Proxmox Backup Server reuses the DNS plugins developed for the
Proxmox Backup re-uses the DNS plugins developed for the
``acme.sh`` [1]_ project. Please refer to its documentation for details
on configuration of specific APIs.
@ -229,7 +229,7 @@ domain/DNS server, in case your primary/real DNS does not support
provisioning via an API. Manually set up a permanent ``CNAME`` record
for ``_acme-challenge.domain1.example`` pointing to
``_acme-challenge.domain2.example``, and set the ``alias`` property in
the Proxmox Backup Server node configuration file ``/etc/proxmox-backup/node.cfg``
the Proxmox Backup node configuration file ``/etc/proxmox-backup/node.cfg``
to ``domain2.example`` to allow the DNS server of ``domain2.example`` to
validate all challenges for ``domain1.example``.
@ -282,7 +282,7 @@ Manually Change Certificate over the Command Line
If you want to get rid of certificate verification warnings, you have to
generate a valid certificate for your server.
Log in to your Proxmox Backup Server via ssh or use the console:
Log in to your Proxmox Backup via ssh or use the console:
::
@ -295,7 +295,7 @@ Follow the instructions on the screen, for example:
Country Name (2 letter code) [AU]: AT
State or Province Name (full name) [Some-State]:Vienna
Locality Name (eg, city) []:Vienna
Organization Name (eg, company) [Internet Widgets Pty Ltd]: Proxmox GmbH
Organization Name (eg, company) [Internet Widgits Pty Ltd]: Proxmox GmbH
Organizational Unit Name (eg, section) []:Proxmox Backup
Common Name (eg, YOUR name) []: yourproxmox.yourdomain.com
Email Address []:support@yourdomain.com

View File

@ -1,5 +1,4 @@
Proxmox Backup Version , Debian Version , First Release , Debian EOL , Proxmox Backup EOL
Proxmox Backup 4 , Debian 13 (Trixie) , 2025-08 , TBA , TBA
Proxmox Backup 3 , Debian 12 (Bookworm) , 2023-06 , 2026-08 , 2026-08
Proxmox Backup 3 , Debian 12 (Bookworm) , 2023-06 , TBA , TBA
Proxmox Backup 2 , Debian 11 (Bullseye) , 2021-07 , 2024-07 , 2024-07
Proxmox Backup 1 , Debian 10 (Buster) , 2020-11 , 2022-08 , 2022-07

1 Proxmox Backup Version Debian Version First Release Debian EOL Proxmox Backup EOL
2 Proxmox Backup 4 Proxmox Backup 3 Debian 13 (Trixie) Debian 12 (Bookworm) 2025-08 2023-06 TBA TBA
Proxmox Backup 3 Debian 12 (Bookworm) 2023-06 2026-08 2026-08
3 Proxmox Backup 2 Debian 11 (Bullseye) 2021-07 2024-07 2024-07
4 Proxmox Backup 1 Debian 10 (Buster) 2020-11 2022-08 2022-07

View File

@ -37,7 +37,7 @@ How can I upgrade Proxmox Backup Server to the next point release?
Minor version upgrades, for example upgrading from Proxmox Backup Server in
version 3.1 to 3.2 or 3.3, can be done just like any normal update.
But, you should still check the `release notes
<https://pbs.proxmox.com/wiki/Roadmap>`_ for any relevant notable,
<https://pbs.proxmox.com/wiki/index.php/Roadmap>`_ for any relevant notable,
or breaking change.
For the update itself use either the Web UI *Node -> Updates* panel or
@ -66,11 +66,9 @@ or tape, ready.
Although the specific upgrade steps depend on your respective setup, we provide
general instructions and advice of how a upgrade should be performed:
* `Upgrade from Proxmox Backup Server 3 to 4 <https://pbs.proxmox.com/wiki/Upgrade_from_3_to_4>`_
* `Upgrade from Proxmox Backup Server 2 to 3 <https://pbs.proxmox.com/wiki/index.php/Upgrade_from_2_to_3>`_
* `Upgrade from Proxmox Backup Server 2 to 3 <https://pbs.proxmox.com/wiki/Upgrade_from_2_to_3>`_
* `Upgrade from Proxmox Backup Server 1 to 2 <https://pbs.proxmox.com/wiki/Upgrade_from_1.1_to_2.x>`_
* `Upgrade from Proxmox Backup Server 1 to 2 <https://pbs.proxmox.com/wiki/index.php/Upgrade_from_1.1_to_2.x>`_
Can I copy or synchronize my datastore to another location?
-----------------------------------------------------------

View File

@ -44,7 +44,7 @@ All headers are stored as little-endian.
- header of ``[u8; 16]`` consisting of type hash and size;
marks start
* - ``PAYLOAD``
- header of ``[u8; 16]`` consisting of type hash and size;
- header of ``[u8; 16]`` cosisting of type hash and size;
referenced by metadata archive
* - Payload
- raw regular file payload

View File

@ -93,15 +93,9 @@ WebAuthn, and HTTP proxy configuration. It also contains the following
subsections:
* **Access Control**: Add and manage users, API tokens, and the permissions
associated with these items.
* **Remotes**: Add, edit and remove remotes (see :term:`Remote`).
* **S3 Endpoints**: Add, edit and remove S3 endpoints to be used by datastores.
* **Traffic Control**: Manage rules for traffic limits on users, networks and
timeframes.
associated with these items
* **Remotes**: Add, edit and remove remotes (see :term:`Remote`)
* **Certificates**: Manage ACME accounts and create SSL certificates.
* **Encryptioin Keys**: Manage encryption keys for tape backups and sync jobs.
* **Notifications**: Configure notification targets and control when
notifications should be send by defining match rules.
* **Subscription**: Upload a subscription key, view subscription status and
access a text-based system report.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -195,7 +195,7 @@ With `systemd-boot`:
# proxmox-boot-tool init <new ESP>
.. NOTE:: `ESP` stands for EFI System Partition, which is setup as partition #2 on
bootable disks setup by the Proxmox Backup Server installer. For details, see
bootable disks setup by the Proxmox Backup installer. For details, see
:ref:`Setting up a new partition for use as synced ESP <systembooting-proxmox-boot-setup>`.
With `grub`:
@ -224,7 +224,7 @@ preferred editor. The required setting for email notification is
ZED_EMAIL_ADDR="root"
Please note that Proxmox Backup Server forwards mails to `root` to the email address
Please note that Proxmox Backup forwards mails to `root` to the email address
configured for the root user.
@ -283,9 +283,8 @@ A good value for servers is 10:
# sysctl -w vm.swappiness=10
To make the swappiness persistent, create a new file
`/etc/sysctl.d/99-swappiness.conf` with an editor of your choice and add the
following line:
To make the swappiness persistent, open `/etc/sysctl.conf` with
an editor of your choice and add the following line:
.. code-block:: console

View File

@ -296,17 +296,8 @@ data is intact. Verification is generally carried out through the creation of
verify jobs. These are scheduled tasks that run verification at a given interval
(see :ref:`calendar-event-scheduling`). With these, you can also set whether
already verified snapshots are ignored, as well as set a time period, after
which snapshots are checked again. The number of read and verify threads used
for a verification job can be specified via the `read-threads` and `verify-threads`
parameters. Possible values range from 1 to 32 threads, defaults being 1 reader
and 4 verify threads. The interface for creating verify jobs can be found under the
**Verify Jobs** tab of the datastore. Alternatively, you can verify all backups manually.
The interface for this can be found under the **Content** tab of the datastore or can
be done via the CLI:
.. code-block:: console
# proxmox-backup-manager verify <datastore> --read-threads 1 --verify-threads 4 --ignore-verified false
which snapshots are checked again. The interface for creating verify jobs can be
found under the **Verify Jobs** tab of the datastore.
.. Note:: It is recommended that you reverify all backups at least monthly, even
if a previous verification was successful. This is because physical drives
@ -349,9 +340,3 @@ Internally Proxmox Backup Server tracks whether each datastore access is a
write or read operation, so that it can gracefully enter the respective mode,
by allowing conflicting operations that started before enabling the maintenance
mode to finish.
The supported maintenance modes are:
- ``read-only``: Only read operations are allowed on the datastore.
- ``offline``: Neither read nor write operations are allowed on the datastore.

View File

@ -67,8 +67,8 @@ itself. Alternatively, you can manage them with the ``proxmox-backup-manager
sync-job`` command. The configuration information for sync jobs is stored at
``/etc/proxmox-backup/sync.cfg``. To create a new sync job, click the add button
in the GUI, or use the ``create`` subcommand. After creating a sync job, you can
start it manually from the GUI, use the ``run`` subcommand, or provide it with
a schedule (see :ref:`calendar-event-scheduling`) to run regularly.
either start it manually from the GUI or provide it with a schedule (see
:ref:`calendar-event-scheduling`) to run regularly.
Backup snapshots, groups and namespaces which are no longer available on the
**Remote** datastore can be removed from the local datastore as well by setting
the ``remove-vanished`` option for the sync job.
@ -87,17 +87,6 @@ trusted remote backup server.
╞════════════╪═══════╪════════╪══════════════╪═══════════╪═════════╡
│ pbs2-local │ local │ pbs2 │ local │ Wed 02:30 │ offsite │
└────────────┴───────┴────────┴──────────────┴───────────┴─────────┘
# proxmox-backup-manager sync-job run pbs2-local
Starting datastore sync job 'pbs2:local:local::pbs2-local'
sync datastore 'local' from 'pbs2/local'
----
Syncing datastore 'local', root namespace into datastore 'local', root namespace
found 0 groups to sync (out of 0 total)
Finished syncing root namespace, current progress: 0 groups, 0 snapshots
Summary: sync job found no new data to pull
sync job 'pbs2:local:local::pbs2-local' end
queued notification (id=a8ce89b5-330a-4ad7-9906-3a876e7ed174)
TASK OK
# proxmox-backup-manager sync-job remove pbs2-local
To set up sync jobs, the configuring user needs the following permissions:
@ -161,19 +150,6 @@ relevant removable datastore is mounted. If mounting a removable datastore would
multiple sync jobs, these jobs will be run sequentially in alphabetical order based on
their ID.
If the ``unmount-on-done`` flag is set, the datastore will be automatically unmounted
after the sync job finishes. This option is only available for sync jobs triggered by
mounting (``run-on-mount``), enabling fully automated external drive workflows.
Setting the ``worker-threads`` option to a value greater than ``1`` (up to ``32``,
default ``1``) synchronizes multiple backup groups in parallel. This can
significantly improve throughput on high-latency connections, where a single
HTTP/2 connection to the source is otherwise bottlenecked by head-of-line
blocking. Log output from concurrent groups is prefixed with the group and
buffered briefly to keep related lines together. Note that the memory and
connection count on both endpoints grow roughly linearly with the number of
workers, so higher values are not always better.
Namespace Support
^^^^^^^^^^^^^^^^^
@ -311,57 +287,3 @@ The following permissions are required for a sync job in push direction:
.. note:: Sync jobs in push direction require namespace support on the remote
Proxmox Backup Server instance (minimum version 2.2).
Server Side Encryption/Decryption During Sync
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Sync job in push direction allow to encrypt unencrypted snapshots when syncing
to a less trusted remote Proxmox Backup Server instance. For this, a server side
encryption key can be assigned to the sync job. This key will then be used to
encrypt the contents before pushing them to the remote, analogous to performing
a backup with an encryption key. Already encrypted snapshots are not re-encrypted
but rather pushed unmodified. Snapshots containing only partially encrypted
contents are skipped for security reasons.
Therefore, sync jobs using the ``encrypted-only`` flag will never use the
``active-encryption-key`` when pushing snapshots, since only already encrypted
snapshots are being synced.
On the other hand, sync jobs in pull direction allow to assign a number of
associated keys, which will be used to decrypt snapshot contents if the key
fingerprint of one of the listed keys matches the one used to encrypt the
backup snapshot. The active encryption key has no effect for sync jobs in pull
direction and should not be set.
In order to configure the sync job, as well as for sync job owner/local user
to access the keys during sync, ``System.Modify`` permissions are required on
the ``/system/encryption-keys/{key}`` path.
.. note:: Encryption key handling comes with a few risks, especially with key
rotation. Therefore, only active keys can be used to encrypt new snapshot
contents during push sync. If an active encryption key is changed, the key is
kept back as associated key on the sync job, in order to protect it from
accidental removal. Further, any encryption key can be archived, rendering it
no longer usable for encryption, only to decrypt pre-existing contents. Any
encryption key usable for sync jobs must therefore be marked as archived and
disassociated from any sync job still associated to it, before being able to
remove it.
The following command can be used to assign the active encryption key for a sync
job.
.. code-block:: console
# proxmox-backup-manager sync-job update pbs2-push --active-encryption-key key0
Setting the associated keys will drop any key not present in the given key list,
with exception of the previously assigned active encryption key, if it is updated
as well. The previously assigned encryption key (in the example above ``key0``)
will always be pushed to the list of associated keys on rotation. For example,
since ``key0`` is currently the active encryption key, below command would assign
``key1`` as the new active encryption key and result in ``key0,key2,key3`` as
associated keys for the sync job.
.. code-block:: console
# proxmox-backup-manager sync-job update pbs2-push --active-encryption-key key1 --associated-key key2 --associated-key key3

View File

@ -97,59 +97,3 @@ of **Configuration** or by using the ``dns`` subcommand of
.. include:: traffic-control.rst
Overriding network device names
-------------------------------
When upgrading kernels, adding PCIe devices or updating your BIOS, automatically
generated network interface names can change. To alleviate this issues, Proxmox
Backup Server provides a tool for automatically generating `systemd .link`_
files for overriding the name of network devices. It also automatically replaces
the occurrences of the old interface name in ``/etc/network/interfaces``.
The generated link files are stored in ``/usr/local/lib/systemd/network``. For
the interfaces file a new file will be generated in the same place with a
``.new`` suffix. This way you can inspect the changes made to the configuration
by using diff (or another diff viewer of your choice):
.. code-block:: console
diff -y /etc/network/interfaces /etc/network/interfaces.new
If you see any problematic changes or want to revert the changes made by the
pinning tool **before rebooting**, simply delete all ``.new`` files and the
respective link files from ``/usr/local/lib/systemd/network``.
The following command will generate a ``.link`` file for all physical network
interfaces that do not yet have a ``.link`` file and update selected Proxmox VE
configuration files (see above). The generated names will use the default prefix
``nic``, so the resulting interface names will be ``nic1``, ``nic2``, ...
.. code-block:: console
proxmox-network-interface-pinning generate
You can override the default prefix with the ``--prefix`` flag:
.. code-block:: console
proxmox-network-interface-pinning generate --prefix myprefix
It is also possible to pin only a specific interface:
.. code-block:: console
proxmox-network-interface-pinning generate --interface enp1s0
When pinning a specific interface, you can specify the exact name that the
interface should be pinned to:
.. code-block:: console
proxmox-network-interface-pinning generate --interface enp1s0 --target-name if42
In order to apply the changes made by ``proxmox-network-interface-pinning`` to
the network configuration, the host needs to be rebooted.
.. _systemd .link: https://www.freedesktop.org/software/systemd/man/latest/systemd.link.html

View File

@ -259,7 +259,7 @@ Notification Events
-------------------
The following table contains a list of all notification events in Proxmox
Backup Server, their type, severity and additional metadata fields. ``type`` as
Backup server, their type, severity and additional metadata fields. ``type`` as
well as any other metadata field may be used in ``match-field`` match rules.
================================ ==================== ========== ==============================================================
@ -371,50 +371,3 @@ The template files follow the naming convention of
``gc-err-body.txt.hbs`` contains the template for rendering notifications for
garbage collection errors, while ``package-updates-subject.txt.hbs`` is used to
render the subject line of notifications for available package updates.
.. _s3_notification_thresholds:
Notification Thresholds and Reset Schedule (S3 Datastores Only)
---------------------------------------------------------------
Datastores of type S3 keep track of the number of requests being send to the
corresponding S3 endpoint and the amount of data being send. Proxmox Backup
Server allows to configure threshold for these request and traffic counters to
send out notifications if one of the set threshold values is exceeded.
The notification threshold value can be set individually by request method or
traffic volume. Notifications will be send out only once per threshold when the
threshold has been exceeded. A counter reset is required to bring it below the
threshold again in order to get further notifications. Therefore, it is possible
to define a threshold reset schedule so request and traffic counters get
periodically reset.
Per datastore notification thresholds and their reset schedule are configurable
in the :ref:`Datastore Options <datastore_options>` for S3 backed datastores.
The following counters thresholds are available for configuration:
==================== ==========================================================
Counter Threshold Description and Usage
==================== ==========================================================
``s3-get`` Number of ``GET`` requests: Mainly used for download of
data and metadata among the following operations: restore,
verification, S3 refresh, garbage collection.
``s3-put`` Number of ``PUT`` requests: Mainly used for upload of data
and metadata among the following operations: backup, syncs,
metadata changes, content moves.
``s3-post`` Number of ``POST`` requests: Mainly used for modification
of data and metadata among the following operations: bulk
deletion during garbage collection, content moves.
``s3-head`` Number of ``HEAD`` requests: Mainly used to check for
access and existence among which the following operations:
checking bucket access, fetching of metadata.
``s3-delete`` Number of ``DELETE`` requests: Mainly used for deleting
single objects by the following operations: garbage
collection, content moves.
``s3-upload`` Amount of bytes uploaded to the S3 endpoint, independent of
request method.
``s3-download`` Amount of bytes downloaded from the S3 endpoint,
independent of request method.
==================== ==========================================================

View File

@ -12,7 +12,7 @@ Synopsis
Catalog Shell Commands
======================
Those command are available when you start an interactive restore shell:
Those command are available when you start an intercative restore shell:
::

View File

@ -233,14 +233,6 @@ datastore is not mounted when they are scheduled. Sync jobs start, but fail
with an error saying the datastore was not mounted. The reason is that syncs
not happening as scheduled should at least be noticeable.
Setups that rely on ``unmount-on-done`` (see :ref:`syncjobs`) have no natural
point to run garbage collection, since the device is detached right after the
sync finishes. Enabling the ``gc-on-unmount`` option on the datastore makes
the unmount trigger a garbage collection first and wait for it to complete
before actually unmounting. If a garbage collection run is already in
progress when the unmount fires (for example from an overlapping scheduled
GC job), that run is waited on instead.
.. _datastore_s3_backend:
Datastores with S3 Backend
@ -249,6 +241,8 @@ Datastores with S3 Backend
Proxmox Backup Server supports S3 compatible object stores as storage backend for datastores. For
this, an S3 endpoint needs to be set-up under "Configuration" > "Remotes" > "S3 Endpoints".
.. important:: The S3 datastore backend is currently a technology preview.
.. important:: Keep in mind that operating as S3 backed object store might cause additional costs.
Providers might charge you for storage space and API requests performed to the buckets, egress
and bandwidth fees might be charged as well. Therefore, monitoring of these values and eventual
@ -294,14 +288,11 @@ To list your s3 endpoint configuration, run:
# proxmox-backup-manager s3 endpoint list
A new datastore with S3 backend can be created using one of the configured S3 endpoints. Although
storing all contents on the S3 object store, the datastore requires nevertheless a local persistent
cache, used to increase performance and reduce the number of requests to the backend. For this, a
local filesystem path has to be provided during datastore creation, just like for regular datastore
setup. However, unlike for regular datastores the size of the local cache can be limited, 64 GiB to
128 GiB are recommended given that cached datastore contents include also data chunks. Best is to
use a dedicated disk, partition or ZFS dataset with quota as local cache. Note however, it is not
possible to use a pre-existing regular datastore for this. Further, the use of volatile memory only
for the cache is currently not possible.
storing all contents on the S3 object store, the datastore requires nevertheless a local cache store,
used to increase performance and reduce the number of requests to the backend. For this, a local
filesystem path has to be provided during datastore creation, just like for regular datastore setup.
A minimum size of a few GiB of storage is recommended, given that cache datastore contents include
also data chunks.
To setup a new datastore called ``my-s3-store`` placed in a bucket called ``pbs-s3-bucket``, run:
@ -533,67 +524,8 @@ For backup groups, the existing privilege rules still apply. You either need a
privileged enough permission or to be the owner of the backup group; nothing
changed here.
.. _storage_move_namespaces_groups:
.. todo:: continue
Moving Namespaces and Groups
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Backup groups can be moved between namespaces within the same datastore.
This is useful for reorganizing backup hierarchies without having to
re-run backups.
A single group can be moved with ``group move``. To relocate an entire
namespace subtree (including all child namespaces and their groups), use
``namespace move``.
.. code-block:: console
# proxmox-backup-client group move <type>/<id> --ns <source> --target-ns <target> --repository <repo>
# proxmox-backup-client namespace move <source> --target-ns <target> --repository <repo>
If the target namespace already exists, groups are moved into it. When a
group with the same type and ID already exists in the target and
``merge-groups`` is enabled, the snapshots are merged into the existing
group provided:
- both groups have the same owner
- the oldest source snapshot is newer than the newest target snapshot
Groups that cannot be merged or locked are skipped and reported in the
task log. They remain at the source and can be retried individually with
``group move``.
.. note::
With defaults, ``namespace move`` merges into existing target groups
(``merge-groups=true``) and removes source namespaces once they are empty
(``delete-source=true``). Pass ``--merge-groups false`` or
``--delete-source false`` to opt out.
Optional parameters for ``namespace move``:
``merge-groups``
Allow merging snapshots into groups that already exist in the target
namespace with the same type and ID. Defaults to true.
``max-depth``
Limits how many levels of child namespaces below the source are
included. When not set, the entire subtree is moved.
``delete-source``
Controls whether the source namespace directories are removed after
all groups have been moved out. Defaults to true. Set to false to
keep the (now empty) source namespace structure.
Required privileges:
- ``group move``: ``DATASTORE_PRUNE`` on the source namespace and
``DATASTORE_BACKUP`` on the target namespace, plus ownership of the
backup group; or ``DATASTORE_MODIFY`` on both.
- ``namespace move``: ``DATASTORE_MODIFY`` on the parent of both the
source and the target namespace.
.. _datastore_options:
Options
~~~~~~~
@ -607,9 +539,6 @@ There are a few per-datastore options:
* :ref:`Notification mode and legacy notification settings <notification_mode>`
* :ref:`Maintenance Mode <maintenance_mode>`
* :ref:`Maintenance Mode <maintenance_mode>`
* :ref:`Notification Thresholds (S3 datastores only) <s3_notification_thresholds>`
* :ref:`Threshold Reset Schedule (S3 datastores only) <s3_notification_thresholds>`
* Verification of incoming backups
.. _datastore_tuning_options:
@ -686,11 +615,6 @@ There are some tuning related options for the datastore that are more advanced:
cache slots, 1048576 (= 1024 * 1024) being the default, 8388608 (= 8192 *
1024) the maximum value.
* ``default-verification-workers`` and ``default-verification-readers``:
Define the default number of threads used for verification and reading of chunks,
respectively. By default, 4 threads are used for verification and 1 thread is used
for reading.
If you want to set multiple tuning options simultaneously, you can separate them
with a comma, like this:

View File

@ -3,13 +3,13 @@
Host System Administration
==========================
`Proxmox Backup`_ Server is based on the famous Debian_ Linux
`Proxmox Backup`_ is based on the famous Debian_ Linux
distribution. This means that you have access to the entire range of
Debian packages, and that the base system is well documented. The `Debian
Administrator's Handbook`_ is available online, and provides a
comprehensive introduction to the Debian operating system.
A standard Proxmox Backup Server installation uses the default
A standard Proxmox Backup installation uses the default
repositories from Debian, so you get bug fixes and security updates
through that channel. In addition, we provide our own package
repository to roll out all Proxmox related packages. This includes
@ -19,8 +19,8 @@ We also deliver a specially optimized Linux kernel, based on the Ubuntu
kernel. This kernel includes drivers for ZFS_.
The following sections will concentrate on backup related topics. They
will explain things which are different on Proxmox Backup Server, or
tasks which are commonly used on Proxmox Backup Server. For other topics,
will explain things which are different on Proxmox Backup, or
tasks which are commonly used on Proxmox Backup. For other topics,
please refer to the standard Debian documentation.

View File

@ -4,8 +4,8 @@
Host Bootloader
---------------
`Proxmox Backup`_ Server currently uses one of two bootloaders, depending on
the disk setup selected in the installer.
`Proxmox Backup`_ currently uses one of two bootloaders, depending on the disk setup
selected in the installer.
For EFI Systems installed with ZFS as the root filesystem ``systemd-boot`` is
used, unless Secure Boot is enabled. All other deployments use the standard
@ -18,8 +18,8 @@ on top of Debian).
Partitioning Scheme Used by the Installer
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The Proxmox Backup Server installer creates 3 partitions on all disks selected
for installation.
The Proxmox Backup installer creates 3 partitions on all disks selected for
installation.
The created partitions are:
@ -98,7 +98,7 @@ For example, to format an empty partition ``/dev/sda2`` as ESP, run the followin
# proxmox-boot-tool format /dev/sda2
To setup an existing, unmounted ESP located on ``/dev/sda2`` for inclusion in
Proxmox Backup Server's kernel update synchronization mechanism, use the following:
Proxmox Backup's kernel update synchronization mechanism, use the following:
.. code-block:: console
@ -198,7 +198,7 @@ Determine which Bootloader is Used
:alt: Grub boot screen
The simplest and most reliable way to determine which bootloader is used, is to
watch the boot process of the Proxmox Backup Server node.
watch the boot process of the Proxmox Backup node.
You will either see the blue box of ``grub`` or the simple black on white
@ -359,11 +359,10 @@ would run:
# proxmox-boot-tool kernel pin 5.15.30-1-pve
.. TIP:: The pinning functionality works for all Proxmox Backup Server systems,
not only those using ``proxmox-boot-tool`` to synchronize the contents of
the ESPs, if your system does not use ``proxmox-boot-tool`` for
synchronizing, you can also skip the ``proxmox-boot-tool refresh`` call in
the end.
.. TIP:: The pinning functionality works for all Proxmox Backup systems, not only those using
``proxmox-boot-tool`` to synchronize the contents of the ESPs, if your system
does not use ``proxmox-boot-tool`` for synchronizing, you can also skip the
``proxmox-boot-tool refresh`` call in the end.
You can also set a kernel version to be booted on the next system boot only.
This is useful, for example, to test if an updated kernel has resolved an issue,
@ -401,8 +400,8 @@ content and configuration on the ESPs by running the ``refresh`` subcommand.
Secure Boot
~~~~~~~~~~~
Since Proxmox Backup Server 3.1, Secure Boot is supported out of the box via
signed packages and integration in ``proxmox-boot-tool``.
Since Proxmox Backup 3.1, Secure Boot is supported out of the box via signed
packages and integration in ``proxmox-boot-tool``.
The following packages need to be installed for Secure Boot to be enabled:
@ -412,8 +411,8 @@ The following packages need to be installed for Secure Boot to be enabled:
* ``proxmox-kernel-6.X.Y-Z-pve-signed`` (Kernel image, signed by Proxmox)
Only Grub as bootloader is supported out of the box, since there are no other
pre-signed bootloader packages available. Any new installation of Proxmox
Backup Server will automatically have all of the above packages included.
pre-signed bootloader packages available. Any new installation of Proxmox Backup
will automatically have all of the above packages included.
More details about how Secure Boot works, and how to customize the setup, are
available in `our wiki <https://pve.proxmox.com/wiki/Secure_Boot_Setup>`_.
@ -427,10 +426,10 @@ Switching an Existing Installation to Secure Boot
.. WARNING:: This can lead to an unbootable installation in some cases if not
done correctly. Reinstalling the host will setup Secure Boot automatically if
available, without any extra interactions. **Make sure you have a working and
well-tested backup of your Proxmox Backup Server host!**
well-tested backup of your Proxmox Backup host!**
An existing UEFI installation can be switched over to Secure Boot if desired,
without having to reinstall Proxmox Backup Server from scratch.
without having to reinstall Proxmox Backup from scratch.
First, ensure all your system is up-to-date. Next, install all the required
pre-signed packages as listed above. Grub automatically creates the needed EFI
@ -443,8 +442,8 @@ boot entry for booting via the default shim.
If ``systemd-boot`` is used as a bootloader (see
:ref:`Determine which Bootloader is used <systembooting-determine-bootloader>`),
some additional setup is needed. This is only the case if Proxmox Backup Server
was installed with ZFS-on-root.
some additional setup is needed. This is only the case if Proxmox Backup was
installed with ZFS-on-root.
To check the latter, run:

View File

@ -1,10 +1,10 @@
System Requirements
-------------------
We recommend using high quality server hardware when running Proxmox Backup
Server in production. To further decrease the impact of a failed host, you can
set up periodic, efficient, incremental :ref:`datastore synchronization
<syncjobs>` from other Proxmox Backup Server instances.
We recommend using high quality server hardware when running Proxmox Backup in
production. To further decrease the impact of a failed host, you can set up
periodic, efficient, incremental :ref:`datastore synchronization <syncjobs>`
from other Proxmox Backup Server instances.
.. _minimum_system_requirements:

View File

@ -624,9 +624,9 @@ GUI, or enter the following command:
.. code-block:: console
// proxmox-tape pool create <name> [OPTIONS]
// proxmox-tape pool create <name> --drive <string> [OPTIONS]
# proxmox-tape pool create daily
# proxmox-tape pool create daily --drive mydrive
Additional options can be set later, using the update command:

View File

@ -156,10 +156,6 @@ metadata:
Similarly, the ``user delete-token`` subcommand can be used to delete a token
again.
.. WARNING:: Direct/manual edits to ``token.shadow`` may take up to 60 seconds (or
longer in edge cases) to take effect due to caching. Restart services for
immediate effect of manual edits.
Newly generated API tokens don't have any permissions. Please read the next
section to learn how to set access permissions.
@ -340,8 +336,8 @@ Some examples are:
:align: left
=========================== =========================================================
``/datastore`` Access to *all* datastores on a Proxmox Backup Server
``/datastore/{store}`` Access to a specific datastore on a Proxmox Backup Server
``/datastore`` Access to *all* datastores on a Proxmox Backup server
``/datastore/{store}`` Access to a specific datastore on a Proxmox Backup server
``/datastore/{store}/{ns}`` Access to a specific namespace on a specific datastore
``/remote`` Access to all remote entries
``/system/network`` Access to configure the host network
@ -536,7 +532,7 @@ WebAuthn
For WebAuthn to work, you need to have two things:
* A trusted HTTPS certificate (for example, by using `Let's Encrypt
<https://pbs.proxmox.com/wiki/HTTPS_Certificate_Configuration>`_).
<https://pbs.proxmox.com/wiki/index.php/HTTPS_Certificate_Configuration>`_).
While it probably works with an untrusted certificate, some browsers may warn
or refuse WebAuthn operations if it is not trusted.

View File

@ -1,7 +1,6 @@
include ../defines.mk
UNITS := \
pbs-network-config-commit.service \
proxmox-backup-daily-update.timer \
removable-device-attach@.service
@ -11,7 +10,7 @@ DYNAMIC_UNITS := \
proxmox-backup.service \
proxmox-backup-proxy.service
all: $(UNITS) $(DYNAMIC_UNITS) pbs-enterprise.sources
all: $(UNITS) $(DYNAMIC_UNITS) pbs-sources.list
clean:
rm -f $(DYNAMIC_UNITS)

View File

@ -1,5 +0,0 @@
Types: deb
URIs: https://enterprise.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-enterprise
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg

View File

@ -1,14 +0,0 @@
[Unit]
Description=Commit any pending Proxmox Backup Server network changes
DefaultDependencies=no
After=local-fs.target pvenetcommit.service
Before=sysinit.target
[Service]
Environment="FN=/etc/network/interfaces"
ExecStart=sh -c 'if [ -f ${FN}.new ]; then mv ${FN}.new ${FN}; fi'
Type=oneshot
RemainAfterExit=yes
[Install]
WantedBy=sysinit.target

1
etc/pbs-sources.list Normal file
View File

@ -0,0 +1 @@
deb https://mirrors.lierfang.com/pxcloud/pbs trixie main

View File

@ -1,5 +1,5 @@
[Unit]
Description=Conditionally mounting device with uuid '%i' if it belongs to a removable datastore
Description=Try to mount the removable device of a datastore with uuid '%i'.
After=proxmox-backup-proxy.service
Requires=proxmox-backup-proxy.service

View File

@ -1,14 +0,0 @@
[Unit]
Description=Mount tmpfs at /run/proxmox-backup
Conflicts=umount.target
Before=local-fs.target umount.target
[Mount]
Type=tmpfs
What=tmpfs
Where=/run/proxmox-backup
# Unlimited inodes as this stores per-chunk file locks
Options=rw,nosuid,nodev,noexec,relatime,uid=backup,gid=backup,nr_inodes=0,mode=755,inode64
[Install]
WantedBy=local-fs.target

View File

@ -3,7 +3,7 @@ use anyhow::Error;
// chacha20-poly1305
fn rate_test(name: &str, bench: &dyn Fn() -> usize) {
print!("{name:<20} ");
print!("{:<20} ", name);
let start = std::time::SystemTime::now();
let duration = std::time::Duration::new(1, 0);

View File

@ -18,7 +18,7 @@ use proxmox_schema::*;
///
/// Returns: nothing
fn echo_command(text: String) -> Result<(), Error> {
println!("{text}");
println!("{}", text);
Ok(())
}

View File

@ -67,7 +67,7 @@ async fn run() -> Result<(), Error> {
fn main() {
if let Err(err) = proxmox_async::runtime::main(run()) {
eprintln!("ERROR: {err}");
eprintln!("ERROR: {}", err);
}
println!("DONE");
}

View File

@ -19,7 +19,7 @@ use anyhow::{bail, Error};
// Error: detected shrunk file "./dyntest1/testfile0.dat" (22020096 < 12679380992)
fn create_large_file(path: PathBuf) {
println!("TEST {path:?}");
println!("TEST {:?}", path);
let mut file = std::fs::OpenOptions::new()
.write(true)
@ -47,7 +47,7 @@ fn main() -> Result<(), Error> {
for i in 0..20 {
let base = base.clone();
handles.push(thread::spawn(move || {
create_large_file(base.join(format!("testfile{i}.dat")));
create_large_file(base.join(format!("testfile{}.dat", i)));
}));
}

View File

@ -15,7 +15,7 @@ use proxmox_human_byte::HumanByte;
fn main() {
if let Err(err) = proxmox_async::runtime::main(run()) {
panic!("ERROR: {err}");
panic!("ERROR: {}", err);
}
}

View File

@ -40,10 +40,10 @@ async fn upload_speed() -> Result<f64, Error> {
fn main() {
match proxmox_async::runtime::main(upload_speed()) {
Ok(mbs) => {
println!("average upload speed: {mbs} MB/s");
println!("average upload speed: {} MB/s", mbs);
}
Err(err) => {
eprintln!("ERROR: {err}");
eprintln!("ERROR: {}", err);
}
}
}

View File

@ -8,26 +8,10 @@ fn main() {
Err(_) => match Command::new("git").args(["rev-parse", "HEAD"]).output() {
Ok(output) => String::from_utf8(output.stdout).unwrap(),
Err(err) => {
panic!("git rev-parse failed: {err}");
panic!("git rev-parse failed: {}", err);
}
},
};
println!("cargo:rustc-env=REPOID={repoid}");
let multiarch = match env::var("CARGO_CFG_TARGET_ARCH")
.as_ref()
.map(String::as_ref)
{
Ok("x86_64") => "x86_64-linux-gnu",
Ok("aarch64") => "aarch64-linux-gnu",
Ok("riscv64") => "riscv64-linux-gnu",
Ok(arch) => {
panic!("Unsupported architecture: {arch}");
}
Err(err) => {
panic!("Failed to get architecture from CARGO_CFG_TARGET_ARCH - {err}");
}
};
println!("cargo:rustc-env=DEB_HOST_MULTIARCH={multiarch}");
println!("cargo:rustc-env=REPOID={}", repoid);
}

View File

@ -51,17 +51,13 @@ macro_rules! PROXMOX_BACKUP_CACHE_DIR_M {
};
}
macro_rules! PROXMOX_BACKUP_MULTIARCH_LIB_DIR_M {
() => {
concat!("/usr/lib/", env!("DEB_HOST_MULTIARCH"), "/proxmox-backup")
#[macro_export]
macro_rules! PROXMOX_BACKUP_FILE_RESTORE_BIN_DIR_M {
($arch:expr) => {
format!("/usr/lib/{}-linux-gnu/proxmox-backup/file-restore", $arch)
};
}
/// The multiarch-namespaced /usr/lib path for Proxmox Backup.
///
/// E.g., on am64/x86_64 this will be "/usr/lib/x86_64-linux-gnu/proxmox-backup"
pub const PROXMOX_BACKUP_MULTIARCH_LIB_DIR: &str = PROXMOX_BACKUP_MULTIARCH_LIB_DIR_M!();
/// namespaced directory for in-memory (tmpfs) run state
pub const PROXMOX_BACKUP_RUN_DIR: &str = PROXMOX_BACKUP_RUN_DIR_M!();
@ -97,10 +93,16 @@ pub const PROXMOX_BACKUP_INITRAMFS_DBG_FN: &str = concat!(
);
/// filename of the kernel to use for booting single file restore VMs
pub const PROXMOX_BACKUP_KERNEL_FN: &str = concat!(
PROXMOX_BACKUP_MULTIARCH_LIB_DIR_M!(),
"/file-restore/bzImage"
);
pub fn PROXMOX_BACKUP_KERNEL_FN() -> String {
let arch = std::env::consts::ARCH;
let file_restore_dir = PROXMOX_BACKUP_FILE_RESTORE_BIN_DIR_M!(arch);
if arch == "x86_64" {
format!("{}/bzImage", file_restore_dir)
} else {
format!("{}/Image", file_restore_dir)
}
}
pub const PROXMOX_BACKUP_SUBSCRIPTION_FN: &str = configdir!("/subscription");

View File

@ -36,11 +36,10 @@ pathpatterns.workspace = true
proxmox-async.workspace = true
proxmox-auth-api.workspace = true
proxmox-compression.workspace = true
proxmox-http = { workspace = true, features = [ "body" ] }
proxmox-http = { workspace = true, features = [ "body", "rate-limiter" ] }
proxmox-human-byte.workspace = true
proxmox-io = { workspace = true, features = [ "tokio" ] }
proxmox-log = { workspace = true }
proxmox-rate-limiter = { workspace = true, features = [ "rate-limiter" ] }
proxmox-router = { workspace = true, features = [ "cli", "server" ] }
proxmox-schema.workspace = true
proxmox-sys.workspace = true

View File

@ -1,159 +1,8 @@
use std::fmt;
use anyhow::{bail, format_err, Error};
use serde::{Deserialize, Serialize};
use anyhow::{format_err, Error};
use proxmox_schema::*;
use pbs_api_types::{
Authid, BackupNamespace, Userid, BACKUP_REPO_URL, BACKUP_REPO_URL_REGEX, DATASTORE_SCHEMA,
IP_V6_REGEX,
};
pub const REPO_URL_SCHEMA: Schema =
StringSchema::new("Repository URL: [[auth-id@]server[:port]:]datastore")
.format(&BACKUP_REPO_URL)
.max_length(256)
.schema();
pub const BACKUP_REPO_SERVER_SCHEMA: Schema =
StringSchema::new("Backup server address (hostname or IP). Default: localhost")
.format(&api_types::DNS_NAME_OR_IP_FORMAT)
.max_length(256)
.schema();
pub const BACKUP_REPO_PORT_SCHEMA: Schema = IntegerSchema::new("Backup server port. Default: 8007")
.minimum(1)
.maximum(65535)
.default(8007)
.schema();
#[api(
properties: {
repository: {
schema: REPO_URL_SCHEMA,
optional: true,
},
server: {
schema: BACKUP_REPO_SERVER_SCHEMA,
optional: true,
},
port: {
schema: BACKUP_REPO_PORT_SCHEMA,
optional: true,
},
datastore: {
schema: DATASTORE_SCHEMA,
optional: true,
},
"auth-id": {
type: Authid,
optional: true,
},
},
)]
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Backup repository location, specified either as a repository URL or as individual
/// components (server, port, datastore, auth-id).
pub struct BackupRepositoryArgs {
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub datastore: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_id: Option<Authid>,
}
#[api(
properties: {
target: {
type: BackupRepositoryArgs,
flatten: true,
},
ns: {
type: BackupNamespace,
optional: true,
},
},
)]
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Backup target for CLI commands, combining the repository location with an
/// optional namespace.
pub struct BackupTargetArgs {
#[serde(flatten)]
pub target: BackupRepositoryArgs,
#[serde(skip_serializing_if = "Option::is_none")]
pub ns: Option<BackupNamespace>,
}
impl BackupRepositoryArgs {
/// Returns `true` if any atom parameter (server, port, datastore, or auth-id) is set.
pub fn has_atoms(&self) -> bool {
self.server.is_some()
|| self.port.is_some()
|| self.datastore.is_some()
|| self.auth_id.is_some()
}
/// Check that `--repository` and atom options are not mixed.
pub fn check_mutual_exclusion(&self) -> Result<(), Error> {
if self.repository.is_some() && self.has_atoms() {
bail!("--repository and --server/--port/--datastore/--auth-id are mutually exclusive");
}
Ok(())
}
/// Merge `self` with `fallback`, using values from `self` where present
/// and filling in from `fallback` for fields that are `None`.
pub fn merge_from(self, fallback: BackupRepositoryArgs) -> Self {
Self {
repository: self.repository.or(fallback.repository),
server: self.server.or(fallback.server),
port: self.port.or(fallback.port),
datastore: self.datastore.or(fallback.datastore),
auth_id: self.auth_id.or(fallback.auth_id),
}
}
}
impl TryFrom<BackupRepositoryArgs> for BackupRepository {
type Error = anyhow::Error;
/// Convert explicit CLI arguments into a [`BackupRepository`].
///
/// * If `repository` and any atom are both set, returns an error.
/// * If atoms are present, builds the repository from them (requires `datastore`).
/// * If only `repository` is set, parses the repo URL.
/// * If nothing is set, returns an error - callers must fall back to environment variables /
/// credentials themselves.
fn try_from(args: BackupRepositoryArgs) -> Result<Self, Self::Error> {
args.check_mutual_exclusion()?;
if args.has_atoms() {
let store = args.datastore.ok_or_else(|| {
format_err!("--datastore is required when not using --repository")
})?;
return Ok(BackupRepository::new(
args.auth_id,
args.server,
args.port,
store,
));
}
if let Some(url) = args.repository {
return url.parse();
}
bail!("no repository specified")
}
}
use pbs_api_types::{Authid, Userid, BACKUP_REPO_URL_REGEX, IP_V6_REGEX};
/// Reference remote backup locations
///
@ -178,7 +27,7 @@ impl BackupRepository {
store: String,
) -> Self {
let host = match host {
Some(host) if (IP_V6_REGEX.regex_obj)().is_match(&host) => Some(format!("[{host}]")),
Some(host) if (IP_V6_REGEX.regex_obj)().is_match(&host) => Some(format!("[{}]", host)),
other => other,
};
Self {
@ -266,172 +115,3 @@ impl std::str::FromStr for BackupRepository {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_datastore_only() {
let repo: BackupRepository = "mystore".parse().unwrap();
assert_eq!(repo.store(), "mystore");
assert_eq!(repo.host(), "localhost");
assert_eq!(repo.port(), 8007);
assert_eq!(repo.auth_id().to_string(), "root@pam");
}
#[test]
fn parse_host_and_datastore() {
let repo: BackupRepository = "myhost:mystore".parse().unwrap();
assert_eq!(repo.host(), "myhost");
assert_eq!(repo.store(), "mystore");
}
#[test]
fn parse_full_with_port() {
let repo: BackupRepository = "admin@pam@backuphost:8008:tank".parse().unwrap();
assert_eq!(repo.auth_id().to_string(), "admin@pam");
assert_eq!(repo.host(), "backuphost");
assert_eq!(repo.port(), 8008);
assert_eq!(repo.store(), "tank");
}
#[test]
fn parse_ipv4_with_port() {
let repo: BackupRepository = "192.168.1.1:1234:mystore".parse().unwrap();
assert_eq!(repo.host(), "192.168.1.1");
assert_eq!(repo.port(), 1234);
}
#[test]
fn parse_ipv6_with_port() {
let repo: BackupRepository = "[ff80::1]:9007:mystore".parse().unwrap();
assert_eq!(repo.host(), "[ff80::1]");
assert_eq!(repo.port(), 9007);
}
#[test]
fn parse_api_token() {
let repo: BackupRepository = "user@pbs!token@myhost:mystore".parse().unwrap();
assert_eq!(repo.auth_id().to_string(), "user@pbs!token");
}
#[test]
fn parse_invalid_url_errors() {
assert!("".parse::<BackupRepository>().is_err());
}
#[test]
fn display_round_trip() {
for url in [
"mystore",
"myhost:mystore",
"admin@pam@backuphost:8008:tank",
] {
let repo: BackupRepository = url.parse().unwrap();
assert_eq!(repo.to_string(), url, "round-trip failed for '{url}'");
}
}
#[test]
fn new_wraps_bare_ipv6_in_brackets() {
let repo = BackupRepository::new(None, Some("ff80::1".into()), None, "s".into());
assert_eq!(repo.host(), "[ff80::1]");
}
#[test]
fn new_preserves_already_bracketed_ipv6() {
let repo = BackupRepository::new(None, Some("[ff80::1]".into()), None, "s".into());
assert_eq!(repo.host(), "[ff80::1]");
}
#[test]
fn has_atoms() {
assert!(!BackupRepositoryArgs::default().has_atoms());
let with_server = BackupRepositoryArgs {
server: Some("host".into()),
..Default::default()
};
assert!(with_server.has_atoms());
let repo_only = BackupRepositoryArgs {
repository: Some("myhost:mystore".into()),
..Default::default()
};
assert!(!repo_only.has_atoms());
}
#[test]
fn try_from_atoms_only() {
let args = BackupRepositoryArgs {
server: Some("pbs.local".into()),
port: Some(9000),
datastore: Some("tank".into()),
auth_id: Some("backup@pam".parse().unwrap()),
..Default::default()
};
let repo = BackupRepository::try_from(args).unwrap();
assert_eq!(repo.host(), "pbs.local");
assert_eq!(repo.port(), 9000);
assert_eq!(repo.store(), "tank");
assert_eq!(repo.auth_id().to_string(), "backup@pam");
}
#[test]
fn try_from_atoms_datastore_only() {
let args = BackupRepositoryArgs {
datastore: Some("local".into()),
..Default::default()
};
let repo = BackupRepository::try_from(args).unwrap();
assert_eq!(repo.store(), "local");
assert_eq!(repo.host(), "localhost");
assert_eq!(repo.port(), 8007);
}
#[test]
fn try_from_url_only() {
let args = BackupRepositoryArgs {
repository: Some("admin@pam@backuphost:8008:mystore".into()),
..Default::default()
};
let repo = BackupRepository::try_from(args).unwrap();
assert_eq!(repo.host(), "backuphost");
assert_eq!(repo.port(), 8008);
assert_eq!(repo.store(), "mystore");
}
#[test]
fn try_from_mutual_exclusion_error() {
let args = BackupRepositoryArgs {
repository: Some("somehost:mystore".into()),
server: Some("otherhost".into()),
..Default::default()
};
let err = BackupRepository::try_from(args).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"), "got: {err}");
}
#[test]
fn try_from_nothing_set_error() {
let err = BackupRepository::try_from(BackupRepositoryArgs::default()).unwrap_err();
assert!(
err.to_string().contains("no repository specified"),
"got: {err}"
);
}
#[test]
fn try_from_atoms_without_datastore_error() {
let args = BackupRepositoryArgs {
server: Some("pbs.local".into()),
..Default::default()
};
let err = BackupRepository::try_from(args).unwrap_err();
assert!(
err.to_string().contains("--datastore is required"),
"got: {err}"
);
}
}

View File

@ -52,26 +52,7 @@ pub struct UploadOptions {
pub previous_manifest: Option<Arc<BackupManifest>>,
pub compress: bool,
pub encrypt: bool,
pub index_type: IndexType,
}
/// Index type for upload options.
#[derive(Default, Clone)]
pub enum IndexType {
/// Dynamic chunking.
#[default]
Dynamic,
/// Fixed size chunking with optional image file size.
Fixed(Option<u64>),
}
impl IndexType {
fn to_prefix_and_size(&self) -> (&'static str, Option<u64>) {
match self {
IndexType::Fixed(size) => ("fixed", *size),
IndexType::Dynamic => ("dynamic", None),
}
}
pub fixed_size: Option<u64>,
}
struct ChunkUploadResponse {
@ -110,9 +91,9 @@ impl BackupWriter {
}
#[allow(clippy::too_many_arguments)]
pub async fn start(
pub async fn start<'a>(
client: &HttpClient,
writer_options: BackupWriterOptions<'_>,
writer_options: BackupWriterOptions<'a>,
) -> Result<Arc<BackupWriter>, Error> {
let mut param = json!({
"backup-type": writer_options.backup.ty(),
@ -311,10 +292,12 @@ impl BackupWriter {
options: UploadOptions,
) -> Result<BackupStats, Error> {
let mut param = json!({ "archive-name": archive_name });
let (prefix, archive_size) = options.index_type.to_prefix_and_size();
if let Some(size) = archive_size {
let prefix = if let Some(size) = options.fixed_size {
param["size"] = size.into();
}
"fixed"
} else {
"dynamic"
};
if options.encrypt && self.crypt_config.is_none() {
bail!("requested encryption without a crypt config");
@ -404,17 +387,19 @@ impl BackupWriter {
let known_chunks = Arc::new(Mutex::new(HashSet::new()));
let mut param = json!({ "archive-name": archive_name });
let (prefix, archive_size) = options.index_type.to_prefix_and_size();
if let Some(size) = archive_size {
let prefix = if let Some(size) = options.fixed_size {
param["size"] = size.into();
}
"fixed"
} else {
"dynamic"
};
if options.encrypt && self.crypt_config.is_none() {
bail!("requested encryption without a crypt config");
}
let index_path = format!("{prefix}_index");
let close_path = format!("{prefix}_close");
let index_path = format!("{}_index", prefix);
let close_path = format!("{}_close", prefix);
if let Some(manifest) = options.previous_manifest {
if !manifest

View File

@ -7,7 +7,6 @@ use std::ops::ControlFlow;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Mutex;
use anyhow::{bail, format_err, Error};
use nix::dir::Dir;
@ -36,7 +35,7 @@ type FileEntry = pxar::accessor::aio::FileEntry<Reader>;
const MAX_SYMLINK_COUNT: usize = 40;
static SHELL: Mutex<Option<Shell>> = Mutex::new(None);
static mut SHELL: Option<usize> = None;
/// This list defines all the shell commands and their properties
/// using the api schema
@ -104,10 +103,8 @@ pub fn catalog_shell_cli() -> CommandLineInterface {
}
fn complete_path(complete_me: &str, _map: &HashMap<String, String>) -> Vec<String> {
let result = block_on(Shell::with(async |shell| {
shell.complete_path(complete_me).await
}));
match result {
let shell: &mut Shell = unsafe { std::mem::transmute(SHELL.unwrap()) };
match shell.complete_path(complete_me) {
Ok(list) => list,
Err(err) => {
error!("error during completion: {}", err);
@ -127,7 +124,7 @@ async fn exit() -> Result<(), Error> {
#[api(input: { properties: {} })]
/// List the current working directory.
async fn pwd_command() -> Result<(), Error> {
Shell::with(Shell::pwd).await
Shell::with(move |shell| shell.pwd()).await
}
#[api(
@ -144,7 +141,7 @@ async fn pwd_command() -> Result<(), Error> {
/// Change the current working directory to the new directory
async fn cd_command(path: Option<String>) -> Result<(), Error> {
let path = path.as_ref().map(Path::new);
Shell::with(async |shell| shell.cd(path).await).await
Shell::with(move |shell| shell.cd(path)).await
}
#[api(
@ -161,7 +158,7 @@ async fn cd_command(path: Option<String>) -> Result<(), Error> {
/// List the content of working directory or given path.
async fn ls_command(path: Option<String>) -> Result<(), Error> {
let path = path.as_ref().map(Path::new);
Shell::with(async |shell| shell.ls(path).await).await
Shell::with(move |shell| shell.ls(path)).await
}
#[api(
@ -179,7 +176,7 @@ async fn ls_command(path: Option<String>) -> Result<(), Error> {
/// This is expensive because the data has to be read from the pxar archive, which means reading
/// over the network.
async fn stat_command(path: String) -> Result<(), Error> {
Shell::with(async |shell| shell.stat(PathBuf::from(path)).await).await
Shell::with(move |shell| shell.stat(PathBuf::from(path))).await
}
#[api(
@ -197,7 +194,7 @@ async fn stat_command(path: String) -> Result<(), Error> {
/// This will return an error if the entry is already present in the list or
/// if an invalid path was provided.
async fn select_command(path: String) -> Result<(), Error> {
Shell::with(async |shell| shell.select(PathBuf::from(path)).await).await
Shell::with(move |shell| shell.select(PathBuf::from(path))).await
}
#[api(
@ -215,13 +212,13 @@ async fn select_command(path: String) -> Result<(), Error> {
/// This will return an error if the entry was not found in the list of entries
/// selected for restore.
async fn deselect_command(path: String) -> Result<(), Error> {
Shell::with(async |shell| shell.deselect(PathBuf::from(path)).await).await
Shell::with(move |shell| shell.deselect(PathBuf::from(path))).await
}
#[api( input: { properties: { } })]
/// Clear the list of files selected for restore.
async fn clear_selected_command() -> Result<(), Error> {
Shell::with(async |shell| shell.deselect_all().await).await
Shell::with(move |shell| shell.deselect_all()).await
}
#[api(
@ -238,7 +235,7 @@ async fn clear_selected_command() -> Result<(), Error> {
)]
/// List entries currently selected for restore.
async fn list_selected_command(patterns: bool) -> Result<(), Error> {
Shell::with(async |shell| shell.list_selected(patterns).await).await
Shell::with(move |shell| shell.list_selected(patterns)).await
}
#[api(
@ -258,7 +255,7 @@ async fn list_selected_command(patterns: bool) -> Result<(), Error> {
)]
/// Find entries in the catalog matching the given match pattern.
async fn find_command(pattern: String, select: bool) -> Result<(), Error> {
Shell::with(async |shell| shell.find(pattern, select).await).await
Shell::with(move |shell| shell.find(pattern, select)).await
}
#[api(
@ -275,7 +272,7 @@ async fn find_command(pattern: String, select: bool) -> Result<(), Error> {
///
/// Target must not exist on the clients filesystem.
async fn restore_selected_command(target: String) -> Result<(), Error> {
Shell::with(async |shell| shell.restore_selected(PathBuf::from(target)).await).await
Shell::with(move |shell| shell.restore_selected(PathBuf::from(target))).await
}
#[api(
@ -298,7 +295,7 @@ async fn restore_selected_command(target: String) -> Result<(), Error> {
/// subset of this sub-archive.
/// If pattern is not present or empty, the full archive is restored to target.
async fn restore_command(target: String, pattern: Option<String>) -> Result<(), Error> {
Shell::with(async |shell| shell.restore(PathBuf::from(target), pattern).await).await
Shell::with(move |shell| shell.restore(PathBuf::from(target), pattern)).await
}
/// TODO: Should we use this to fix `step()`? Make path resolution behave more like described in
@ -308,6 +305,9 @@ async fn restore_command(target: String, pattern: Option<String>) -> Result<(),
/// trailing `Component::CurDir` entries. Since we only support regular paths we'll roll our own
/// here:
pub struct Shell {
/// Readline instance handling input and callbacks
rl: rustyline::Editor<CliHelper, rustyline::history::MemHistory>,
/// Interactive prompt.
prompt: String,
@ -351,6 +351,13 @@ impl Shell {
archive_name: &str,
archive: Accessor,
) -> Result<Self, Error> {
let cli_helper = CliHelper::new(catalog_shell_cli());
let mut rl = rustyline::Editor::<CliHelper, _>::with_history(
rustyline::Config::default(),
rustyline::history::MemHistory::new(),
)?;
rl.set_helper(Some(cli_helper));
let mut position = Vec::new();
if let Some(catalog) = catalog.as_mut() {
let catalog_root = catalog.root()?;
@ -378,6 +385,7 @@ impl Shell {
}
let mut this = Self {
rl,
prompt: String::new(),
catalog,
selected: HashMap::new(),
@ -388,40 +396,28 @@ impl Shell {
Ok(this)
}
async fn with<R, F>(call: F) -> Result<R, Error>
async fn with<'a, Fut, R, F>(call: F) -> Result<R, Error>
where
F: AsyncFnOnce(&mut Shell) -> Result<R, Error>,
F: FnOnce(&'a mut Shell) -> Fut,
Fut: Future<Output = Result<R, Error>>,
F: 'a,
Fut: 'a,
R: 'static,
{
// Under the assumption that only one shell command is executed at a
// time, moving out of a std mutex like this ensures data integrity
// after panics (or cancellations) and detects deadlocks from recursion.
let mut shell = SHELL
.lock()
.unwrap()
.take()
.ok_or_else(|| format_err!("expected SHELL"))?;
let result = call(&mut shell).await;
*SHELL.lock().unwrap() = Some(shell);
result
let shell: &mut Shell = unsafe { std::mem::transmute(SHELL.unwrap()) };
call(&mut *shell).await
}
pub async fn shell(self) -> Result<(), Error> {
let mut prompt = self.prompt.clone();
*SHELL.lock().unwrap() = Some(self);
let cli_helper = CliHelper::new(catalog_shell_cli());
let mut rl = rustyline::Editor::<CliHelper, _>::with_history(
rustyline::Config::default(),
rustyline::history::MemHistory::new(),
)?;
rl.set_helper(Some(cli_helper));
while let Ok(line) = rl.readline(&prompt) {
pub async fn shell(mut self) -> Result<(), Error> {
let this = &mut self;
unsafe {
SHELL = Some(this as *mut Shell as usize);
}
while let Ok(line) = this.rl.readline(&this.prompt) {
if line == "exit" {
break;
}
let helper = rl.helper().unwrap();
let helper = this.rl.helper().unwrap();
let args = match cli::shellword_split(&line) {
Ok(args) => args,
Err(err) => {
@ -433,16 +429,9 @@ impl Shell {
let _ =
cli::handle_command_future(helper.cmd_def(), "", args, cli::CliEnvironment::new())
.await;
let _ = rl.add_history_entry(line);
if let Some(shell) = &mut *SHELL.lock().unwrap() {
shell.update_prompt();
prompt = shell.prompt.clone();
} else {
bail!("SHELL missing at prompt update");
}
let _ = this.rl.add_history_entry(line);
this.update_prompt();
}
*SHELL.lock().unwrap() = None;
Ok(())
}
@ -474,7 +463,7 @@ impl Shell {
)
.await?;
let path = Self::format_path_stack(&stack);
println!("{path:?}");
println!("{:?}", path);
Ok(())
}
@ -565,7 +554,7 @@ impl Shell {
Ok(())
}
async fn step_nofollow(
fn step_nofollow(
stack: &mut Vec<PathStackEntry>,
catalog: &mut Option<CatalogReader>,
component: std::path::Component<'_>,
@ -590,8 +579,8 @@ impl Shell {
}
} else {
let pxar_entry = parent_pxar_entry(stack)?;
let parent_dir = pxar_entry.enter_directory().await?;
match parent_dir.lookup(entry).await? {
let parent_dir = block_on(pxar_entry.enter_directory())?;
match block_on(parent_dir.lookup(entry))? {
Some(entry) => {
let entry_attr = DirEntryAttribute::try_from(&entry)?;
stack.push(PathStackEntry {
@ -625,13 +614,13 @@ impl Shell {
}
/// Non-async version cannot follow symlinks.
async fn walk_catalog_nofollow(
fn walk_catalog_nofollow(
stack: &mut Vec<PathStackEntry>,
catalog: &mut Option<CatalogReader>,
path: &Path,
) -> Result<(), Error> {
for c in path.components() {
Self::step_nofollow(stack, catalog, c).await?;
Self::step_nofollow(stack, catalog, c)?;
}
Ok(())
}
@ -668,7 +657,7 @@ impl Shell {
Ok(stack.last().unwrap().pxar.clone().unwrap())
}
async fn complete_path(&mut self, input: &str) -> Result<Vec<String>, Error> {
fn complete_path(&mut self, input: &str) -> Result<Vec<String>, Error> {
let mut tmp_stack;
let (parent, base, part) = match input.rfind('/') {
Some(ind) => {
@ -679,7 +668,7 @@ impl Shell {
} else {
tmp_stack = self.position.clone();
}
Self::walk_catalog_nofollow(&mut tmp_stack, &mut self.catalog, &path).await?;
Self::walk_catalog_nofollow(&mut tmp_stack, &mut self.catalog, &path)?;
(&tmp_stack.last().unwrap(), base, part)
}
None => (&self.position.last().unwrap(), "", input),
@ -689,12 +678,12 @@ impl Shell {
catalog.read_dir(&parent.catalog)?
} else {
let dir = if let Some(entry) = parent.pxar.as_ref() {
entry.enter_directory().await?
block_on(entry.enter_directory())?
} else {
bail!("missing pxar entry for parent");
};
let mut out = Vec::new();
let entries = crate::pxar::tools::pxar_metadata_read_dir(dir).await?;
let entries = block_on(crate::pxar::tools::pxar_metadata_read_dir(dir))?;
for entry in entries {
let mut name = base.to_string();
let file_name = entry.file_name().as_bytes();
@ -873,9 +862,9 @@ impl Shell {
let path = Self::format_path_stack(&stack);
let entry = MatchEntry::include(MatchPattern::Literal(path.as_bytes().to_vec()));
if self.selected.insert(path.clone(), entry).is_some() {
println!("path already selected: {path:?}");
println!("path already selected: {:?}", path);
} else {
println!("added path: {path:?}");
println!("added path: {:?}", path);
}
Ok(())
@ -894,9 +883,9 @@ impl Shell {
let path = Self::format_path_stack(&stack);
if self.selected.remove(&path).is_some() {
println!("removed path from selection: {path:?}");
println!("removed path from selection: {:?}", path);
} else {
println!("path not selected: {path:?}");
println!("path not selected: {:?}", path);
}
Ok(())
@ -918,7 +907,7 @@ impl Shell {
async fn list_selected_patterns(&self) -> Result<(), Error> {
for entry in self.selected.keys() {
println!("{entry:?}");
println!("{:?}", entry);
}
Ok(())
}
@ -1091,8 +1080,7 @@ impl Shell {
extractor,
match_list,
&self.accessor,
)
.await?;
)?;
extractor.extract().await
}
@ -1131,7 +1119,7 @@ struct ExtractorState<'a> {
}
impl<'a> ExtractorState<'a> {
pub async fn new(
pub fn new(
catalog: &'a mut Option<CatalogReader>,
dir_stack: Vec<PathStackEntry>,
extractor: crate::pxar::extract::Extractor,
@ -1144,8 +1132,8 @@ impl<'a> ExtractorState<'a> {
.into_iter()
} else {
let pxar_entry = parent_pxar_entry(&dir_stack)?;
let dir = pxar_entry.enter_directory().await?;
let entries = crate::pxar::tools::pxar_metadata_read_dir(dir).await?;
let dir = block_on(pxar_entry.enter_directory())?;
let entries = block_on(crate::pxar::tools::pxar_metadata_read_dir(dir))?;
let mut catalog_entries = Vec::with_capacity(entries.len());
for entry in entries {
@ -1241,7 +1229,7 @@ impl<'a> ExtractorState<'a> {
let dir = Shell::walk_pxar_archive(self.accessor, &mut self.dir_stack).await?;
self.dir_stack.pop();
let dir = dir.enter_directory().await?;
let entries = crate::pxar::tools::pxar_metadata_read_dir(dir).await?;
let entries = block_on(crate::pxar::tools::pxar_metadata_read_dir(dir))?;
entries
.into_iter()
.map(|entry| {

View File

@ -34,7 +34,6 @@ impl InjectionData {
/// Split input stream into dynamic sized chunks
pub struct ChunkStream<S: Unpin> {
input: S,
input_is_done: bool,
chunker: Box<dyn Chunker + Send>,
buffer: BytesMut,
scan_pos: usize,
@ -52,7 +51,6 @@ impl<S: Unpin> ChunkStream<S> {
let chunk_size = chunk_size.unwrap_or(4 * 1024 * 1024);
Self {
input,
input_is_done: false,
chunker: if let Some(suggested) = suggested_boundaries {
Box::new(PayloadChunker::new(chunk_size, suggested))
} else {
@ -164,16 +162,12 @@ where
}
}
if this.input_is_done {
return Poll::Ready(None);
}
match ready!(Pin::new(&mut this.input).try_poll_next(cx)) {
Some(Err(err)) => {
return Poll::Ready(Some(Err(err.into())));
}
None => {
this.scan_pos = 0;
this.input_is_done = true;
if !this.buffer.is_empty() {
return Poll::Ready(Some(Ok(this.buffer.split())));
} else {
@ -193,7 +187,6 @@ pub struct FixedChunkStream<S: Unpin> {
input: S,
chunk_size: usize,
buffer: BytesMut,
done: bool,
}
impl<S: Unpin> FixedChunkStream<S> {
@ -202,7 +195,6 @@ impl<S: Unpin> FixedChunkStream<S> {
input,
chunk_size,
buffer: BytesMut::new(),
done: false,
}
}
}
@ -221,9 +213,6 @@ where
cx: &mut Context,
) -> Poll<Option<Result<BytesMut, S::Error>>> {
let this = self.get_mut();
if this.done {
return Poll::Ready(None);
}
loop {
if this.buffer.len() >= this.chunk_size {
return Poll::Ready(Some(Ok(this.buffer.split_to(this.chunk_size))));
@ -234,9 +223,6 @@ where
return Poll::Ready(Some(Err(err)));
}
None => {
// Must not call input.try_poll_next again!
this.done = true;
// last chunk can have any size
if !this.buffer.is_empty() {
return Poll::Ready(Some(Ok(this.buffer.split())));
@ -260,12 +246,11 @@ mod test {
struct DummyInput {
data: Vec<u8>,
done: bool,
}
impl DummyInput {
fn new(data: Vec<u8>) -> Self {
Self { data, done: false }
Self { data }
}
}
@ -275,11 +260,7 @@ mod test {
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match this.data.len() {
0 => {
assert!(!this.done);
this.done = true;
Poll::Ready(None)
}
0 => Poll::Ready(None),
size if size > 10 => Poll::Ready(Some(Ok(this.data.split_off(10)))),
_ => Poll::Ready(Some(Ok(std::mem::take(&mut this.data)))),
}

View File

@ -31,9 +31,8 @@ use proxmox_async::broadcast_future::BroadcastFuture;
use proxmox_http::client::HttpsConnector;
use proxmox_http::uri::{build_authority, json_object_to_query};
use proxmox_http::Body;
use proxmox_http::ProxyConfig;
use proxmox_http::{ProxyConfig, RateLimiter};
use proxmox_log::{error, info, warn};
use proxmox_rate_limiter::RateLimiter;
use pbs_api_types::percent_encoding::DEFAULT_ENCODE_SET;
use pbs_api_types::{Authid, RateLimitConfig, Userid};
@ -134,7 +133,6 @@ pub struct HttpClientOptions {
fingerprint_cache: bool,
verify_cert: bool,
limit: RateLimitConfig,
proxy: Option<ProxyConfig>,
}
impl HttpClientOptions {
@ -197,11 +195,6 @@ impl HttpClientOptions {
self.limit = rate_limit;
self
}
pub fn proxy(mut self, proxy: Option<ProxyConfig>) -> Self {
self.proxy = proxy;
self
}
}
impl Default for HttpClientOptions {
@ -215,7 +208,6 @@ impl Default for HttpClientOptions {
fingerprint_cache: false,
verify_cert: true,
limit: RateLimitConfig::default(), // unlimited
proxy: None,
}
}
}
@ -244,11 +236,7 @@ pub fn delete_ticket_info(prefix: &str, server: &str, username: &Userid) -> Resu
let mut data = file_get_json(&path, Some(json!({})))?;
if let Some(map) = data[server].as_object_mut() {
if map.remove(username.as_str()).is_none() {
warn!("no ticket found for {username} on server {server}");
}
} else {
warn!("no ticket found for server {server}");
map.remove(username.as_str());
}
replace_file(
@ -392,8 +380,8 @@ fn build_uri(server: &str, port: u16, path: &str, query: Option<String>) -> Resu
.scheme("https")
.authority(build_authority(server, port)?)
.path_and_query(match query {
Some(query) => format!("/{path}?{query}"),
None => format!("/{path}"),
Some(query) => format!("/{}?{}", path, query),
None => format!("/{}", path),
})
.build()
.map_err(|err| format_err!("error building uri - {}", err))
@ -437,11 +425,11 @@ impl HttpClient {
) {
Ok(None) => true,
Ok(Some(fingerprint)) => {
if fingerprint_cache {
if let Some(ref prefix) = prefix {
if let Err(err) = store_fingerprint(prefix, &server, &fingerprint) {
error!("{}", err);
}
if fingerprint_cache && prefix.is_some() {
if let Err(err) =
store_fingerprint(prefix.as_ref().unwrap(), &server, &fingerprint)
{
error!("{}", err);
}
}
*verified_fingerprint.lock().unwrap() = Some(fingerprint);
@ -485,9 +473,7 @@ impl HttpClient {
)))));
}
let proxy_config = options.proxy.clone();
let proxy_config = proxy_config.or(ProxyConfig::from_proxy_env()?);
let proxy_config = ProxyConfig::from_proxy_env()?;
if let Some(config) = proxy_config {
info!("Using proxy connection: {}:{}", config.host, config.port);
https.set_proxy(config);
@ -549,18 +535,16 @@ impl HttpClient {
.await
{
Ok(auth) => {
if use_ticket_cache {
if let Some(ref prefix) = prefix2 {
if let Err(err) = store_ticket_info(
prefix,
&server2,
&auth.auth_id.to_string(),
&auth.ticket,
&auth.token,
) {
if std::io::stdout().is_terminal() {
error!("storing login ticket failed: {}", err);
}
if use_ticket_cache && prefix2.is_some() {
if let Err(err) = store_ticket_info(
prefix2.as_ref().unwrap(),
&server2,
&auth.auth_id.to_string(),
&auth.ticket,
&auth.token,
) {
if std::io::stdout().is_terminal() {
error!("storing login ticket failed: {}", err);
}
}
}
@ -588,22 +572,19 @@ impl HttpClient {
let authinfo = auth.clone();
move |auth| {
if use_ticket_cache {
if let Some(ref prefix) = prefix {
if let Err(err) = store_ticket_info(
prefix,
&server,
&auth.auth_id.to_string(),
&auth.ticket,
&auth.token,
) {
if std::io::stdout().is_terminal() {
error!("storing login ticket failed: {}", err);
}
if use_ticket_cache && prefix.is_some() {
if let Err(err) = store_ticket_info(
prefix.as_ref().unwrap(),
&server,
&auth.auth_id.to_string(),
&auth.ticket,
&auth.token,
) {
if std::io::stdout().is_terminal() {
error!("storing login ticket failed: {}", err);
}
}
}
*authinfo.write().unwrap() = auth;
tokio::spawn(renewal_future);
}
@ -654,7 +635,7 @@ impl HttpClient {
fn get_password(username: &Userid, interactive: bool) -> Result<String, Error> {
// If we're on a TTY, query the user for a password
if interactive && std::io::stdin().is_terminal() {
let msg = format!("Password for \"{username}\": ");
let msg = format!("Password for \"{}\": ", username);
return Ok(String::from_utf8(tty::read_password(&msg)?)?);
}

View File

@ -188,11 +188,6 @@ struct Archiver {
suggested_boundaries: Option<mpsc::Sender<u64>>,
previous_payload_index: Option<DynamicIndexReader>,
cache: PxarLookaheadCache,
// Highest payload offset accepted as reusable so far. Mirrors the encoder's accumulated
// strict-monotonic-offset invariant on the read side, so that a previous archive written by
// an older client without the encode-time check is detected here and the affected file is
// re-encoded instead of reused.
last_reusable_offset: Option<u64>,
reuse_stats: ReuseStats,
split_archive: bool,
}
@ -303,7 +298,6 @@ where
suggested_boundaries,
previous_payload_index,
cache: PxarLookaheadCache::new(options.max_cache_size),
last_reusable_offset: None,
reuse_stats: ReuseStats::default(),
split_archive,
};
@ -424,16 +418,6 @@ impl Archiver {
.boxed()
}
/// Record an accepted reusable payload offset from the previous metadata archive.
///
/// Returns `false` if `offset` is not strictly greater than the last accepted offset, which
/// signals a duplicate or non-monotonic previous archive. The caller must then treat the file
/// as non-reusable so the encoder's strict offset check cannot reject the eventual
/// `add_payload_ref`. Tracking is global to catch inversions that span cache ranges.
fn try_record_reusable_offset(&mut self, offset: u64) -> bool {
try_record_strictly_greater(&mut self.last_reusable_offset, offset)
}
async fn is_reusable_entry(
&mut self,
previous_metadata_accessor: &Option<Directory<MetadataArchiveReader>>,
@ -453,19 +437,6 @@ impl Archiver {
if file_size != *size {
return Ok(None);
}
// a previous archive written by an older client version may contain
// duplicate or non-monotonic PXAR_PAYLOAD_REF offsets for distinct files.
// Re-encoding the affected file lets the chain self-heal and avoids the
// pxar strict offset check rejecting the whole backup.
if !self.try_record_reusable_offset(*offset) {
let last = self.last_reusable_offset.unwrap_or(0);
warn!(
"re-encode: {file_name:?} previous archive payload offset \
{offset} not strictly greater than last seen {last}, \
treating as non-reusable"
);
return Ok(None);
}
let range =
*offset..*offset + size + size_of::<pxar::format::Header>() as u64;
debug!(
@ -566,20 +537,13 @@ impl Archiver {
let mut buf;
let (line, mode, anchored) = if line[0] == b'/' {
buf = Vec::with_capacity(path_bytes.len() + 2 + line.len());
// need to anchor the base path if it is not
if !path_bytes.is_empty() && !path_bytes.starts_with(b"/") {
buf.push(b'/');
}
buf = Vec::with_capacity(path_bytes.len() + 1 + line.len());
buf.extend(path_bytes);
buf.extend(line);
(&buf[..], MatchType::Exclude, true)
} else if line.starts_with(b"!/") {
// inverted case with absolute path
buf = Vec::with_capacity(path_bytes.len() + 1 + line.len());
if !path_bytes.is_empty() && !path_bytes.starts_with(b"/") {
buf.push(b'/');
}
buf = Vec::with_capacity(path_bytes.len() + line.len());
buf.extend(path_bytes);
buf.extend(&line[1..]); // without the '!'
(&buf[..], MatchType::Include, true)
@ -589,20 +553,7 @@ impl Archiver {
(line, MatchType::Exclude, false)
};
let line = OsStr::from_bytes(line);
let line_normalized = crate::pxar::tools::normalize_lexically(line);
if line_normalized.as_os_str() != line {
warn!(
"Sanitized exclude pattern. Exclude patterns are relative to the current \
backup root, not the current working directory and should not contain '.' or \
'..' as path segments."
);
}
match MatchEntry::parse_pattern(
line_normalized.as_os_str().as_bytes(),
PatternFlag::PATH_NAME,
mode,
) {
match MatchEntry::parse_pattern(line, PatternFlag::PATH_NAME, mode) {
Ok(pattern) => {
if anchored {
self.patterns.push(pattern.add_flags(MatchFlag::ANCHORED));
@ -1063,10 +1014,9 @@ impl Archiver {
let mut padding = start_padding + end_padding;
let total_size = (range.end - range.start) + padding;
// must use the same identity predicate as the absorb branch below; otherwise
// a dedup collision subtracts an unrelated chunk's bytes from padding.
// take into account used bytes of kept back chunk for padding
if let (Some(first), Some(last)) = (indices.first(), prev_last_chunk.as_ref()) {
if last.same_indexed_chunk_as(first) {
if last.digest() == first.digest() {
// Update padding used for threshold calculation only
let used = last.size() - last.padding;
padding -= used;
@ -1094,13 +1044,10 @@ impl Archiver {
indices.len(),
);
// inject the kept back chunk unless it refers to the same index entry
// as the new range's first: digests differ when the previous range ended
// on a chunk boundary, and matching digests at different `end_offset`s
// are a dedup collision in the previous archive (same content at distinct
// index positions). Both cases need an explicit injection.
// check for cases where kept back last is not equal first chunk because the range
// end aligned with a chunk boundary, and the chunks therefore needs to be injected
if let (Some(first), Some(last)) = (indices.first_mut(), prev_last_chunk) {
if !last.same_indexed_chunk_as(first) {
if last.digest() != first.digest() {
// make sure to inject previous last chunk before encoding entries
self.inject_chunks_at_current_payload_position(encoder, vec![last])?;
} else {
@ -1402,10 +1349,6 @@ pub struct ReusableDynamicEntry {
size: u64,
padding: u64,
digest: [u8; 32],
/// Absolute end position of this entry in the previous archive's payload index;
/// together with `digest` the canonical identity tuple for an index entry -
/// `digest` alone aliases dedup-collided entries at distinct index positions.
end_offset: u64,
}
impl ReusableDynamicEntry {
@ -1418,28 +1361,6 @@ impl ReusableDynamicEntry {
pub fn digest(&self) -> [u8; 32] {
self.digest
}
/// Returns whether `self` and `other` reference the same entry in the previous
/// archive's payload index. Two distinct entries can share a digest (same chunk
/// content via deduplication), so digest equality alone aliases dedup collisions;
/// `end_offset` is the per-index-entry identity component that disambiguates them.
#[inline]
pub fn same_indexed_chunk_as(&self, other: &Self) -> bool {
self.digest == other.digest && self.end_offset == other.end_offset
}
}
/// Updates `last` to `offset` and returns `true` if `offset` is strictly greater than the current
/// value (or `last` is `None`). Returns `false` without modifying `last` for duplicate or
/// backwards offsets.
fn try_record_strictly_greater(last: &mut Option<u64>, offset: u64) -> bool {
if let Some(prev) = *last {
if offset <= prev {
return false;
}
}
*last = Some(offset);
true
}
/// List of dynamic entries containing the data given by an offset range
@ -1467,7 +1388,6 @@ fn lookup_dynamic_entries(
size: (end - prev_end),
padding: 0,
digest: dynamic_entry.digest(),
end_offset: end,
};
indices.push(reusable_dynamic_entry);
@ -2074,7 +1994,6 @@ mod tests {
previous_payload_index,
suggested_boundaries: Some(suggested_boundaries),
cache: PxarLookaheadCache::new(None),
last_reusable_offset: None,
reuse_stats: ReuseStats::default(),
split_archive: true,
};
@ -2108,93 +2027,4 @@ mod tests {
Ok::<(), Error>(())
})
}
/// Strictly-greater bookkeeping for [`Archiver::try_record_reusable_offset`]; mirrors the
/// encoder's accumulated strict-monotonic-offset invariant on the read side.
#[test]
fn try_record_strictly_greater_accepts_increasing() {
let mut last = None;
assert!(super::try_record_strictly_greater(&mut last, 100));
assert_eq!(last, Some(100));
assert!(super::try_record_strictly_greater(&mut last, 200));
assert_eq!(last, Some(200));
}
#[test]
fn try_record_strictly_greater_rejects_equal_and_backwards() {
let mut last = Some(200);
// duplicate offset: previous archive recorded two distinct files at the same offset
assert!(!super::try_record_strictly_greater(&mut last, 200));
assert_eq!(last, Some(200), "rejected offset must not update state");
// backwards offset: previous archive recorded the next file at a lower offset
assert!(!super::try_record_strictly_greater(&mut last, 150));
assert_eq!(last, Some(200));
}
#[test]
fn try_record_strictly_greater_first_call_accepts_any_value() {
let mut last = None;
assert!(super::try_record_strictly_greater(&mut last, 0));
assert_eq!(last, Some(0));
}
/// Pins `lookup_dynamic_entries`'s `end_offset` population to `dynamic_entry.end()`;
/// any range-relative substitute would alias dedup-collided entries and resurrect
/// the backwards-`PXAR_PAYLOAD_REF` bug class.
#[test]
fn lookup_dynamic_entries_pins_chunk_identity() {
use pbs_datastore::dynamic_index::{DynamicIndexReader, DynamicIndexWriter};
// Use ./target/ instead of tempfile / std::env::temp_dir to keep the test
// runnable in build environments where /tmp may not be writable.
let mut testdir = PathBuf::from("./target/testout");
testdir.push(std::module_path!());
let _ = std::fs::create_dir_all(&testdir);
let path = testdir.join("lookup_dynamic_entries.didx");
let _ = std::fs::remove_file(&path);
let mut writer = DynamicIndexWriter::create(&path).unwrap();
let digest_a = [0xAAu8; 32];
let digest_b = [0xBBu8; 32];
// chunk 0: ends at 1024, digest A
writer.add_chunk(1024, &digest_a).unwrap();
// chunk 1: ends at 2048, digest B
writer.add_chunk(2048, &digest_b).unwrap();
// chunk 2: ends at 3072, digest A again - dedup collision (same content,
// distinct position). This is the configuration the bug class lives on.
writer.add_chunk(3072, &digest_a).unwrap();
let _csum = writer.close().unwrap();
let reader = DynamicIndexReader::open(&path).unwrap();
let (indices, _start_padding, _end_padding) =
super::lookup_dynamic_entries(&reader, &(0..3072)).unwrap();
assert_eq!(indices.len(), 3);
assert_eq!(indices[0].end_offset, 1024);
assert_eq!(indices[1].end_offset, 2048);
assert_eq!(indices[2].end_offset, 3072);
// The dedup collision must NOT compare equal under same_indexed_chunk_as; this
// is the predicate flush_cached_reusing_if_below_threshold relies on for the
// absorb-vs-inject decision.
assert_eq!(indices[0].digest, indices[2].digest);
assert!(!indices[0].same_indexed_chunk_as(&indices[2]));
assert!(indices[0].same_indexed_chunk_as(&indices[0]));
let _ = std::fs::remove_file(&path);
}
/// Inversions that span cache ranges must be caught by the global tracking; a per-range
/// implementation would have reset on flush and accepted the second offset, after which
/// the encoder strict check would still abort the backup.
#[test]
fn try_record_strictly_greater_persists_across_resets() {
let mut last = None;
assert!(super::try_record_strictly_greater(&mut last, 1000));
// a per-range implementation would have cleared `last` here on cache flush
assert!(!super::try_record_strictly_greater(&mut last, 500));
assert_eq!(last, Some(1000));
assert!(super::try_record_strictly_greater(&mut last, 1500));
assert_eq!(last, Some(1500));
}
}

View File

@ -36,10 +36,10 @@ impl PxarDir {
}
fn create_dir(
&'_ mut self,
&mut self,
parent: RawFd,
allow_existing_dirs: bool,
) -> Result<BorrowedFd<'_>, Error> {
) -> Result<BorrowedFd, Error> {
if let Err(err) = mkdirat(
Some(parent),
self.file_name.as_os_str(),
@ -53,7 +53,7 @@ impl PxarDir {
self.open_dir(parent)
}
fn open_dir(&'_ mut self, parent: RawFd) -> Result<BorrowedFd<'_>, Error> {
fn open_dir(&mut self, parent: RawFd) -> Result<BorrowedFd, Error> {
let dir = Dir::openat(
Some(parent),
self.file_name.as_os_str(),
@ -68,7 +68,7 @@ impl PxarDir {
Ok(fd)
}
pub fn try_as_borrowed_fd(&'_ self) -> Option<BorrowedFd<'_>> {
pub fn try_as_borrowed_fd(&self) -> Option<BorrowedFd> {
// Once `nix` adds `AsFd` support use `.as_fd()` instead.
self.dir
.as_ref()
@ -120,7 +120,7 @@ impl PxarDirStack {
Ok(out)
}
pub fn last_dir_fd(&'_ mut self, allow_existing_dirs: bool) -> Result<BorrowedFd<'_>, Error> {
pub fn last_dir_fd(&mut self, allow_existing_dirs: bool) -> Result<BorrowedFd, Error> {
// should not be possible given the way we use it:
assert!(!self.dirs.is_empty(), "PxarDirStack underrun");
@ -147,7 +147,7 @@ impl PxarDirStack {
Ok(())
}
pub fn root_dir_fd(&'_ self) -> Result<BorrowedFd<'_>, Error> {
pub fn root_dir_fd(&self) -> Result<BorrowedFd, Error> {
// should not be possible given the way we use it:
assert!(!self.dirs.is_empty(), "PxarDirStack underrun");

View File

@ -26,7 +26,7 @@ use proxmox_log::{debug, error, info};
use proxmox_sys::c_result;
use proxmox_sys::fs::{create_path, CreateOptions};
use proxmox_compression::zip::{FileType, ZipEncoder, ZipEntry};
use proxmox_compression::zip::{ZipEncoder, ZipEntry};
use crate::pxar::dir_stack::PxarDirStack;
use crate::pxar::metadata;
@ -1034,7 +1034,7 @@ where
path,
metadata.stat.mtime.secs,
metadata.stat.mode as u16,
FileType::Directory,
false,
);
zip.add_entry::<FileContents<T>>(entry, None).await?;
}
@ -1053,7 +1053,7 @@ where
path,
metadata.stat.mtime.secs,
metadata.stat.mode as u16,
FileType::Regular,
true,
);
let contents = decoder.contents().await?;
zip.add_entry(entry, contents)
@ -1064,7 +1064,7 @@ where
let entry = root
.lookup(&path)
.await?
.with_context(|| format!("error looking up {path:?}"))?;
.with_context(|| format!("error looking up {:?}", path))?;
let realfile = accessor.follow_hardlink(&entry).await?;
let metadata = realfile.entry().metadata();
debug!("adding '{}' to zip", path.display());
@ -1072,7 +1072,7 @@ where
path,
metadata.stat.mtime.secs,
metadata.stat.mode as u16,
FileType::Regular,
true,
);
let contents = decoder.contents().await?;
zip.add_entry(entry, contents)
@ -1085,28 +1085,17 @@ where
path,
metadata.stat.mtime.secs,
metadata.stat.mode as u16,
FileType::Directory,
false,
);
zip.add_entry::<FileContents<T>>(entry, None).await?;
}
EntryKind::Symlink(target) => {
debug!("adding '{}' to zip", path.display());
let entry = ZipEntry::new(
path,
metadata.stat.mtime.secs,
metadata.stat.mode as u16,
FileType::Symlink,
);
let target = io::Cursor::<&[u8]>::new(target.as_ref());
zip.add_entry(entry, Some(target)).await?;
}
_ => {} // ignore all else
};
}
}
zip.finish().await.map_err(|err| {
eprintln!("error during finishing of zip: {err}");
eprintln!("error during finishing of zip: {}", err);
err
})
}
@ -1333,11 +1322,16 @@ where
}
.await
{
let path_display = match entry.kind() {
EntryKind::GoodbyeTable => "<directory>".to_string(),
_ => entry.path().display().to_string(),
};
error!("error extracting {path_display}: {err}");
let display_string = entry.path().display().to_string();
error!(
"error extracting {}: {}",
if matches!(entry.kind(), EntryKind::GoodbyeTable) {
"<directory>"
} else {
&display_string
},
err
);
}
if dir_level < 0 {

View File

@ -105,7 +105,7 @@ pub fn apply(
path_info: &Path,
on_error: &mut (dyn FnMut(Error) -> Result<(), Error> + Send),
) -> Result<(), Error> {
let c_proc_path = CString::new(format!("/proc/self/fd/{fd}")).unwrap();
let c_proc_path = CString::new(format!("/proc/self/fd/{}", fd)).unwrap();
apply_ownership(flags, c_proc_path.as_ptr(), metadata, &mut *on_error)?;
let mut skip_xattrs = false;

View File

@ -33,7 +33,7 @@ pub(crate) fn perms_from_metadata(meta: &Metadata) -> Result<Mode, Error> {
.context("couldn't narrow permission bits")
.and_then(|mode| {
Mode::from_bits(mode)
.with_context(|| format!("mode contains illegal bits: 0x{mode:x} (0o{mode:o})"))
.with_context(|| format!("mode contains illegal bits: 0x{:x} (0o{:o})", mode, mode))
})
}
@ -76,35 +76,6 @@ fn assert_single_path_component_do(path: &Path) -> Result<(), Error> {
Ok(())
}
pub fn normalize_lexically<S: AsRef<OsStr> + ?Sized>(path: &S) -> PathBuf {
// FIXME: Once std::path::normalize_lexically is stabilized we can
// switch to that
use std::path::Component;
let path = Path::new(path);
let has_trailing_slash = path
.as_os_str()
.as_encoded_bytes()
.ends_with(std::path::MAIN_SEPARATOR_STR.as_bytes());
let mut new = PathBuf::new();
let iter = path.components();
for component in iter {
match component {
Component::RootDir => new.push(Component::RootDir),
Component::Prefix(p) => new.push(Component::Prefix(p)),
Component::CurDir => continue,
Component::ParentDir => {
new.pop();
}
Component::Normal(n) => new.push(n),
};
}
if has_trailing_slash {
new.push("");
}
new
}
#[rustfmt::skip]
fn symbolic_mode(c: u64, special: bool, special_x: u8, special_no_x: u8) -> [u8; 3] {
[

View File

@ -42,9 +42,9 @@ pub enum KeySource {
pub fn format_key_source(source: &KeySource, key_type: &str) -> String {
match source {
KeySource::DefaultKey => format!("Using default {key_type} key.."),
KeySource::Fd => format!("Using {key_type} key from file descriptor.."),
KeySource::Path(path) => format!("Using {key_type} key from '{path}'.."),
KeySource::DefaultKey => format!("Using default {} key..", key_type),
KeySource::Fd => format!("Using {} key from file descriptor..", key_type),
KeySource::Path(path) => format!("Using {} key from '{}'..", key_type, path),
}
}
@ -386,9 +386,9 @@ fn test_crypto_parameters_handling() -> Result<(), Error> {
let testdir = create_testdir("key_source")?;
let keypath = format!("{testdir}/keyfile.test");
let master_keypath = format!("{testdir}/masterkeyfile.test");
let invalid_keypath = format!("{testdir}/invalid_keyfile.test");
let keypath = format!("{}/keyfile.test", testdir);
let master_keypath = format!("{}/masterkeyfile.test", testdir);
let invalid_keypath = format!("{}/invalid_keyfile.test", testdir);
let no_key_res = CryptoParams {
enc_key: None,

View File

@ -17,14 +17,12 @@ use proxmox_router::cli::{complete_file_name, shellword_split};
use proxmox_schema::*;
use proxmox_sys::fs::file_get_json;
use pbs_api_types::{Authid, BackupArchiveName, BackupNamespace, RateLimitConfig, UserWithTokens};
use pbs_api_types::{
Authid, BackupArchiveName, BackupNamespace, RateLimitConfig, UserWithTokens, BACKUP_REPO_URL,
};
use pbs_datastore::BackupManifest;
use crate::{BackupRepository, BackupRepositoryArgs, HttpClient, HttpClientOptions};
// Re-export for backward compatibility; the canonical definition is now in backup_repo alongside
// BackupRepositoryArgs.
pub use crate::REPO_URL_SCHEMA;
use crate::{BackupRepository, HttpClient, HttpClientOptions};
pub mod key_source;
@ -32,11 +30,6 @@ const ENV_VAR_PBS_FINGERPRINT: &str = "PBS_FINGERPRINT";
const ENV_VAR_PBS_PASSWORD: &str = "PBS_PASSWORD";
const ENV_VAR_PBS_ENCRYPTION_PASSWORD: &str = "PBS_ENCRYPTION_PASSWORD";
const ENV_VAR_PBS_REPOSITORY: &str = "PBS_REPOSITORY";
const ENV_VAR_PBS_SERVER: &str = "PBS_SERVER";
const ENV_VAR_PBS_PORT: &str = "PBS_PORT";
const ENV_VAR_PBS_DATASTORE: &str = "PBS_DATASTORE";
const ENV_VAR_PBS_AUTH_ID: &str = "PBS_AUTH_ID";
const ENV_VAR_PBS_NAMESPACE: &str = "PBS_NAMESPACE";
/// Directory with system [credential]s. See systemd-creds(1).
///
@ -51,6 +44,11 @@ const CRED_PBS_REPOSITORY: &str = "proxmox-backup-client.repository";
/// Credential name of the the fingerprint.
const CRED_PBS_FINGERPRINT: &str = "proxmox-backup-client.fingerprint";
pub const REPO_URL_SCHEMA: Schema = StringSchema::new("Repository URL.")
.format(&BACKUP_REPO_URL)
.max_length(256)
.schema();
pub const CHUNK_SIZE_SCHEMA: Schema = IntegerSchema::new("Chunk size in KB. Must be a power of 2.")
.minimum(64)
.maximum(4096)
@ -115,7 +113,7 @@ fn get_secret_from_env(base_name: &str) -> Result<Option<String>, Error> {
Err(NotPresent) => {}
};
let env_name = format!("{base_name}_FD");
let env_name = format!("{}_FD", base_name);
match std::env::var(&env_name) {
Ok(fd_str) => {
let fd: i32 = fd_str.parse().map_err(|err| {
@ -132,7 +130,7 @@ fn get_secret_from_env(base_name: &str) -> Result<Option<String>, Error> {
Err(NotPresent) => {}
}
let env_name = format!("{base_name}_FILE");
let env_name = format!("{}_FILE", base_name);
match std::env::var(&env_name) {
Ok(filename) => {
let mut file = std::fs::File::open(filename)
@ -143,7 +141,7 @@ fn get_secret_from_env(base_name: &str) -> Result<Option<String>, Error> {
Err(NotPresent) => {}
}
let env_name = format!("{base_name}_CMD");
let env_name = format!("{}_CMD", base_name);
match std::env::var(&env_name) {
Ok(ref command) => {
let args = shellword_split(command)?;
@ -235,122 +233,41 @@ pub fn get_fingerprint() -> Option<String> {
.unwrap_or_default()
}
/// Build [`BackupRepositoryArgs`] from the fields in a JSON Value.
fn args_from_value(param: &Value) -> BackupRepositoryArgs {
BackupRepositoryArgs {
repository: param["repository"].as_str().map(String::from),
server: param["server"].as_str().map(String::from),
port: param["port"].as_u64().map(|p| p as u16),
datastore: param["datastore"].as_str().map(String::from),
auth_id: param["auth-id"]
.as_str()
.and_then(|s| s.parse::<Authid>().ok()),
}
}
/// Build [`BackupRepositoryArgs`] from `PBS_*` environment variables.
fn args_from_env() -> BackupRepositoryArgs {
BackupRepositoryArgs {
repository: None,
server: std::env::var(ENV_VAR_PBS_SERVER).ok(),
port: std::env::var(ENV_VAR_PBS_PORT)
.ok()
.and_then(|p| p.parse::<u16>().ok()),
datastore: std::env::var(ENV_VAR_PBS_DATASTORE).ok(),
auth_id: std::env::var(ENV_VAR_PBS_AUTH_ID)
.ok()
.and_then(|s| s.parse::<Authid>().ok()),
}
}
/// Resolve a [`BackupRepository`] from explicit CLI arguments with environment variable fallback.
///
/// Resolution:
/// - `--repository` and CLI atoms are mutually exclusive.
/// - `--repository` alone is used as-is (env vars ignored).
/// - CLI atoms are merged with `PBS_*` env atom vars per-field (CLI wins).
/// - If no CLI args are given, falls back to `PBS_REPOSITORY`, then to
/// `PBS_*` atom env vars, then errors.
fn resolve_repository(cli: BackupRepositoryArgs) -> Result<BackupRepository, Error> {
cli.check_mutual_exclusion()?;
if cli.repository.is_some() {
return BackupRepository::try_from(cli);
}
if cli.has_atoms() {
let env = args_from_env();
return BackupRepository::try_from(cli.merge_from(env));
}
// No CLI args at all, try environment.
if let Some(url) = get_default_repository() {
return url.parse();
}
let env = args_from_env();
if env.has_atoms() {
return BackupRepository::try_from(env);
}
bail!("unable to get (default) repository");
}
/// Remove repository-related keys from a JSON Value and return the parsed [`BackupRepository`].
///
/// This is used by commands that forward the remaining parameters to the server API after stripping
/// the repository fields.
pub fn remove_repository_from_value(param: &mut Value) -> Result<BackupRepository, Error> {
let map = param
if let Some(url) = param
.as_object_mut()
.ok_or_else(|| format_err!("unable to get repository (parameter is not an object)"))?;
.ok_or_else(|| format_err!("unable to get repository (parameter is not an object)"))?
.remove("repository")
{
return url
.as_str()
.ok_or_else(|| format_err!("invalid repository value (must be a string)"))?
.parse();
}
let to_string = |v: Value| v.as_str().map(String::from);
let args = BackupRepositoryArgs {
repository: map.remove("repository").and_then(to_string),
server: map.remove("server").and_then(to_string),
port: map
.remove("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16),
datastore: map.remove("datastore").and_then(to_string),
auth_id: map
.remove("auth-id")
.and_then(to_string)
.map(|s| s.parse::<Authid>())
.transpose()?,
};
resolve_repository(args)
get_default_repository()
.ok_or_else(|| format_err!("unable to get default repository"))?
.parse()
}
/// Extract a [`BackupRepository`] from CLI parameters.
pub fn extract_repository_from_value(param: &Value) -> Result<BackupRepository, Error> {
resolve_repository(args_from_value(param))
let repo_url = param["repository"]
.as_str()
.map(String::from)
.or_else(get_default_repository)
.ok_or_else(|| format_err!("unable to get (default) repository"))?;
let repo: BackupRepository = repo_url.parse()?;
Ok(repo)
}
/// Extract a [`BackupRepository`] from a parameter map (used for shell completion callbacks).
pub fn extract_repository_from_map(param: &HashMap<String, String>) -> Option<BackupRepository> {
let cli = BackupRepositoryArgs {
repository: param.get("repository").cloned(),
server: param.get("server").cloned(),
port: param.get("port").and_then(|p| p.parse().ok()),
datastore: param.get("datastore").cloned(),
auth_id: param.get("auth-id").and_then(|s| s.parse().ok()),
};
resolve_repository(cli).ok()
}
/// Extract a [`BackupNamespace`] from CLI parameters, falling back to PBS_NAMESPACE.
pub fn optional_ns_param(param: &Value) -> Result<BackupNamespace, Error> {
match param.get("ns") {
Some(Value::String(ns)) => return ns.parse(),
Some(_) => bail!("invalid namespace parameter"),
None => {}
}
if let Ok(ns) = std::env::var(ENV_VAR_PBS_NAMESPACE) {
return ns.parse();
}
Ok(BackupNamespace::root())
param
.get("repository")
.map(String::from)
.or_else(get_default_repository)
.and_then(|repo_url| repo_url.parse::<BackupRepository>().ok())
}
pub fn connect(repo: &BackupRepository) -> Result<HttpClient, Error> {
@ -428,7 +345,7 @@ pub async fn complete_backup_group_do(param: &HashMap<String, String>) -> Vec<St
if let (Some(backup_id), Some(backup_type)) =
(item["backup-id"].as_str(), item["backup-type"].as_str())
{
result.push(format!("{backup_type}/{backup_id}"));
result.push(format!("{}/{}", backup_type, backup_id));
}
}
}
@ -449,7 +366,7 @@ pub async fn complete_group_or_snapshot_do(
let mut result = vec![];
for group in groups {
result.push(group.to_string());
result.push(format!("{group}/"));
result.push(format!("{}/", group));
}
return result;
}
@ -730,7 +647,7 @@ pub fn find_xdg_file(
let file_name = file_name.as_ref();
base_directories()
.map(|base| base.find_config_file(file_name))
.with_context(|| format!("error searching for {description}"))
.with_context(|| format!("error searching for {}", description))
}
pub fn place_xdg_file(
@ -740,7 +657,7 @@ pub fn place_xdg_file(
let file_name = file_name.as_ref();
base_directories()
.and_then(|base| base.place_config_file(file_name).map_err(Error::from))
.with_context(|| format!("failed to place {description} in xdg home"))
.with_context(|| format!("failed to place {} in xdg home", description))
}
pub fn get_pxar_archive_names(
@ -840,141 +757,3 @@ pub fn create_tmp_file() -> std::io::Result<std::fs::File> {
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
const REPO_ENV_VARS: &[&str] = &[
ENV_VAR_PBS_REPOSITORY,
ENV_VAR_PBS_SERVER,
ENV_VAR_PBS_PORT,
ENV_VAR_PBS_DATASTORE,
ENV_VAR_PBS_AUTH_ID,
ENV_VAR_PBS_NAMESPACE,
ENV_VAR_CREDENTIALS_DIRECTORY,
];
fn with_cleared_repo_env(f: impl FnOnce()) {
let _guard = ENV_MUTEX.lock().unwrap();
for k in REPO_ENV_VARS {
std::env::remove_var(k);
}
f();
for k in REPO_ENV_VARS {
std::env::remove_var(k);
}
}
#[test]
fn extract_repo_from_atoms() {
with_cleared_repo_env(|| {
let param = json!({"server": "myhost", "datastore": "mystore"});
let repo = extract_repository_from_value(&param).unwrap();
assert_eq!(repo.host(), "myhost");
assert_eq!(repo.store(), "mystore");
assert_eq!(repo.port(), 8007);
});
}
#[test]
fn extract_repo_from_url() {
with_cleared_repo_env(|| {
let param = json!({"repository": "myhost:mystore"});
let repo = extract_repository_from_value(&param).unwrap();
assert_eq!(repo.host(), "myhost");
assert_eq!(repo.store(), "mystore");
});
}
#[test]
fn extract_repo_mutual_exclusion_error() {
with_cleared_repo_env(|| {
let param = json!({"repository": "myhost:mystore", "auth-id": "user@pam"});
let err = extract_repository_from_value(&param).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"), "got: {err}");
});
}
#[test]
fn extract_repo_atoms_without_datastore_error() {
with_cleared_repo_env(|| {
let param = json!({"server": "myhost"});
let err = extract_repository_from_value(&param).unwrap_err();
assert!(
err.to_string().contains("--datastore is required"),
"got: {err}"
);
});
}
#[test]
fn extract_repo_nothing_provided_error() {
with_cleared_repo_env(|| {
let err = extract_repository_from_value(&json!({})).unwrap_err();
assert!(err.to_string().contains("unable to get"), "got: {err}");
});
}
#[test]
fn extract_repo_env_fallback() {
with_cleared_repo_env(|| {
std::env::set_var(ENV_VAR_PBS_SERVER, "envhost");
std::env::set_var(ENV_VAR_PBS_DATASTORE, "envstore");
let repo = extract_repository_from_value(&json!({})).unwrap();
assert_eq!(repo.host(), "envhost");
assert_eq!(repo.store(), "envstore");
});
}
#[test]
fn extract_repo_pbs_repository_env_takes_precedence() {
with_cleared_repo_env(|| {
std::env::set_var(ENV_VAR_PBS_REPOSITORY, "repohost:repostore");
std::env::set_var(ENV_VAR_PBS_SERVER, "envhost");
std::env::set_var(ENV_VAR_PBS_DATASTORE, "envstore");
let repo = extract_repository_from_value(&json!({})).unwrap();
assert_eq!(repo.host(), "repohost");
assert_eq!(repo.store(), "repostore");
});
}
#[test]
fn extract_repo_cli_overrides_env() {
with_cleared_repo_env(|| {
std::env::set_var(ENV_VAR_PBS_REPOSITORY, "envhost:envstore");
let param = json!({"server": "clihost", "datastore": "clistore"});
let repo = extract_repository_from_value(&param).unwrap();
assert_eq!(repo.host(), "clihost");
assert_eq!(repo.store(), "clistore");
});
}
#[test]
fn extract_repo_cli_atoms_merge_with_env_atoms() {
with_cleared_repo_env(|| {
std::env::set_var(ENV_VAR_PBS_SERVER, "envhost");
std::env::set_var(ENV_VAR_PBS_DATASTORE, "envstore");
let param = json!({"auth-id": "backup@pbs"});
let repo = extract_repository_from_value(&param).unwrap();
assert_eq!(repo.host(), "envhost");
assert_eq!(repo.store(), "envstore");
assert_eq!(repo.auth_id().to_string(), "backup@pbs");
});
}
#[test]
fn extract_repo_cli_atom_overrides_same_env_atom() {
with_cleared_repo_env(|| {
std::env::set_var(ENV_VAR_PBS_SERVER, "envhost");
std::env::set_var(ENV_VAR_PBS_DATASTORE, "envstore");
let param = json!({"server": "clihost"});
let repo = extract_repository_from_value(&param).unwrap();
assert_eq!(repo.host(), "clihost");
assert_eq!(repo.store(), "envstore");
});
}
}

View File

@ -9,18 +9,14 @@ rust-version.workspace = true
[dependencies]
anyhow.workspace = true
const_format.workspace = true
hex.workspace = true
libc.workspace = true
nix.workspace = true
once_cell.workspace = true
openssl.workspace = true
parking_lot.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
proxmox-http.workspace = true
proxmox-lang.workspace = true
proxmox-notify.workspace = true
proxmox-router = { workspace = true, default-features = false }
proxmox-s3-client.workspace = true
@ -33,4 +29,3 @@ proxmox-uuid.workspace = true
pbs-api-types.workspace = true
pbs-buildcfg.workspace = true
pbs-key-config.workspace = true

View File

@ -127,12 +127,6 @@ pub fn check_acl_path(path: &str) -> Result<(), Error> {
_ => {}
}
}
"s3-endpoint" | "encryption-keys" => {
// /system/<matched-component>/{id}
if components_len <= 3 {
return Ok(());
}
}
_ => {}
}
}
@ -449,7 +443,7 @@ impl AclTree {
for (auth_id, roles) in &node.users {
// no need to save, because root is always 'Administrator'
if !auth_id.is_token() && auth_id.user() == Userid::root_userid() {
if !auth_id.is_token() && auth_id.user() == "root@pam" {
continue;
}
for (role, propagate) in roles {
@ -465,7 +459,7 @@ impl AclTree {
for (group, roles) in &node.groups {
for (role, propagate) in roles {
let group = format!("@{group}");
let group = format!("@{}", group);
if *propagate {
role_ug_map1.entry(role).or_default().insert(group);
} else {
@ -533,7 +527,7 @@ impl AclTree {
}
for (name, child) in node.children.iter() {
let child_path = format!("{path}/{name}");
let child_path = format!("{}/{}", path, name);
Self::write_node_config(child, &child_path, w)?;
}
@ -789,7 +783,8 @@ mod test {
assert_eq!(
roles, expected_roles,
"\nat check_roles for '{auth_id}' on '{path}'"
"\nat check_roles for '{}' on '{}'",
auth_id, path
);
}

View File

@ -137,7 +137,7 @@ impl CachedUserInfo {
}
pub fn is_superuser(&self, auth_id: &Authid) -> bool {
!auth_id.is_token() && auth_id.user() == Userid::root_userid()
!auth_id.is_token() && auth_id.user() == "root@pam"
}
pub fn is_group_member(&self, _userid: &Userid, _group: &str) -> bool {
@ -208,7 +208,7 @@ impl CachedUserInfo {
impl UserInformation for CachedUserInfo {
fn is_superuser(&self, userid: &str) -> bool {
userid == Userid::root_userid().as_str()
userid == "root@pam"
}
fn is_group_member(&self, _userid: &str, _group: &str) -> bool {

View File

@ -26,9 +26,8 @@ struct ConfigVersionCacheDataInner {
// Traffic control (traffic-control.cfg) generation/version.
traffic_control_generation: AtomicUsize,
// datastore (datastore.cfg) generation/version
// FIXME: remove with PBS 3.0
datastore_generation: AtomicUsize,
// Token shadow (token.shadow) generation/version.
token_shadow_generation: AtomicUsize,
// Add further atomics here
}
@ -146,35 +145,12 @@ impl ConfigVersionCache {
.fetch_add(1, Ordering::AcqRel);
}
/// Returns the datastore generation number.
pub fn datastore_generation(&self) -> usize {
self.shmem
.data()
.datastore_generation
.load(Ordering::Acquire)
}
/// Increase the datastore generation number.
// FIXME: remove with PBS 3.0 or make actually useful again in datastore lookup
pub fn increase_datastore_generation(&self) -> usize {
self.shmem
.data()
.datastore_generation
.fetch_add(1, Ordering::AcqRel)
}
/// Returns the token shadow generation number.
pub fn token_shadow_generation(&self) -> usize {
self.shmem
.data()
.token_shadow_generation
.load(Ordering::Acquire)
}
/// Increase the token shadow generation number.
pub fn increase_token_shadow_generation(&self) -> usize {
self.shmem
.data()
.token_shadow_generation
.fetch_add(1, Ordering::AcqRel)
}
}

View File

@ -6,7 +6,7 @@ use anyhow::Error;
use proxmox_schema::{AllOfSchema, ApiType};
use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
use pbs_api_types::{DataStoreConfig, DatastoreBackendConfig, DATASTORE_SCHEMA};
use pbs_api_types::{DataStoreConfig, DATASTORE_SCHEMA};
use crate::{open_backup_lockfile, replace_backup_config, BackupLockGuard, ConfigVersionCache};
@ -85,7 +85,7 @@ pub fn complete_acl_path(_arg: &str, _param: &HashMap<String, String>) -> Vec<St
if let Ok((data, _digest)) = config() {
for id in data.sections.keys() {
list.push(format!("/datastore/{id}"));
list.push(format!("/datastore/{}", id));
}
}
@ -114,15 +114,15 @@ pub fn complete_calendar_event(_arg: &str, _param: &HashMap<String, String>) ->
.collect()
}
/// Parse the backend configuration from a datastore config.
pub fn parse_backend_config(config: &DataStoreConfig) -> Result<DatastoreBackendConfig, Error> {
config.backend.as_deref().unwrap_or("").parse()
}
/// Returns the datastore backend type from its name.
/// Returns the datastore backend type from it's name
pub fn datastore_backend_type(store: &str) -> Result<pbs_api_types::DatastoreBackendType, Error> {
let (config, _) = self::config()?;
let store_config: DataStoreConfig = config.lookup("datastore", store)?;
let (config, _) = config()?;
let store_config: DataStoreConfig = config.lookup("datastore", &store)?;
Ok(parse_backend_config(&store_config)?.ty.unwrap_or_default())
let backend_config: pbs_api_types::DatastoreBackendConfig = serde_json::from_value(
pbs_api_types::DatastoreBackendConfig::API_SCHEMA
.parse_property_string(store_config.backend.as_deref().unwrap_or(""))?,
)?;
Ok(backend_config.ty.unwrap_or_default())
}

View File

@ -1,201 +0,0 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use anyhow::{bail, format_err, Error};
use nix::{sys::stat::Mode, unistd::Uid};
use serde::Deserialize;
use pbs_api_types::{CryptKey, KeyInfo, CRYPT_KEY_ID_SCHEMA};
use proxmox_schema::ApiType;
use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
use proxmox_sys::fs::CreateOptions;
use pbs_buildcfg::configdir;
use pbs_key_config::KeyConfig;
use crate::{open_backup_lockfile, replace_backup_config, BackupLockGuard};
pub static CONFIG: LazyLock<SectionConfig> = LazyLock::new(init);
fn init() -> SectionConfig {
let obj_schema = CryptKey::API_SCHEMA.unwrap_all_of_schema();
let plugin = SectionConfigPlugin::new(
ENCRYPTION_KEYS_CFG_TYPE_ID.to_string(),
Some(String::from("id")),
obj_schema,
);
let mut config = SectionConfig::new(&CRYPT_KEY_ID_SCHEMA);
config.register_plugin(plugin);
config
}
/// Configuration file location for encryption keys.
pub const ENCRYPTION_KEYS_CFG_FILENAME: &str = configdir!("/encryption-keys.cfg");
/// Configuration lock file used to prevent concurrent configuration update operations.
pub const ENCRYPTION_KEYS_CFG_LOCKFILE: &str = configdir!("/.encryption-keys.lck");
/// Directory where to store the actual encryption keys
pub const ENCRYPTION_KEYS_DIR: &str = configdir!("/encryption-keys/");
/// Config type for encryption key config entries
pub const ENCRYPTION_KEYS_CFG_TYPE_ID: &str = "sync-key";
/// Get exclusive lock for encryption key configuration update.
pub fn lock_config() -> Result<BackupLockGuard, Error> {
open_backup_lockfile(ENCRYPTION_KEYS_CFG_LOCKFILE, None, true)
}
/// Load encryption key configuration from file.
pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> {
let content = proxmox_sys::fs::file_read_optional_string(ENCRYPTION_KEYS_CFG_FILENAME)?;
let content = content.unwrap_or_default();
let digest = openssl::sha::sha256(content.as_bytes());
let data = CONFIG.parse(ENCRYPTION_KEYS_CFG_FILENAME, &content)?;
Ok((data, digest))
}
/// Save given key configuration to file.
pub fn save_config(config: &SectionConfigData) -> Result<(), Error> {
let raw = CONFIG.write(ENCRYPTION_KEYS_CFG_FILENAME, config)?;
replace_backup_config(ENCRYPTION_KEYS_CFG_FILENAME, raw.as_bytes())
}
/// Shell completion helper to complete encryption key id's as found in the config.
pub fn complete_encryption_key_id(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
match config() {
Ok((data, _digest)) => data.sections.keys().map(|id| id.to_string()).collect(),
Err(_) => Vec::new(),
}
}
/// Load the encryption key from file.
///
/// Looks up the key in the config and tries to load it from the given file.
/// Upon loading, the config key fingerprint is compared to the one stored in the key
/// file. Fail to load archived keys if flag is set.
pub fn load_key_config(id: &str, fail_on_archived: bool) -> Result<KeyConfig, Error> {
let _lock = lock_config()?;
let (config, _digest) = config()?;
let key: CryptKey = config.lookup(ENCRYPTION_KEYS_CFG_TYPE_ID, id)?;
if fail_on_archived && key.archived_at.is_some() {
bail!("cannot load archived encryption key {id}");
}
let key_config = match &key.info.path {
Some(path) => KeyConfig::load(path)?,
None => bail!("missing path for encryption key {id}"),
};
let stored_key_info = KeyInfo::from(&key_config);
if key.info.fingerprint != stored_key_info.fingerprint {
bail!("loaded key does not match the config for key {id}");
}
Ok(key_config)
}
/// Store the encryption key to file.
///
/// Inserts the key in the config and stores it to the given file.
pub fn store_key(id: &str, key: &KeyConfig) -> Result<(), Error> {
let _lock = lock_config()?;
let (mut config, _digest) = config()?;
if config.sections.contains_key(id) {
bail!("key with id '{id}' already exists.");
}
let backup_user = crate::backup_user()?;
let dir_options = CreateOptions::new()
.perm(Mode::from_bits_truncate(0o0750))
.owner(Uid::from_raw(0))
.group(backup_user.gid);
proxmox_sys::fs::ensure_dir_exists(ENCRYPTION_KEYS_DIR, &dir_options, true)?;
let key_path = format!("{ENCRYPTION_KEYS_DIR}{id}.enc");
let key_lock_path = format!("{key_path}.lck");
// lock to avoid race with key deletion
let _lock = open_backup_lockfile(&key_lock_path, None, true)?;
// assert the key file is empty or does not exist
match std::fs::metadata(&key_path) {
Ok(metadata) => {
if metadata.len() > 0 {
bail!("detected pre-existing key file, refusing to overwrite.");
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
}
let keyfile_mode = nix::sys::stat::Mode::from_bits_truncate(0o0640);
key.store_with(
&key_path,
true,
Some(keyfile_mode),
Some(Uid::from_raw(0)),
Some(backup_user.gid),
)?;
let mut info = KeyInfo::from(key);
info.path = Some(key_path.clone());
let crypt_key = CryptKey {
id: id.to_string(),
info,
archived_at: None,
};
let result = proxmox_lang::try_block!({
config.set_data(id, ENCRYPTION_KEYS_CFG_TYPE_ID, crypt_key)?;
save_config(&config)
});
if result.is_err() {
let _ = std::fs::remove_file(key_path);
}
result
}
/// Delete the encryption key from config.
///
/// Returns true if the key was removed successfully, false if there was no matching key.
/// Safety: caller must acquire and hold config lock.
pub fn delete_key(id: &str, mut config: SectionConfigData) -> Result<bool, Error> {
if let Some((_, key)) = config.sections.remove(id) {
let key =
CryptKey::deserialize(key).map_err(|_err| format_err!("failed to parse key config"))?;
if key.archived_at.is_none() {
bail!("key still active, deleting is only possible for archived keys");
}
if let Some(key_path) = &key.info.path {
let key_lock_path = format!("{key_path}.lck");
// Avoid races with key insertion
let _lock = open_backup_lockfile(key_lock_path, None, true)?;
let key_config = KeyConfig::load(key_path)?;
let stored_key_info = KeyInfo::from(&key_config);
// Check the key is the expected one
if key.info.fingerprint != stored_key_info.fingerprint {
bail!("unexpected key detected in key file, refuse to delete");
}
let raw = CONFIG.write(ENCRYPTION_KEYS_CFG_FILENAME, &config)?;
// drops config lock
replace_backup_config(ENCRYPTION_KEYS_CFG_FILENAME, raw.as_bytes())?;
std::fs::remove_file(key_path)?;
return Ok(true);
}
bail!("missing key file path for key '{id}'");
}
Ok(false)
}

View File

@ -4,11 +4,9 @@ pub use cached_user_info::CachedUserInfo;
pub mod datastore;
pub mod domains;
pub mod drive;
pub mod encryption_keys;
pub mod key_value;
pub mod media_pool;
pub mod metrics;
pub mod node;
pub mod network;
pub mod notifications;
pub mod prune;
pub mod remote;
@ -23,8 +21,7 @@ pub mod verify;
mod config_version_cache;
pub use config_version_cache::ConfigVersionCache;
use anyhow::{bail, format_err, Error};
use hex::FromHex;
use anyhow::{format_err, Error};
use nix::unistd::{Gid, Group, Uid, User};
use proxmox_sys::fs::DirLockGuard;
use std::os::unix::prelude::AsRawFd;
@ -51,16 +48,6 @@ pub fn backup_group() -> Result<nix::unistd::Group, Error> {
}
}
/// Return User info for root
pub fn priv_user() -> Result<nix::unistd::User, Error> {
if cfg!(test) {
Ok(User::from_uid(Uid::current())?.expect("current user does not exist"))
} else {
User::from_name("root")?.ok_or_else(|| format_err!("Unable to lookup superuser."))
}
}
#[must_use = "lock guard must be used to keep file locked"]
pub struct BackupLockGuard {
file: Option<std::fs::File>,
// TODO: Remove `_legacy_dir` with PBS 5
@ -153,19 +140,3 @@ pub fn replace_secret_config<P: AsRef<std::path::Path>>(path: P, data: &[u8]) ->
Ok(())
}
/// Detect modified configuration files
///
/// This function fails with a reasonable error message if checksums do not match.
pub fn detect_modified_configuration_file<T: AsRef<str>>(
digest_str: Option<T>,
expected_digest: &[u8; 32],
) -> Result<(), Error> {
if let Some(digest_str) = digest_str {
let digest = <[u8; 32]>::from_hex(digest_str.as_ref())?;
if &digest != expected_digest {
bail!("detected modified configuration - file changed by other user? Try again.");
}
}
Ok(())
}

View File

@ -0,0 +1,223 @@
use std::collections::HashMap;
use std::os::unix::io::AsRawFd;
use std::path::Path;
use std::process::Command;
use std::sync::LazyLock;
use anyhow::{bail, format_err, Error};
use const_format::concatcp;
use nix::ioctl_read_bad;
use nix::sys::socket::{socket, AddressFamily, SockFlag, SockType};
use regex::Regex;
use pbs_api_types::*; // for IP macros
pub static IPV4_REVERSE_MASK: &[&str] = &[
"0.0.0.0",
"128.0.0.0",
"192.0.0.0",
"224.0.0.0",
"240.0.0.0",
"248.0.0.0",
"252.0.0.0",
"254.0.0.0",
"255.0.0.0",
"255.128.0.0",
"255.192.0.0",
"255.224.0.0",
"255.240.0.0",
"255.248.0.0",
"255.252.0.0",
"255.254.0.0",
"255.255.0.0",
"255.255.128.0",
"255.255.192.0",
"255.255.224.0",
"255.255.240.0",
"255.255.248.0",
"255.255.252.0",
"255.255.254.0",
"255.255.255.0",
"255.255.255.128",
"255.255.255.192",
"255.255.255.224",
"255.255.255.240",
"255.255.255.248",
"255.255.255.252",
"255.255.255.254",
"255.255.255.255",
];
pub static IPV4_MASK_HASH_LOCALNET: LazyLock<HashMap<&'static str, u8>> = LazyLock::new(|| {
let mut map = HashMap::new();
#[allow(clippy::needless_range_loop)]
for i in 0..IPV4_REVERSE_MASK.len() {
map.insert(IPV4_REVERSE_MASK[i], i as u8);
}
map
});
pub fn parse_cidr(cidr: &str) -> Result<(String, u8, bool), Error> {
let (address, mask, is_v6) = parse_address_or_cidr(cidr)?;
if let Some(mask) = mask {
Ok((address, mask, is_v6))
} else {
bail!("missing netmask in '{}'", cidr);
}
}
pub fn check_netmask(mask: u8, is_v6: bool) -> Result<(), Error> {
let (ver, min, max) = if is_v6 {
("IPv6", 1, 128)
} else {
("IPv4", 1, 32)
};
if !(mask >= min && mask <= max) {
bail!(
"{} mask '{}' is out of range ({}..{}).",
ver,
mask,
min,
max
);
}
Ok(())
}
// parse ip address with optional cidr mask
pub fn parse_address_or_cidr(cidr: &str) -> Result<(String, Option<u8>, bool), Error> {
// NOTE: This is NOT the same regex as in proxmox-schema as this one has capture groups for
// the addresses vs cidr portions!
pub static CIDR_V4_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(concatcp!(r"^(", IPV4RE_STR, r")(?:/(\d{1,2}))?$")).unwrap());
pub static CIDR_V6_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(concatcp!(r"^(", IPV6RE_STR, r")(?:/(\d{1,3}))?$")).unwrap());
if let Some(caps) = CIDR_V4_REGEX.captures(cidr) {
let address = &caps[1];
if let Some(mask) = caps.get(2) {
let mask: u8 = mask.as_str().parse()?;
check_netmask(mask, false)?;
Ok((address.to_string(), Some(mask), false))
} else {
Ok((address.to_string(), None, false))
}
} else if let Some(caps) = CIDR_V6_REGEX.captures(cidr) {
let address = &caps[1];
if let Some(mask) = caps.get(2) {
let mask: u8 = mask.as_str().parse()?;
check_netmask(mask, true)?;
Ok((address.to_string(), Some(mask), true))
} else {
Ok((address.to_string(), None, true))
}
} else {
bail!("invalid address/mask '{}'", cidr);
}
}
pub fn get_network_interfaces() -> Result<HashMap<String, bool>, Error> {
const PROC_NET_DEV: &str = "/proc/net/dev";
#[repr(C)]
pub struct ifreq {
ifr_name: [libc::c_uchar; libc::IFNAMSIZ],
ifru_flags: libc::c_short,
}
ioctl_read_bad!(get_interface_flags, libc::SIOCGIFFLAGS, ifreq);
static IFACE_LINE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*([^:\s]+):").unwrap());
let raw = std::fs::read_to_string(PROC_NET_DEV)
.map_err(|err| format_err!("unable to read {} - {}", PROC_NET_DEV, err))?;
let lines = raw.lines();
let sock = socket(
AddressFamily::Inet,
SockType::Datagram,
SockFlag::empty(),
None,
)
.or_else(|_| {
socket(
AddressFamily::Inet6,
SockType::Datagram,
SockFlag::empty(),
None,
)
})?;
let mut interface_list = HashMap::new();
for line in lines {
if let Some(cap) = IFACE_LINE_REGEX.captures(line) {
let ifname = &cap[1];
let mut req = ifreq {
ifr_name: *b"0000000000000000",
ifru_flags: 0,
};
for (i, b) in std::ffi::CString::new(ifname)?
.as_bytes_with_nul()
.iter()
.enumerate()
{
if i < (libc::IFNAMSIZ - 1) {
req.ifr_name[i] = *b as libc::c_uchar;
}
}
let res = unsafe { get_interface_flags(sock.as_raw_fd(), &mut req)? };
if res != 0 {
bail!(
"ioctl get_interface_flags for '{}' failed ({})",
ifname,
res
);
}
let is_up = (req.ifru_flags & (libc::IFF_UP as libc::c_short)) != 0;
interface_list.insert(ifname.to_string(), is_up);
}
}
Ok(interface_list)
}
pub fn compute_file_diff(filename: &str, shadow: &str) -> Result<String, Error> {
let output = Command::new("diff")
.arg("-b")
.arg("-u")
.arg(filename)
.arg(shadow)
.output()
.map_err(|err| format_err!("failed to execute diff - {}", err))?;
let diff = proxmox_sys::command::command_output_as_string(output, Some(|c| c == 0 || c == 1))
.map_err(|err| format_err!("diff failed: {}", err))?;
Ok(diff)
}
pub fn assert_ifupdown2_installed() -> Result<(), Error> {
if !Path::new("/usr/share/ifupdown2").exists() {
bail!("ifupdown2 is not installed.");
}
Ok(())
}
pub fn network_reload() -> Result<(), Error> {
let output = Command::new("ifreload")
.arg("-a")
.output()
.map_err(|err| format_err!("failed to execute 'ifreload' - {}", err))?;
proxmox_sys::command::command_output(output, None)
.map_err(|err| format_err!("ifreload failed: {}", err))?;
Ok(())
}

View File

@ -0,0 +1,136 @@
use std::collections::{HashMap, VecDeque};
use std::io::BufRead;
use std::iter::Iterator;
use std::sync::LazyLock;
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Token {
Text,
Comment,
DHCP,
Newline,
Address,
Auto,
Gateway,
Inet,
Inet6,
Iface,
Loopback,
Manual,
Netmask,
Static,
Attribute,
MTU,
BridgePorts,
BridgeVlanAware,
VlanId,
VlanRawDevice,
BondSlaves,
BondMode,
BondPrimary,
BondXmitHashPolicy,
EOF,
}
static KEYWORDS: LazyLock<HashMap<&'static str, Token>> = LazyLock::new(|| {
let mut map = HashMap::new();
map.insert("address", Token::Address);
map.insert("auto", Token::Auto);
map.insert("dhcp", Token::DHCP);
map.insert("gateway", Token::Gateway);
map.insert("inet", Token::Inet);
map.insert("inet6", Token::Inet6);
map.insert("iface", Token::Iface);
map.insert("loopback", Token::Loopback);
map.insert("manual", Token::Manual);
map.insert("netmask", Token::Netmask);
map.insert("static", Token::Static);
map.insert("mtu", Token::MTU);
map.insert("bridge-ports", Token::BridgePorts);
map.insert("bridge_ports", Token::BridgePorts);
map.insert("bridge-vlan-aware", Token::BridgeVlanAware);
map.insert("bridge_vlan_aware", Token::BridgeVlanAware);
map.insert("vlan-id", Token::VlanId);
map.insert("vlan_id", Token::VlanId);
map.insert("vlan-raw-device", Token::VlanRawDevice);
map.insert("vlan_raw_device", Token::VlanRawDevice);
map.insert("bond-slaves", Token::BondSlaves);
map.insert("bond_slaves", Token::BondSlaves);
map.insert("bond-mode", Token::BondMode);
map.insert("bond-primary", Token::BondPrimary);
map.insert("bond_primary", Token::BondPrimary);
map.insert("bond_xmit_hash_policy", Token::BondXmitHashPolicy);
map.insert("bond-xmit-hash-policy", Token::BondXmitHashPolicy);
map
});
pub struct Lexer<R> {
input: R,
eof_count: usize,
cur_line: Option<VecDeque<(Token, String)>>,
}
impl<R: BufRead> Lexer<R> {
pub fn new(input: R) -> Self {
Self {
input,
eof_count: 0,
cur_line: None,
}
}
fn split_line(line: &str) -> VecDeque<(Token, String)> {
if let Some(comment) = line.strip_prefix('#') {
let mut res = VecDeque::new();
res.push_back((Token::Comment, comment.trim().to_string()));
return res;
}
let mut list: VecDeque<(Token, String)> = line
.split_ascii_whitespace()
.map(|text| {
let token = KEYWORDS.get(text).unwrap_or(&Token::Text);
(*token, text.to_string())
})
.collect();
if line.starts_with(|c: char| c.is_ascii_whitespace() && c != '\n') {
list.push_front((Token::Attribute, String::from("\t")));
}
list
}
}
impl<R: BufRead> Iterator for Lexer<R> {
type Item = Result<(Token, String), std::io::Error>;
fn next(&mut self) -> Option<Self::Item> {
if self.cur_line.is_none() {
let mut line = String::new();
match self.input.read_line(&mut line) {
Err(err) => return Some(Err(err)),
Ok(0) => {
self.eof_count += 1;
if self.eof_count == 1 {
return Some(Ok((Token::EOF, String::new())));
}
return None;
}
_ => {}
}
self.cur_line = Some(Self::split_line(&line));
}
match self.cur_line {
Some(ref mut cur_line) => {
if cur_line.is_empty() {
self.cur_line = None;
Some(Ok((Token::Newline, String::from("\n"))))
} else {
let (token, text) = cur_line.pop_front().unwrap();
Some(Ok((token, text)))
}
}
None => None,
}
}
}

View File

@ -0,0 +1,687 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Write;
use std::sync::LazyLock;
use anyhow::{bail, format_err, Error};
use regex::Regex;
use serde::de::{value, Deserialize, IntoDeserializer};
use proxmox_sys::{fs::replace_file, fs::CreateOptions};
mod helper;
pub use helper::*;
mod lexer;
pub use lexer::*;
mod parser;
pub use parser::*;
use pbs_api_types::{
BondXmitHashPolicy, Interface, LinuxBondMode, NetworkConfigMethod, NetworkInterfaceType,
};
use crate::{open_backup_lockfile, BackupLockGuard};
static PHYSICAL_NIC_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:eth\d+|en[^:.]+|ib\d+)$").unwrap());
static VLAN_INTERFACE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?P<vlan_raw_device>\S+)\.(?P<vlan_id>\d+)|vlan(?P<vlan_id2>\d+)$").unwrap()
});
pub fn is_physical_nic(iface: &str) -> bool {
PHYSICAL_NIC_REGEX.is_match(iface)
}
pub fn bond_mode_from_str(s: &str) -> Result<LinuxBondMode, Error> {
LinuxBondMode::deserialize(s.into_deserializer())
.map_err(|_: value::Error| format_err!("invalid bond_mode '{}'", s))
}
pub fn bond_xmit_hash_policy_from_str(s: &str) -> Result<BondXmitHashPolicy, Error> {
BondXmitHashPolicy::deserialize(s.into_deserializer())
.map_err(|_: value::Error| format_err!("invalid bond_xmit_hash_policy '{}'", s))
}
pub fn parse_vlan_id_from_name(iface_name: &str) -> Option<u16> {
VLAN_INTERFACE_REGEX.captures(iface_name).and_then(|cap| {
cap.name("vlan_id")
.or(cap.name("vlan_id2"))
.and_then(|id| id.as_str().parse::<u16>().ok())
})
}
pub fn parse_vlan_raw_device_from_name(iface_name: &str) -> Option<&str> {
VLAN_INTERFACE_REGEX
.captures(iface_name)
.and_then(|cap| cap.name("vlan_raw_device"))
.map(Into::into)
}
// Write attributes not depending on address family
fn write_iface_attributes(iface: &Interface, w: &mut dyn Write) -> Result<(), Error> {
static EMPTY_LIST: Vec<String> = Vec::new();
match iface.interface_type {
NetworkInterfaceType::Bridge => {
if let Some(true) = iface.bridge_vlan_aware {
writeln!(w, "\tbridge-vlan-aware yes")?;
}
let ports = iface.bridge_ports.as_ref().unwrap_or(&EMPTY_LIST);
if ports.is_empty() {
writeln!(w, "\tbridge-ports none")?;
} else {
writeln!(w, "\tbridge-ports {}", ports.join(" "))?;
}
}
NetworkInterfaceType::Bond => {
let mode = iface.bond_mode.unwrap_or(LinuxBondMode::BalanceRr);
writeln!(w, "\tbond-mode {mode}")?;
if let Some(primary) = &iface.bond_primary {
if mode == LinuxBondMode::ActiveBackup {
writeln!(w, "\tbond-primary {}", primary)?;
}
}
if let Some(xmit_policy) = &iface.bond_xmit_hash_policy {
if mode == LinuxBondMode::Ieee802_3ad || mode == LinuxBondMode::BalanceXor {
writeln!(w, "\tbond_xmit_hash_policy {xmit_policy}")?;
}
}
let slaves = iface.slaves.as_ref().unwrap_or(&EMPTY_LIST);
if slaves.is_empty() {
writeln!(w, "\tbond-slaves none")?;
} else {
writeln!(w, "\tbond-slaves {}", slaves.join(" "))?;
}
}
NetworkInterfaceType::Vlan => {
if let Some(vlan_id) = iface.vlan_id {
writeln!(w, "\tvlan-id {vlan_id}")?;
}
if let Some(vlan_raw_device) = &iface.vlan_raw_device {
writeln!(w, "\tvlan-raw-device {vlan_raw_device}")?;
}
}
_ => {}
}
if let Some(mtu) = iface.mtu {
writeln!(w, "\tmtu {}", mtu)?;
}
Ok(())
}
// Write attributes depending on address family inet (IPv4)
fn write_iface_attributes_v4(
iface: &Interface,
w: &mut dyn Write,
method: NetworkConfigMethod,
) -> Result<(), Error> {
if method == NetworkConfigMethod::Static {
if let Some(address) = &iface.cidr {
writeln!(w, "\taddress {}", address)?;
}
if let Some(gateway) = &iface.gateway {
writeln!(w, "\tgateway {}", gateway)?;
}
}
for option in &iface.options {
writeln!(w, "\t{}", option)?;
}
if let Some(ref comments) = iface.comments {
for comment in comments.lines() {
writeln!(w, "#{}", comment)?;
}
}
Ok(())
}
/// Write attributes depending on address family inet6 (IPv6)
fn write_iface_attributes_v6(
iface: &Interface,
w: &mut dyn Write,
method: NetworkConfigMethod,
) -> Result<(), Error> {
if method == NetworkConfigMethod::Static {
if let Some(address) = &iface.cidr6 {
writeln!(w, "\taddress {}", address)?;
}
if let Some(gateway) = &iface.gateway6 {
writeln!(w, "\tgateway {}", gateway)?;
}
}
for option in &iface.options6 {
writeln!(w, "\t{}", option)?;
}
if let Some(ref comments) = iface.comments6 {
for comment in comments.lines() {
writeln!(w, "#{}", comment)?;
}
}
Ok(())
}
fn write_iface(iface: &Interface, w: &mut dyn Write) -> Result<(), Error> {
fn method_to_str(method: NetworkConfigMethod) -> &'static str {
match method {
NetworkConfigMethod::Static => "static",
NetworkConfigMethod::Loopback => "loopback",
NetworkConfigMethod::Manual => "manual",
NetworkConfigMethod::DHCP => "dhcp",
}
}
if iface.method.is_none() && iface.method6.is_none() {
return Ok(());
}
if iface.autostart {
writeln!(w, "auto {}", iface.name)?;
}
if let Some(method) = iface.method {
writeln!(w, "iface {} inet {}", iface.name, method_to_str(method))?;
write_iface_attributes_v4(iface, w, method)?;
write_iface_attributes(iface, w)?;
writeln!(w)?;
}
if let Some(method6) = iface.method6 {
let mut skip_v6 = false; // avoid empty inet6 manual entry
if iface.method.is_some()
&& method6 == NetworkConfigMethod::Manual
&& iface.comments6.is_none()
&& iface.options6.is_empty()
{
skip_v6 = true;
}
if !skip_v6 {
writeln!(w, "iface {} inet6 {}", iface.name, method_to_str(method6))?;
write_iface_attributes_v6(iface, w, method6)?;
if iface.method.is_none() {
// only write common attributes once
write_iface_attributes(iface, w)?;
}
writeln!(w)?;
}
}
Ok(())
}
#[derive(Debug)]
enum NetworkOrderEntry {
Iface(String),
Comment(String),
Option(String),
}
#[derive(Debug, Default)]
pub struct NetworkConfig {
pub interfaces: BTreeMap<String, Interface>,
order: Vec<NetworkOrderEntry>,
}
impl TryFrom<NetworkConfig> for String {
type Error = Error;
fn try_from(config: NetworkConfig) -> Result<Self, Self::Error> {
let mut output = Vec::new();
config.write_config(&mut output)?;
let res = String::from_utf8(output)?;
Ok(res)
}
}
impl NetworkConfig {
pub fn new() -> Self {
Self {
interfaces: BTreeMap::new(),
order: Vec::new(),
}
}
pub fn lookup(&self, name: &str) -> Result<&Interface, Error> {
let interface = self
.interfaces
.get(name)
.ok_or_else(|| format_err!("interface '{}' does not exist.", name))?;
Ok(interface)
}
pub fn lookup_mut(&mut self, name: &str) -> Result<&mut Interface, Error> {
let interface = self
.interfaces
.get_mut(name)
.ok_or_else(|| format_err!("interface '{}' does not exist.", name))?;
Ok(interface)
}
/// Check if ports are used only once
fn check_port_usage(&self) -> Result<(), Error> {
let mut used_ports = HashMap::new();
let mut check_port_usage = |iface, ports: &Vec<String>| {
for port in ports.iter() {
if let Some(prev_iface) = used_ports.get(port) {
bail!(
"iface '{}' port '{}' is already used on interface '{}'",
iface,
port,
prev_iface
);
}
used_ports.insert(port.to_string(), iface);
}
Ok(())
};
for (iface, interface) in self.interfaces.iter() {
if let Some(ports) = &interface.bridge_ports {
check_port_usage(iface, ports)?;
}
if let Some(slaves) = &interface.slaves {
check_port_usage(iface, slaves)?;
}
}
Ok(())
}
/// Check if child mtu is less or equal than parent mtu
fn check_mtu(&self, parent_name: &str, child_name: &str) -> Result<(), Error> {
let parent = self
.interfaces
.get(parent_name)
.ok_or_else(|| format_err!("check_mtu - missing parent interface '{}'", parent_name))?;
let child = self
.interfaces
.get(child_name)
.ok_or_else(|| format_err!("check_mtu - missing child interface '{}'", child_name))?;
let child_mtu = match child.mtu {
Some(mtu) => mtu,
None => return Ok(()),
};
let parent_mtu = match parent.mtu {
Some(mtu) => mtu,
None => {
if parent.interface_type == NetworkInterfaceType::Bond {
child_mtu
} else {
1500
}
}
};
if parent_mtu < child_mtu {
bail!(
"interface '{}' - mtu {} is lower than '{}' - mtu {}\n",
parent_name,
parent_mtu,
child_name,
child_mtu
);
}
Ok(())
}
/// Check if bond slaves exists
fn check_bond_slaves(&self) -> Result<(), Error> {
for (iface, interface) in self.interfaces.iter() {
if let Some(slaves) = &interface.slaves {
for slave in slaves.iter() {
match self.interfaces.get(slave) {
Some(entry) => {
if entry.interface_type != NetworkInterfaceType::Eth {
bail!(
"bond '{}' - wrong interface type on slave '{}' ({:?} != {:?})",
iface,
slave,
entry.interface_type,
NetworkInterfaceType::Eth
);
}
}
None => {
bail!("bond '{}' - unable to find slave '{}'", iface, slave);
}
}
self.check_mtu(iface, slave)?;
}
}
}
Ok(())
}
/// Check if bridge ports exists
fn check_bridge_ports(&self) -> Result<(), Error> {
static VLAN_INTERFACE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(\S+)\.(\d+)$").unwrap());
for (iface, interface) in self.interfaces.iter() {
if let Some(ports) = &interface.bridge_ports {
for port in ports.iter() {
let captures = VLAN_INTERFACE_REGEX.captures(port);
let port = if let Some(ref caps) = captures {
&caps[1]
} else {
port.as_str()
};
if !self.interfaces.contains_key(port) {
bail!("bridge '{}' - unable to find port '{}'", iface, port);
}
self.check_mtu(iface, port)?;
}
}
}
Ok(())
}
fn write_config(&self, w: &mut dyn Write) -> Result<(), Error> {
self.check_port_usage()?;
self.check_bond_slaves()?;
self.check_bridge_ports()?;
let mut done = HashSet::new();
let mut last_entry_was_comment = false;
for entry in self.order.iter() {
match entry {
NetworkOrderEntry::Comment(comment) => {
writeln!(w, "#{}", comment)?;
last_entry_was_comment = true;
}
NetworkOrderEntry::Option(option) => {
if last_entry_was_comment {
writeln!(w)?;
}
last_entry_was_comment = false;
writeln!(w, "{}", option)?;
writeln!(w)?;
}
NetworkOrderEntry::Iface(name) => {
let interface = match self.interfaces.get(name) {
Some(interface) => interface,
None => continue,
};
if last_entry_was_comment {
writeln!(w)?;
}
last_entry_was_comment = false;
if done.contains(name) {
continue;
}
done.insert(name);
write_iface(interface, w)?;
}
}
}
for (name, interface) in &self.interfaces {
if done.contains(name) {
continue;
}
write_iface(interface, w)?;
}
Ok(())
}
}
pub const NETWORK_INTERFACES_FILENAME: &str = "/etc/network/interfaces";
pub const NETWORK_INTERFACES_NEW_FILENAME: &str = "/etc/network/interfaces.new";
pub const NETWORK_LOCKFILE: &str = "/var/lock/pve-network.lck";
pub fn lock_config() -> Result<BackupLockGuard, Error> {
open_backup_lockfile(NETWORK_LOCKFILE, None, true)
}
pub fn config() -> Result<(NetworkConfig, [u8; 32]), Error> {
let content =
match proxmox_sys::fs::file_get_optional_contents(NETWORK_INTERFACES_NEW_FILENAME)? {
Some(content) => content,
None => {
let content =
proxmox_sys::fs::file_get_optional_contents(NETWORK_INTERFACES_FILENAME)?;
content.unwrap_or_default()
}
};
let digest = openssl::sha::sha256(&content);
let existing_interfaces = get_network_interfaces()?;
let mut parser = NetworkParser::new(&content[..]);
let data = parser.parse_interfaces(Some(&existing_interfaces))?;
Ok((data, digest))
}
pub fn changes() -> Result<String, Error> {
if !std::path::Path::new(NETWORK_INTERFACES_NEW_FILENAME).exists() {
return Ok(String::new());
}
compute_file_diff(NETWORK_INTERFACES_FILENAME, NETWORK_INTERFACES_NEW_FILENAME)
}
pub fn save_config(config: &NetworkConfig) -> Result<(), Error> {
let mut raw = Vec::new();
config.write_config(&mut raw)?;
let mode = nix::sys::stat::Mode::from_bits_truncate(0o0644);
// set the correct owner/group/permissions while saving file
// owner(rw) = root, group(r)=root, others(r)
let options = CreateOptions::new()
.perm(mode)
.owner(nix::unistd::ROOT)
.group(nix::unistd::Gid::from_raw(0));
replace_file(NETWORK_INTERFACES_NEW_FILENAME, &raw, options, true)?;
Ok(())
}
// shell completion helper
pub fn complete_interface_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
match config() {
Ok((data, _digest)) => data.interfaces.keys().map(|id| id.to_string()).collect(),
Err(_) => Vec::new(),
}
}
pub fn complete_port_list(arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
let mut ports = Vec::new();
match config() {
Ok((data, _digest)) => {
for (iface, interface) in data.interfaces.iter() {
if interface.interface_type == NetworkInterfaceType::Eth {
ports.push(iface.to_string());
}
}
}
Err(_) => return Vec::new(),
};
let arg = arg.trim();
let prefix = if let Some(idx) = arg.rfind(',') {
&arg[..idx + 1]
} else {
""
};
ports
.iter()
.map(|port| format!("{}{}", prefix, port))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use NetworkConfigMethod::*;
use NetworkInterfaceType::*;
use NetworkOrderEntry::*;
#[test]
fn test_write_network_config_manual() {
let iface_name = String::from("enp3s0");
let mut iface = Interface::new(iface_name.clone());
iface.interface_type = Eth;
iface.method = Some(Manual);
iface.active = true;
let nw_config = NetworkConfig {
interfaces: BTreeMap::from([(iface_name.clone(), iface)]),
order: vec![Iface(iface_name.clone())],
};
assert_eq!(
String::try_from(nw_config).unwrap().trim(),
r#"iface enp3s0 inet manual"#
);
}
#[test]
fn test_write_network_config_static() {
let iface_name = String::from("enp3s0");
let mut iface = Interface::new(iface_name.clone());
iface.interface_type = Eth;
iface.method = Some(Static);
iface.cidr = Some(String::from("10.0.0.100/16"));
iface.active = true;
let nw_config = NetworkConfig {
interfaces: BTreeMap::from([(iface_name.clone(), iface)]),
order: vec![Iface(iface_name.clone())],
};
assert_eq!(
String::try_from(nw_config).unwrap().trim(),
r#"
iface enp3s0 inet static
address 10.0.0.100/16"#
.to_string()
.trim()
);
}
#[test]
fn test_write_network_config_static_with_gateway() {
let iface_name = String::from("enp3s0");
let mut iface = Interface::new(iface_name.clone());
iface.interface_type = Eth;
iface.method = Some(Static);
iface.cidr = Some(String::from("10.0.0.100/16"));
iface.gateway = Some(String::from("10.0.0.1"));
iface.active = true;
let nw_config = NetworkConfig {
interfaces: BTreeMap::from([(iface_name.clone(), iface)]),
order: vec![Iface(iface_name.clone())],
};
assert_eq!(
String::try_from(nw_config).unwrap().trim(),
r#"
iface enp3s0 inet static
address 10.0.0.100/16
gateway 10.0.0.1"#
.to_string()
.trim()
);
}
#[test]
fn test_write_network_config_vlan_id_in_name() {
let iface_name = String::from("vmbr0.100");
let mut iface = Interface::new(iface_name.clone());
iface.interface_type = Vlan;
iface.method = Some(Manual);
iface.active = true;
let nw_config = NetworkConfig {
interfaces: BTreeMap::from([(iface_name.clone(), iface)]),
order: vec![Iface(iface_name.clone())],
};
assert_eq!(
String::try_from(nw_config).unwrap().trim(),
"iface vmbr0.100 inet manual"
);
}
#[test]
fn test_write_network_config_vlan_with_raw_device() {
let iface_name = String::from("vlan100");
let mut iface = Interface::new(iface_name.clone());
iface.interface_type = Vlan;
iface.vlan_raw_device = Some(String::from("vmbr0"));
iface.method = Some(Manual);
iface.active = true;
let nw_config = NetworkConfig {
interfaces: BTreeMap::from([(iface_name.clone(), iface)]),
order: vec![Iface(iface_name.clone())],
};
assert_eq!(
String::try_from(nw_config).unwrap().trim(),
r#"
iface vlan100 inet manual
vlan-raw-device vmbr0"#
.trim()
);
}
#[test]
fn test_write_network_config_vlan_with_individual_name() {
let iface_name = String::from("individual_name");
let mut iface = Interface::new(iface_name.clone());
iface.interface_type = Vlan;
iface.vlan_raw_device = Some(String::from("vmbr0"));
iface.vlan_id = Some(100);
iface.method = Some(Manual);
iface.active = true;
let nw_config = NetworkConfig {
interfaces: BTreeMap::from([(iface_name.clone(), iface)]),
order: vec![Iface(iface_name.clone())],
};
assert_eq!(
String::try_from(nw_config).unwrap().trim(),
r#"
iface individual_name inet manual
vlan-id 100
vlan-raw-device vmbr0"#
.trim()
);
}
#[test]
fn test_vlan_parse_vlan_id_from_name() {
assert_eq!(parse_vlan_id_from_name("vlan100"), Some(100));
assert_eq!(parse_vlan_id_from_name("vlan"), None);
assert_eq!(parse_vlan_id_from_name("arbitrary"), None);
assert_eq!(parse_vlan_id_from_name("vmbr0.100"), Some(100));
assert_eq!(parse_vlan_id_from_name("vmbr0"), None);
// assert_eq!(parse_vlan_id_from_name("vmbr0.1.400"), Some(400)); // NOTE ifupdown2 does actually support this
}
#[test]
fn test_vlan_parse_vlan_raw_device_from_name() {
assert_eq!(parse_vlan_raw_device_from_name("vlan100"), None);
assert_eq!(parse_vlan_raw_device_from_name("arbitrary"), None);
assert_eq!(parse_vlan_raw_device_from_name("vmbr0"), None);
assert_eq!(parse_vlan_raw_device_from_name("vmbr0.200"), Some("vmbr0"));
}
}

View File

@ -0,0 +1,846 @@
use crate::network::VLAN_INTERFACE_REGEX;
use std::collections::{HashMap, HashSet};
use std::io::BufRead;
use std::iter::{Iterator, Peekable};
use std::sync::LazyLock;
use anyhow::{bail, format_err, Error};
use regex::Regex;
use super::helper::*;
use super::lexer::*;
use super::{
bond_mode_from_str, bond_xmit_hash_policy_from_str, Interface, NetworkConfig,
NetworkConfigMethod, NetworkInterfaceType, NetworkOrderEntry,
};
fn set_method_v4(iface: &mut Interface, method: NetworkConfigMethod) -> Result<(), Error> {
if iface.method.is_none() {
iface.method = Some(method);
} else {
bail!("inet configuration method already set.");
}
Ok(())
}
fn set_method_v6(iface: &mut Interface, method: NetworkConfigMethod) -> Result<(), Error> {
if iface.method6.is_none() {
iface.method6 = Some(method);
} else {
bail!("inet6 configuration method already set.");
}
Ok(())
}
fn set_cidr_v4(iface: &mut Interface, address: String) -> Result<(), Error> {
if iface.cidr.is_none() {
iface.cidr = Some(address);
} else {
bail!("duplicate IPv4 address.");
}
Ok(())
}
fn set_gateway_v4(iface: &mut Interface, gateway: String) -> Result<(), Error> {
if iface.gateway.is_none() {
iface.gateway = Some(gateway);
} else {
bail!("duplicate IPv4 gateway.");
}
Ok(())
}
fn set_cidr_v6(iface: &mut Interface, address: String) -> Result<(), Error> {
if iface.cidr6.is_none() {
iface.cidr6 = Some(address);
} else {
bail!("duplicate IPv6 address.");
}
Ok(())
}
fn set_gateway_v6(iface: &mut Interface, gateway: String) -> Result<(), Error> {
if iface.gateway6.is_none() {
iface.gateway6 = Some(gateway);
} else {
bail!("duplicate IPv4 gateway.");
}
Ok(())
}
fn set_interface_type(
iface: &mut Interface,
interface_type: NetworkInterfaceType,
) -> Result<(), Error> {
if iface.interface_type == NetworkInterfaceType::Unknown {
iface.interface_type = interface_type;
} else if iface.interface_type != interface_type {
bail!(
"interface type already defined - cannot change from {:?} to {:?}",
iface.interface_type,
interface_type
);
}
Ok(())
}
pub struct NetworkParser<R: BufRead> {
input: Peekable<Lexer<R>>,
line_nr: usize,
}
impl<R: BufRead> NetworkParser<R> {
pub fn new(reader: R) -> Self {
let input = Lexer::new(reader).peekable();
Self { input, line_nr: 1 }
}
fn peek(&mut self) -> Result<Token, Error> {
match self.input.peek() {
Some(Err(err)) => {
bail!("input error - {}", err);
}
Some(Ok((token, _))) => Ok(*token),
None => {
bail!("got unexpected end of stream (inside peek)");
}
}
}
fn next(&mut self) -> Result<(Token, String), Error> {
match self.input.next() {
Some(Err(err)) => {
bail!("input error - {}", err);
}
Some(Ok((token, text))) => {
if token == Token::Newline {
self.line_nr += 1;
}
Ok((token, text))
}
None => {
bail!("got unexpected end of stream (inside peek)");
}
}
}
fn next_text(&mut self) -> Result<String, Error> {
match self.next()? {
(Token::Text, text) => Ok(text),
(unexpected, _) => bail!("got unexpected token {:?} (expecting Text)", unexpected),
}
}
fn eat(&mut self, expected: Token) -> Result<String, Error> {
let (next, text) = self.next()?;
if next != expected {
bail!("expected {:?}, got {:?}", expected, next);
}
Ok(text)
}
fn parse_auto(&mut self, auto_flag: &mut HashSet<String>) -> Result<(), Error> {
self.eat(Token::Auto)?;
loop {
match self.next()? {
(Token::Text, iface) => {
auto_flag.insert(iface.to_string());
}
(Token::Newline, _) => break,
unexpected => {
bail!("expected {:?}, got {:?}", Token::Text, unexpected);
}
}
}
Ok(())
}
fn parse_netmask(&mut self) -> Result<u8, Error> {
self.eat(Token::Netmask)?;
let netmask = self.next_text()?;
let mask = if let Some(mask) = IPV4_MASK_HASH_LOCALNET.get(netmask.as_str()) {
*mask
} else {
match netmask.as_str().parse::<u8>() {
Ok(mask) => mask,
Err(err) => {
bail!("unable to parse netmask '{}' - {}", netmask, err);
}
}
};
self.eat(Token::Newline)?;
Ok(mask)
}
fn parse_iface_address(&mut self) -> Result<(String, Option<u8>, bool), Error> {
self.eat(Token::Address)?;
let cidr = self.next_text()?;
let (_address, mask, ipv6) = parse_address_or_cidr(&cidr)?;
self.eat(Token::Newline)?;
Ok((cidr, mask, ipv6))
}
fn parse_iface_gateway(&mut self, interface: &mut Interface) -> Result<(), Error> {
self.eat(Token::Gateway)?;
let gateway = self.next_text()?;
if pbs_api_types::IP_REGEX.is_match(&gateway) {
if gateway.contains(':') {
set_gateway_v6(interface, gateway)?;
} else {
set_gateway_v4(interface, gateway)?;
}
} else {
bail!("unable to parse gateway address");
}
self.eat(Token::Newline)?;
Ok(())
}
fn parse_iface_mtu(&mut self) -> Result<u64, Error> {
self.eat(Token::MTU)?;
let mtu = self.next_text()?;
let mtu = match mtu.parse::<u64>() {
Ok(mtu) => mtu,
Err(err) => {
bail!("unable to parse mtu value '{}' - {}", mtu, err);
}
};
self.eat(Token::Newline)?;
Ok(mtu)
}
fn parse_yes_no(&mut self) -> Result<bool, Error> {
let text = self.next_text()?;
let value = match text.to_lowercase().as_str() {
"yes" => true,
"no" => false,
_ => {
bail!("unable to bool value '{}' - (expected yes/no)", text);
}
};
self.eat(Token::Newline)?;
Ok(value)
}
fn parse_to_eol(&mut self) -> Result<String, Error> {
let mut line = String::new();
loop {
match self.next()? {
(Token::Newline, _) => return Ok(line),
(_, text) => {
if !line.is_empty() {
line.push(' ');
}
line.push_str(&text);
}
}
}
}
fn parse_iface_list(&mut self) -> Result<Vec<String>, Error> {
let mut list = Vec::new();
loop {
let (token, text) = self.next()?;
match token {
Token::Newline => break,
Token::Text => {
if &text != "none" {
list.push(text);
}
}
_ => bail!(
"unable to parse interface list - unexpected token '{:?}'",
token
),
}
}
Ok(list)
}
fn parse_iface_attributes(
&mut self,
interface: &mut Interface,
address_family_v4: bool,
address_family_v6: bool,
) -> Result<(), Error> {
let mut netmask = None;
let mut address_list = Vec::new();
loop {
match self.peek()? {
Token::Attribute => {
self.eat(Token::Attribute)?;
}
Token::Comment => {
let comment = self.eat(Token::Comment)?;
if !address_family_v4 && address_family_v6 {
let mut comments = interface.comments6.take().unwrap_or_default();
if !comments.is_empty() {
comments.push('\n');
}
comments.push_str(&comment);
interface.comments6 = Some(comments);
} else {
let mut comments = interface.comments.take().unwrap_or_default();
if !comments.is_empty() {
comments.push('\n');
}
comments.push_str(&comment);
interface.comments = Some(comments);
}
self.eat(Token::Newline)?;
continue;
}
_ => break,
}
match self.peek()? {
Token::Address => {
let (cidr, mask, is_v6) = self.parse_iface_address()?;
address_list.push((cidr, mask, is_v6));
}
Token::Gateway => self.parse_iface_gateway(interface)?,
Token::Netmask => {
//Note: netmask is deprecated, but we try to do our best
netmask = Some(self.parse_netmask()?);
}
Token::MTU => {
let mtu = self.parse_iface_mtu()?;
interface.mtu = Some(mtu);
}
Token::BridgeVlanAware => {
self.eat(Token::BridgeVlanAware)?;
let bridge_vlan_aware = self.parse_yes_no()?;
interface.bridge_vlan_aware = Some(bridge_vlan_aware);
}
Token::BridgePorts => {
self.eat(Token::BridgePorts)?;
let ports = self.parse_iface_list()?;
interface.bridge_ports = Some(ports);
set_interface_type(interface, NetworkInterfaceType::Bridge)?;
}
Token::BondSlaves => {
self.eat(Token::BondSlaves)?;
let slaves = self.parse_iface_list()?;
interface.slaves = Some(slaves);
set_interface_type(interface, NetworkInterfaceType::Bond)?;
}
Token::BondMode => {
self.eat(Token::BondMode)?;
let mode = self.next_text()?;
interface.bond_mode = Some(bond_mode_from_str(&mode)?);
self.eat(Token::Newline)?;
}
Token::BondPrimary => {
self.eat(Token::BondPrimary)?;
let primary = self.next_text()?;
interface.bond_primary = Some(primary);
self.eat(Token::Newline)?;
}
Token::BondXmitHashPolicy => {
self.eat(Token::BondXmitHashPolicy)?;
let policy = bond_xmit_hash_policy_from_str(&self.next_text()?)?;
interface.bond_xmit_hash_policy = Some(policy);
self.eat(Token::Newline)?;
}
Token::VlanId => {
self.eat(Token::VlanId)?;
let vlan_id = self.next_text()?.parse()?;
interface.vlan_id = Some(vlan_id);
set_interface_type(interface, NetworkInterfaceType::Vlan)?;
self.eat(Token::Newline)?;
}
Token::VlanRawDevice => {
self.eat(Token::VlanRawDevice)?;
let vlan_raw_device = self.next_text()?;
interface.vlan_raw_device = Some(vlan_raw_device);
set_interface_type(interface, NetworkInterfaceType::Vlan)?;
self.eat(Token::Newline)?;
}
_ => {
// parse addon attributes
let option = self.parse_to_eol()?;
if !option.is_empty() {
if !address_family_v4 && address_family_v6 {
interface.options6.push(option);
} else {
interface.options.push(option);
}
};
}
}
}
#[allow(clippy::comparison_chain)]
if let Some(netmask) = netmask {
if address_list.len() > 1 {
bail!("unable to apply netmask to multiple addresses (please use cidr notation)");
} else if address_list.len() == 1 {
let (mut cidr, mask, is_v6) = address_list.pop().unwrap();
if mask.is_some() {
// address already has a mask - ignore netmask
} else {
use std::fmt::Write as _;
check_netmask(netmask, is_v6)?;
let _ = write!(cidr, "/{}", netmask);
}
if is_v6 {
set_cidr_v6(interface, cidr)?;
} else {
set_cidr_v4(interface, cidr)?;
}
} else {
// no address - simply ignore useless netmask
}
} else {
for (cidr, mask, is_v6) in address_list {
if mask.is_none() {
bail!("missing netmask in '{}'", cidr);
}
if is_v6 {
set_cidr_v6(interface, cidr)?;
} else {
set_cidr_v4(interface, cidr)?;
}
}
}
Ok(())
}
fn parse_iface(&mut self, config: &mut NetworkConfig) -> Result<(), Error> {
self.eat(Token::Iface)?;
let iface = self.next_text()?;
let mut address_family_v4 = false;
let mut address_family_v6 = false;
let mut config_method = None;
loop {
let (token, text) = self.next()?;
match token {
Token::Newline => break,
Token::Inet => address_family_v4 = true,
Token::Inet6 => address_family_v6 = true,
Token::Loopback => config_method = Some(NetworkConfigMethod::Loopback),
Token::Static => config_method = Some(NetworkConfigMethod::Static),
Token::Manual => config_method = Some(NetworkConfigMethod::Manual),
Token::DHCP => config_method = Some(NetworkConfigMethod::DHCP),
_ => bail!("unknown iface option {}", text),
}
}
let config_method = config_method.unwrap_or(NetworkConfigMethod::Static);
if !(address_family_v4 || address_family_v6) {
address_family_v4 = true;
address_family_v6 = true;
}
if let Some(interface) = config.interfaces.get_mut(&iface) {
if address_family_v4 {
set_method_v4(interface, config_method)?;
}
if address_family_v6 {
set_method_v6(interface, config_method)?;
}
self.parse_iface_attributes(interface, address_family_v4, address_family_v6)?;
} else {
let mut interface = Interface::new(iface.clone());
if address_family_v4 {
set_method_v4(&mut interface, config_method)?;
}
if address_family_v6 {
set_method_v6(&mut interface, config_method)?;
}
self.parse_iface_attributes(&mut interface, address_family_v4, address_family_v6)?;
config.interfaces.insert(interface.name.clone(), interface);
config.order.push(NetworkOrderEntry::Iface(iface));
}
Ok(())
}
pub fn parse_interfaces(
&mut self,
existing_interfaces: Option<&HashMap<String, bool>>,
) -> Result<NetworkConfig, Error> {
self.do_parse_interfaces(existing_interfaces)
.map_err(|err| format_err!("line {}: {}", self.line_nr, err))
}
fn do_parse_interfaces(
&mut self,
existing_interfaces: Option<&HashMap<String, bool>>,
) -> Result<NetworkConfig, Error> {
let mut config = NetworkConfig::new();
let mut auto_flag: HashSet<String> = HashSet::new();
loop {
match self.peek()? {
Token::EOF => {
break;
}
Token::Newline => {
// skip empty lines
self.eat(Token::Newline)?;
}
Token::Comment => {
let (_, text) = self.next()?;
config.order.push(NetworkOrderEntry::Comment(text));
self.eat(Token::Newline)?;
}
Token::Auto => {
self.parse_auto(&mut auto_flag)?;
}
Token::Iface => {
self.parse_iface(&mut config)?;
}
_ => {
let option = self.parse_to_eol()?;
if !option.is_empty() {
config.order.push(NetworkOrderEntry::Option(option));
}
}
}
}
for iface in auto_flag.iter() {
if let Some(interface) = config.interfaces.get_mut(iface) {
interface.autostart = true;
}
}
static INTERFACE_ALIAS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\S+:\d+$").unwrap());
if let Some(existing_interfaces) = existing_interfaces {
for (iface, active) in existing_interfaces.iter() {
if let Some(interface) = config.interfaces.get_mut(iface) {
interface.active = *active;
if interface.interface_type == NetworkInterfaceType::Unknown
&& super::is_physical_nic(iface)
{
interface.interface_type = NetworkInterfaceType::Eth;
}
} else if super::is_physical_nic(iface) {
// also add all physical NICs
let mut interface = Interface::new(iface.clone());
set_method_v4(&mut interface, NetworkConfigMethod::Manual)?;
interface.interface_type = NetworkInterfaceType::Eth;
interface.active = *active;
config.interfaces.insert(interface.name.clone(), interface);
config
.order
.push(NetworkOrderEntry::Iface(iface.to_string()));
}
}
}
for (name, interface) in config.interfaces.iter_mut() {
if interface.interface_type != NetworkInterfaceType::Unknown {
continue;
}
if name == "lo" {
interface.interface_type = NetworkInterfaceType::Loopback;
continue;
}
if INTERFACE_ALIAS_REGEX.is_match(name) {
interface.interface_type = NetworkInterfaceType::Alias;
continue;
}
if VLAN_INTERFACE_REGEX.is_match(name) {
interface.interface_type = NetworkInterfaceType::Vlan;
continue;
}
if super::is_physical_nic(name) {
interface.interface_type = NetworkInterfaceType::Eth;
continue;
}
}
if !config.interfaces.contains_key("lo") {
let mut interface = Interface::new(String::from("lo"));
set_method_v4(&mut interface, NetworkConfigMethod::Loopback)?;
interface.interface_type = NetworkInterfaceType::Loopback;
interface.autostart = true;
config.interfaces.insert(interface.name.clone(), interface);
// Note: insert 'lo' as first interface after initial comments
let mut new_order = Vec::new();
let mut added_lo = false;
for entry in config.order {
if added_lo {
new_order.push(entry);
continue;
} // copy the rest
match entry {
NetworkOrderEntry::Comment(_) => {
new_order.push(entry);
}
_ => {
new_order.push(NetworkOrderEntry::Iface(String::from("lo")));
added_lo = true;
new_order.push(entry);
}
}
}
config.order = new_order;
}
Ok(config)
}
}
#[cfg(test)]
mod test {
use anyhow::Error;
use super::*;
#[test]
fn test_network_config_create_lo_1() -> Result<(), Error> {
let input = "";
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None)?;
let output = String::try_from(config)?;
let expected = "auto lo\niface lo inet loopback\n\n";
assert_eq!(output, expected);
// run again using output as input
let mut parser = NetworkParser::new(output.as_bytes());
let config = parser.parse_interfaces(None)?;
let output = String::try_from(config)?;
assert_eq!(output, expected);
Ok(())
}
#[test]
fn test_network_config_create_lo_2() -> Result<(), Error> {
let input = "#c1\n\n#c2\n\niface test inet manual\n";
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None)?;
let output = String::try_from(config)?;
// Note: loopback should be added in front of other interfaces
let expected = "#c1\n#c2\n\nauto lo\niface lo inet loopback\n\niface test inet manual\n\n";
assert_eq!(output, expected);
Ok(())
}
#[test]
fn test_network_config_parser_no_blank_1() -> Result<(), Error> {
let input = "auto lo\n\
iface lo inet loopback\n\
iface lo inet6 loopback\n\
auto ens18\n\
iface ens18 inet static\n\
\taddress 192.168.20.144/20\n\
\tgateway 192.168.16.1\n\
# comment\n\
iface ens20 inet static\n\
\taddress 192.168.20.145/20\n\
iface ens21 inet manual\n\
iface ens22 inet manual\n";
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None)?;
let output = String::try_from(config)?;
let expected = "auto lo\n\
iface lo inet loopback\n\
\n\
iface lo inet6 loopback\n\
\n\
auto ens18\n\
iface ens18 inet static\n\
\taddress 192.168.20.144/20\n\
\tgateway 192.168.16.1\n\
#comment\n\
\n\
iface ens20 inet static\n\
\taddress 192.168.20.145/20\n\
\n\
iface ens21 inet manual\n\
\n\
iface ens22 inet manual\n\
\n";
assert_eq!(output, expected);
Ok(())
}
#[test]
fn test_network_config_parser_no_blank_2() -> Result<(), Error> {
// Adapted from bug 2926
let input = "### Hetzner Online GmbH installimage\n\
\n\
source /etc/network/interfaces.d/*\n\
\n\
auto lo\n\
iface lo inet loopback\n\
iface lo inet6 loopback\n\
\n\
auto enp4s0\n\
iface enp4s0 inet static\n\
\taddress 10.10.10.10/24\n\
\tgateway 10.10.10.1\n\
\t# route 10.10.20.10/24 via 10.10.20.1\n\
\tup route add -net 10.10.20.10 netmask 255.255.255.0 gw 10.10.20.1 dev enp4s0\n\
\n\
iface enp4s0 inet6 static\n\
\taddress fe80::5496:35ff:fe99:5a6a/64\n\
\tgateway fe80::1\n";
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None)?;
let output = String::try_from(config)?;
let expected = "### Hetzner Online GmbH installimage\n\
\n\
source /etc/network/interfaces.d/*\n\
\n\
auto lo\n\
iface lo inet loopback\n\
\n\
iface lo inet6 loopback\n\
\n\
auto enp4s0\n\
iface enp4s0 inet static\n\
\taddress 10.10.10.10/24\n\
\tgateway 10.10.10.1\n\
\t# route 10.10.20.10/24 via 10.10.20.1\n\
\tup route add -net 10.10.20.10 netmask 255.255.255.0 gw 10.10.20.1 dev enp4s0\n\
\n\
iface enp4s0 inet6 static\n\
\taddress fe80::5496:35ff:fe99:5a6a/64\n\
\tgateway fe80::1\n\
\n";
assert_eq!(output, expected);
Ok(())
}
#[test]
fn test_network_config_parser_vlan_id_in_name() {
let input = "iface vmbr0.100 inet static manual";
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None).unwrap();
let iface = config.interfaces.get("vmbr0.100").unwrap();
assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan);
assert_eq!(iface.vlan_raw_device, None);
assert_eq!(iface.vlan_id, None);
}
#[test]
fn test_network_config_parser_vlan_with_raw_device() {
let input = r#"
iface vlan100 inet manual
vlan-raw-device vmbr0"#;
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None).unwrap();
let iface = config.interfaces.get("vlan100").unwrap();
assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan);
assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0")));
assert_eq!(iface.vlan_id, None);
}
#[test]
fn test_network_config_parser_vlan_with_raw_device_static() {
let input = r#"
iface vlan100 inet static
vlan-raw-device vmbr0
address 10.0.0.100/16"#;
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None).unwrap();
let iface = config.interfaces.get("vlan100").unwrap();
assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan);
assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0")));
assert_eq!(iface.vlan_id, None);
assert_eq!(iface.method, Some(NetworkConfigMethod::Static));
assert_eq!(iface.cidr, Some(String::from("10.0.0.100/16")));
}
#[test]
fn test_network_config_parser_vlan_individual_name() {
let input = r#"
iface individual_name inet manual
vlan-id 100
vlan-raw-device vmbr0"#;
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None).unwrap();
let iface = config.interfaces.get("individual_name").unwrap();
assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan);
assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0")));
assert_eq!(iface.vlan_id, Some(100));
}
#[test]
fn test_network_config_parser_vlan_individual_name_static() {
let input = r#"
iface individual_name inet static
vlan-id 100
vlan-raw-device vmbr0
address 10.0.0.100/16
"#;
let mut parser = NetworkParser::new(input.as_bytes());
let config = parser.parse_interfaces(None).unwrap();
let iface = config.interfaces.get("individual_name").unwrap();
assert_eq!(iface.interface_type, NetworkInterfaceType::Vlan);
assert_eq!(iface.vlan_raw_device, Some(String::from("vmbr0")));
assert_eq!(iface.vlan_id, Some(100));
assert_eq!(iface.method, Some(NetworkConfigMethod::Static));
assert_eq!(iface.cidr, Some(String::from("10.0.0.100/16")));
}
}

View File

@ -1,55 +0,0 @@
use std::collections::HashSet;
use anyhow::{bail, Error};
use openssl::ssl::{SslAcceptor, SslMethod};
use pbs_api_types::NodeConfig;
use proxmox_http::ProxyConfig;
use proxmox_schema::ApiType;
use pbs_buildcfg::configdir;
use crate::{open_backup_lockfile, BackupLockGuard};
const CONF_FILE: &str = configdir!("/node.cfg");
const LOCK_FILE: &str = configdir!("/.node.lck");
pub fn lock() -> Result<BackupLockGuard, Error> {
open_backup_lockfile(LOCK_FILE, None, true)
}
/// Read the Node Config.
pub fn config() -> Result<(NodeConfig, [u8; 32]), Error> {
let content = proxmox_sys::fs::file_read_optional_string(CONF_FILE)?.unwrap_or_default();
let digest = openssl::sha::sha256(content.as_bytes());
let data: NodeConfig = crate::key_value::from_str(&content, &NodeConfig::API_SCHEMA)?;
Ok((data, digest))
}
/// Write the Node Config, requires the write lock to be held.
pub fn save_config(config: &NodeConfig) -> Result<(), Error> {
let mut domains = HashSet::new();
for domain in config.acme_domains() {
let domain = domain?;
if !domains.insert(domain.domain.to_lowercase()) {
bail!("duplicate domain '{}' in ACME config", domain.domain);
}
}
let mut dummy_acceptor = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap();
if let Some(ciphers) = config.ciphers_tls_1_3.as_deref() {
dummy_acceptor.set_ciphersuites(ciphers)?;
}
if let Some(ciphers) = config.ciphers_tls_1_2.as_deref() {
dummy_acceptor.set_cipher_list(ciphers)?;
}
let raw = crate::key_value::to_bytes(config, &NodeConfig::API_SCHEMA)?;
crate::replace_backup_config(CONF_FILE, &raw)
}
pub fn node_http_proxy_config() -> Result<Option<ProxyConfig>, Error> {
let (node_config, _digest) = self::config()?;
Ok(node_config.http_proxy())
}

View File

@ -1,16 +1,10 @@
use std::collections::HashMap;
use std::fs;
use std::io::ErrorKind;
use std::sync::LazyLock;
use std::time::SystemTime;
use anyhow::{bail, format_err, Error};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::{from_value, Value};
use proxmox_sys::fs::CreateOptions;
use proxmox_time::epoch_i64;
use pbs_api_types::Authid;
//use crate::auth;
@ -19,21 +13,6 @@ use crate::{open_backup_lockfile, BackupLockGuard};
const LOCK_FILE: &str = pbs_buildcfg::configdir!("/token.shadow.lock");
const CONF_FILE: &str = pbs_buildcfg::configdir!("/token.shadow");
/// Global in-memory cache for successfully verified API token secrets.
/// The cache stores plain text secrets for token Authids that have already been
/// verified against the hashed values in `token.shadow`. This allows for cheap
/// subsequent authentications for the same token+secret combination, avoiding
/// recomputing the password hash on every request.
static TOKEN_SECRET_CACHE: LazyLock<RwLock<ApiTokenSecretCache>> = LazyLock::new(|| {
RwLock::new(ApiTokenSecretCache {
secrets: HashMap::new(),
cached_gen: 0,
file_info: None,
})
});
/// Max age in seconds of the token secret cache before checking for file changes.
const TOKEN_SECRET_CACHE_TTL_SECS: i64 = 60;
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// ApiToken id / secret pair
@ -69,111 +48,15 @@ fn write_file(data: HashMap<Authid, String>) -> Result<(), Error> {
proxmox_sys::fs::replace_file(CONF_FILE, &json, options, true)
}
/// Tries to match the given token secret against the cached secret.
///
/// Verifies the generation/version before doing the constant-time
/// comparison to reduce TOCTOU risk. During token rotation or deletion
/// tokens for in-flight requests may still validate against the previous
/// generation.
///
/// If the cache file metadata's TTL has expired, will revalidate and invalidate the cache if
/// needed.
///
/// Returns true if secret is cached and cache is still valid
fn cached_secret_valid(tokenid: &Authid, secret: &str) -> bool {
let now = epoch_i64();
// Fast path: cache is fresh if generation matches and TTL not expired.
if let (Some(cache), Some(read_gen)) =
(TOKEN_SECRET_CACHE.try_read(), token_shadow_generation())
{
if cache.cached_gen == read_gen && cache.shadow_check_within_ttl(now) {
return cache.secret_matches(tokenid, secret);
}
// read lock drops here
} else {
return false;
}
// Slow path: best-effort refresh under write lock.
let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
return false;
};
// Re-read generation after acquiring the lock (may have changed meanwhile).
let Some(current_gen) = token_shadow_generation() else {
return false;
};
// If another process bumped the generation, we don't know what changed -> clear cache
if cache.cached_gen != current_gen {
cache.reset_and_set_gen(current_gen);
}
// TTL check again after acquiring the lock
let now = epoch_i64();
if cache.shadow_check_within_ttl(now) {
return cache.secret_matches(tokenid, secret);
}
// Stat the file to detect manual edits.
let Ok((new_mtime, new_len)) = shadow_mtime_len() else {
return false;
};
// If the file didn't change, only update last_checked
if let Some(shadow) = cache.file_info.as_mut() {
if shadow.mtime == new_mtime && shadow.len == new_len {
shadow.last_checked = now;
return cache.secret_matches(tokenid, secret);
}
}
cache.secrets.clear();
let prev = cache.file_info.replace(ShadowFileInfo {
mtime: new_mtime,
len: new_len,
last_checked: now,
});
if prev.is_some() {
// Best-effort propagation to other processes if a change was detected
if let Some(new_gen) = bump_token_shadow_generation() {
cache.cached_gen = new_gen;
}
}
false
}
/// Verifies that an entry for given tokenid / API token secret exists
pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
if !tokenid.is_token() {
bail!("not an API token ID");
}
// Fast path
if cached_secret_valid(tokenid, secret) {
return Ok(());
}
// Slow path
// First, capture the generation before doing the hash verification.
let gen_before = token_shadow_generation();
let data = read_file()?;
match data.get(tokenid) {
Some(hashed_secret) => {
proxmox_sys::crypt::verify_crypt_pw(secret, hashed_secret)?;
// Try to cache only if nothing changed while verifying the secret.
if let Some(gen_before) = gen_before {
cache_try_insert_secret(tokenid.clone(), secret.to_owned(), gen_before);
}
Ok(())
}
Some(hashed_secret) => proxmox_sys::crypt::verify_crypt_pw(secret, hashed_secret),
None => bail!("invalid API token"),
}
}
@ -181,203 +64,38 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
/// Generates a new secret for the given tokenid / API token, sets it then returns it.
/// The secret is stored as salted hash.
pub fn generate_and_set_secret(tokenid: &Authid) -> Result<String, Error> {
apply_api_mutation(tokenid, true)?
.ok_or_else(|| format_err!("Failed to generate API token secret"))
let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
set_secret(tokenid, &secret)?;
Ok(secret)
}
/// Deletes the entry for the given tokenid.
pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
apply_api_mutation(tokenid, false)?;
Ok(())
}
/// Cached secret.
struct CachedSecret {
secret: String,
}
struct ApiTokenSecretCache {
/// Keys are token Authids, values are the corresponding plain text secrets.
/// Entries are added after a successful on-disk verification in
/// `verify_secret` or when a new token secret is generated by
/// `generate_and_set_secret`. Used to avoid repeated
/// password-hash computation on subsequent authentications.
secrets: HashMap<Authid, CachedSecret>,
/// token.shadow generation of cached secrets.
cached_gen: usize,
/// Shadow file info to detect changes
file_info: Option<ShadowFileInfo>,
}
impl ApiTokenSecretCache {
/// Resets all local cache contents and sets/updates the cached generation.
fn reset_and_set_gen(&mut self, new_gen: usize) {
self.secrets.clear();
self.cached_gen = new_gen;
self.file_info = None;
}
/// Caches a secret and sets/updates the cache generation.
fn insert_and_set_gen(&mut self, tokenid: Authid, secret: CachedSecret, new_gen: usize) {
self.secrets.insert(tokenid, secret);
self.cached_gen = new_gen;
}
/// Evicts a cached secret and sets/updates the cached generation.
fn evict_and_set_gen(&mut self, tokenid: &Authid, new_gen: usize) {
self.secrets.remove(tokenid);
self.cached_gen = new_gen;
}
/// Returns true if cached token.shadow metadata exists and was checked within the TTL window.
fn shadow_check_within_ttl(&self, now: i64) -> bool {
self.file_info.as_ref().is_some_and(|cached| {
now >= cached.last_checked && (now - cached.last_checked) < TOKEN_SECRET_CACHE_TTL_SECS
})
}
/// Returns true if there is a matching cached entry
fn secret_matches(&self, tokenid: &Authid, secret: &str) -> bool {
let Some(entry) = self.secrets.get(tokenid) else {
return false;
};
let cached_secret_bytes = entry.secret.as_bytes();
let secret_bytes = secret.as_bytes();
cached_secret_bytes.len() == secret_bytes.len()
&& openssl::memcmp::eq(cached_secret_bytes, secret_bytes)
}
}
/// Shadow file info
struct ShadowFileInfo {
// shadow file mtime to detect changes
mtime: Option<SystemTime>,
// shadow file length to detect changes
len: Option<u64>,
// last time the file metadata was checked
last_checked: i64,
}
fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
return;
};
let Some(gen_now) = token_shadow_generation() else {
return;
};
// If this process missed a generation bump, its cache is stale.
if cache.cached_gen != gen_now {
cache.reset_and_set_gen(gen_now);
}
// If a mutation happened while we were verifying the secret, do not insert.
if gen_now == gen_before {
cache.insert_and_set_gen(tokenid, CachedSecret { secret }, gen_now);
}
}
fn apply_api_mutation(tokenid: &Authid, generate: bool) -> Result<Option<String>, Error> {
/// Adds a new entry for the given tokenid / API token secret. The secret is stored as salted hash.
fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
if !tokenid.is_token() {
bail!("not an API token ID");
}
let _guard = lock_config()?;
// Capture state before we write to detect external edits.
let pre_write_meta = shadow_mtime_len().unwrap_or((None, None));
let mut data = read_file()?;
let secret = if generate {
let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
let hashed_secret = proxmox_sys::crypt::encrypt_pw(&secret)?;
data.insert(tokenid.clone(), hashed_secret);
Some(secret)
} else {
data.remove(tokenid);
None
};
let hashed_secret = proxmox_sys::crypt::encrypt_pw(secret)?;
data.insert(tokenid.clone(), hashed_secret);
write_file(data)?;
let now = epoch_i64();
// Signal cache invalidation to other processes (best-effort).
let bumped_gen = bump_token_shadow_generation();
let mut cache = TOKEN_SECRET_CACHE.write();
// If we cannot get the current generation, we cannot trust the cache
let Some(current_gen) = token_shadow_generation() else {
cache.reset_and_set_gen(0);
return Ok(secret);
};
// If we cannot bump the generation, or if it changed after
// obtaining the cache write lock, we cannot trust the cache
if bumped_gen != Some(current_gen) {
cache.reset_and_set_gen(current_gen);
return Ok(secret);
}
// If our cached file metadata does not match the on-disk state before our write,
// we likely missed an external/manual edit. We can no longer trust any cached secrets.
if cache
.file_info
.as_ref()
.is_some_and(|s| (s.mtime, s.len) != pre_write_meta)
{
cache.secrets.clear();
}
// Apply the new mutation.
match &secret {
Some(secret) => {
let cached_secret = CachedSecret {
secret: secret.to_owned(),
};
cache.insert_and_set_gen(tokenid.clone(), cached_secret, current_gen);
}
None => cache.evict_and_set_gen(tokenid, current_gen),
}
// Update our view of the file metadata to the post-write state (best-effort).
// (If this fails, drop local cache so callers fall back to slow path until refreshed.)
match shadow_mtime_len() {
Ok((mtime, len)) => {
cache.file_info = Some(ShadowFileInfo {
mtime,
len,
last_checked: now,
});
}
Err(_) => {
// If we cannot validate state, do not trust cache.
cache.reset_and_set_gen(current_gen);
}
}
Ok(secret)
Ok(())
}
/// Get the current generation.
fn token_shadow_generation() -> Option<usize> {
crate::ConfigVersionCache::new()
.ok()
.map(|cvc| cvc.token_shadow_generation())
}
/// Bump and return the new generation.
fn bump_token_shadow_generation() -> Option<usize> {
crate::ConfigVersionCache::new()
.ok()
.map(|cvc| cvc.increase_token_shadow_generation() + 1)
}
fn shadow_mtime_len() -> Result<(Option<SystemTime>, Option<u64>), Error> {
match fs::metadata(CONF_FILE) {
Ok(meta) => Ok((meta.modified().ok(), Some(meta.len()))),
Err(e) if e.kind() == ErrorKind::NotFound => Ok((None, None)),
Err(e) => Err(e.into()),
/// Deletes the entry for the given tokenid.
pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
if !tokenid.is_token() {
bail!("not an API token ID");
}
let _guard = lock_config()?;
let mut data = read_file()?;
data.remove(tokenid);
write_file(data)?;
Ok(())
}

View File

@ -85,7 +85,7 @@ mod test {
timeframe fri 9:00-12:00
";
let data = CONFIG.parse(TRAFFIC_CONTROL_CFG_FILENAME, content)?;
eprintln!("GOT {data:?}");
eprintln!("GOT {:?}", data);
Ok(())
}

View File

@ -53,9 +53,8 @@ pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> {
let digest = openssl::sha::sha256(content.as_bytes());
let mut data = CONFIG.parse(USER_CFG_FILENAME, &content)?;
let root_user = Userid::root_userid().as_str();
if !data.sections.contains_key(root_user) {
if !data.sections.contains_key("root@pam") {
let user: User = User {
userid: Userid::root_userid().clone(),
comment: Some("Superuser".to_string()),
@ -65,7 +64,7 @@ pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> {
lastname: None,
email: None,
};
data.set_data(root_user, "user", &user).unwrap();
data.set_data("root@pam", "user", &user).unwrap();
}
Ok((data, digest))

View File

@ -40,7 +40,6 @@ proxmox-io.workspace = true
proxmox-lang.workspace=true
proxmox-s3-client = { workspace = true, features = [ "impl" ] }
proxmox-schema = { workspace = true, features = [ "api-macro" ] }
proxmox-section-config.workspace = true
proxmox-serde = { workspace = true, features = [ "serde_json" ] }
proxmox-sys.workspace = true
proxmox-systemd.workspace = true
@ -53,6 +52,3 @@ pbs-buildcfg.workspace = true
pbs-config.workspace = true
pbs-key-config.workspace = true
pbs-tools.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@ -21,7 +21,7 @@ fn run() -> Result<(), Error> {
let store = unsafe { DataStore::open_path("", base, None)? };
for ns in store.recursive_iter_backup_ns_ok(Default::default(), max_depth)? {
println!("found namespace store:/{ns}");
println!("found namespace store:/{}", ns);
for group in store.iter_backup_groups(ns)? {
let group = group?;
@ -41,7 +41,7 @@ fn main() {
std::process::exit(match run() {
Ok(_) => 0,
Err(err) => {
eprintln!("error: {err}");
eprintln!("error: {}", err);
1
}
});

View File

@ -8,21 +8,19 @@ use std::time::Duration;
use anyhow::{bail, format_err, Context, Error};
use const_format::concatcp;
use tracing::info;
use proxmox_s3_client::{S3ObjectKey, S3PathPrefix};
use proxmox_s3_client::S3PathPrefix;
use proxmox_sys::fs::{lock_dir_noblock, lock_dir_noblock_shared, replace_file, CreateOptions};
use proxmox_systemd::escape_unit;
use pbs_api_types::{
ArchiveType, Authid, BackupGroupDeleteStats, BackupNamespace, BackupType, GroupFilter,
VerifyState, BACKUP_DATE_REGEX, CLIENT_LOG_BLOB_NAME, MANIFEST_BLOB_NAME,
Authid, BackupGroupDeleteStats, BackupNamespace, BackupType, GroupFilter, VerifyState,
BACKUP_DATE_REGEX, CLIENT_LOG_BLOB_NAME, MANIFEST_BLOB_NAME,
};
use pbs_config::{open_backup_lockfile, BackupLockGuard};
use crate::datastore::{GROUP_NOTES_FILE_NAME, GROUP_OWNER_FILE_NAME};
use crate::manifest::{BackupManifest, MANIFEST_LOCK_NAME};
use crate::move_journal;
use crate::s3::S3_CONTENT_PREFIX;
use crate::{DataBlob, DataStore, DatastoreBackend};
@ -219,18 +217,14 @@ impl BackupGroup {
crate::ListSnapshots::new(self.clone())
}
/// Destroy the group inclusive all its backup snapshots (BackupDir's).
///
/// Consumes the group lock. The caller is responsible for acquiring it via
/// [`Self::lock`] beforehand.
/// Destroy the group inclusive all its backup snapshots (BackupDir's)
///
/// Returns `BackupGroupDeleteStats`, containing the number of deleted snapshots
/// and number of protected snaphsots, which therefore were not removed.
pub(crate) fn destroy(
&self,
_lock_guard: BackupLockGuard,
backend: &DatastoreBackend,
) -> Result<BackupGroupDeleteStats, Error> {
pub fn destroy(&self, backend: &DatastoreBackend) -> Result<BackupGroupDeleteStats, Error> {
let _guard = self
.lock()
.with_context(|| format!("while destroying group '{self:?}'"))?;
let path = self.full_group_path();
log::info!("removing backup group {:?}", path);
@ -253,15 +247,14 @@ impl BackupGroup {
.to_str()
.ok_or_else(|| format_err!("invalid group path prefix"))?;
let prefix = format!("{S3_CONTENT_PREFIX}/{group_prefix}");
let delete_objects_errors = proxmox_async::runtime::block_on(
let delete_objects_error = proxmox_async::runtime::block_on(
s3_client.delete_objects_by_prefix_with_suffix_filter(
&S3PathPrefix::Some(prefix),
PROTECTED_MARKER_FILENAME,
&[GROUP_OWNER_FILE_NAME, GROUP_NOTES_FILE_NAME],
),
)?;
if !delete_objects_errors.is_empty() {
crate::s3::log_s3_delete_objects_errors(&delete_objects_errors);
if delete_objects_error {
bail!("deleting objects failed");
}
}
@ -276,132 +269,6 @@ impl BackupGroup {
Ok(delete_stats)
}
/// Check merge invariants for moving this group's snapshots into `target`.
/// Returns an error if ownership differs or snapshot times mismatch.
pub(crate) fn check_merge_invariants(&self, target: &BackupGroup) -> Result<(), Error> {
let src_owner = self.get_owner()?;
let tgt_owner = target.get_owner()?;
if src_owner != tgt_owner {
bail!(
"cannot merge group '{}/{}' from '{}' into '{}': owner mismatch \
(source: {src_owner}, target: {tgt_owner})",
self.group.ty,
self.group.id,
self.ns,
target.ns,
);
}
let (src_oldest, src_oldest_str) = self.iter_snapshots()?.filter_map(Result::ok).fold(
(i64::MAX, String::new()),
|(min, min_str), s| {
let curr = s.backup_time();
if curr < min {
(curr, s.backup_time_string.clone())
} else {
(min, min_str)
}
},
);
if src_oldest != i64::MAX {
// Any target snapshot with time >= src_oldest violates the
// "source strictly newer than target" merge invariant. Short-circuit on the first hit.
if let Some(overlap) = target
.iter_snapshots()?
.filter_map(Result::ok)
.find_map(|s| {
if s.backup_time() >= src_oldest {
Some(s.backup_time_string().to_owned())
} else {
None
}
})
{
info!("oldest source snapshot: {src_oldest_str}");
info!("conflicting target snapshot: {overlap}");
bail!(
"cannot merge group '{}/{}' from '{}' into '{}': snapshot time mismatch",
self.group.ty,
self.group.id,
self.ns,
target.ns,
);
}
}
Ok(())
}
/// Move the group notes file (if any) from this group to `target`. Caller must hold
/// exclusive group locks on both.
///
/// Behavior:
/// - If the source has no notes, do nothing.
/// - If only the source has notes, upload them to the target's S3 notes object first
/// (when the backend is S3), then rename the local file. A failure in either step
/// aborts the move so the source is left intact for the caller to retry; the S3
/// upload uses replace semantics so re-running is idempotent.
/// - If both source and target have notes (merge case):
/// - identical contents: leave the target unchanged. The source copy will be
/// removed by `destroy()`.
/// - diverging contents: log a warning and keep the target's notes.
pub(crate) fn move_notes_to(
&self,
target: &BackupGroup,
backend: &DatastoreBackend,
) -> Result<(), Error> {
let src_notes_path = self.store.group_notes_path(&self.ns, &self.group);
let dst_notes_path = target.store.group_notes_path(&target.ns, &target.group);
let src_notes = match std::fs::read(&src_notes_path) {
Ok(v) => v,
Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
bail!("reading source group notes {src_notes_path:?} failed: {err}")
}
};
match std::fs::read(&dst_notes_path) {
Ok(dst_notes) => {
if dst_notes != src_notes {
log::warn!(
"group notes differ during merge of '{}' from '{}' into '{}' - keeping target's notes",
self.group, self.ns, target.ns,
);
}
// Identical or intentionally kept: source copy will be removed by destroy().
return Ok(());
}
Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
bail!("reading target group notes {dst_notes_path:?} failed: {err}")
}
}
if let DatastoreBackend::S3(s3_client) = backend {
let dst_key = crate::s3::object_key_from_path(
&target.relative_group_path(),
GROUP_NOTES_FILE_NAME,
)
.context("invalid target notes object key")?;
let data = hyper::body::Bytes::copy_from_slice(&src_notes);
proxmox_async::runtime::block_on(s3_client.upload_replace_with_retry(dst_key, data))
.with_context(|| {
format!(
"failed to upload group notes on S3 backend for '{}' in '{}'",
target.group, target.ns,
)
})?;
}
std::fs::rename(&src_notes_path, &dst_notes_path).with_context(|| {
format!("failed to move group notes {src_notes_path:?} -> {dst_notes_path:?}")
})?;
Ok(())
}
/// Helper function, assumes that no more snapshots are present in the group.
fn remove_group_dir(&self) -> Result<(), Error> {
let note_path = self.store.group_notes_path(&self.ns, &self.group);
@ -741,149 +608,10 @@ impl BackupDir {
}
}
/// Move this snapshot into `target`.
///
/// For the filesystem backend, renames the snapshot directory. For S3, copies all
/// objects under the snapshot prefix to the target, renames the local cache directory,
/// then deletes the source objects. A copy failure returns an error with the snapshot
/// intact at source. A delete failure is logged as a warning.
///
/// Before the rename, each index file's post-rename path is recorded in the per-datastore
/// move journal so a concurrent GC phase-1 drain can still mark their chunks - see
/// `move_journal` for the race this closes. If the rename then fails the journal entry
/// becomes a ghost that the drain skips on `open_index_reader` returning `None`.
///
/// The caller must hold an exclusive lock on this snapshot, hold exclusive locks on
/// both source and target groups, and ensure the target group directory exists.
pub(crate) fn move_to(
&self,
target: &BackupGroup,
backend: &DatastoreBackend,
) -> Result<(), Error> {
if !Arc::ptr_eq(&self.store, &target.store) {
bail!("cannot move snapshot across different datastores");
}
let target_snap = target.backup_dir_with_rfc3339(self.backup_time_string.clone())?;
let src_snap_path = self.full_path();
let dst_snap_path = target_snap.full_path();
// Enumerate the index files at source (they are still there until the
// rename below) and record their future paths in the move journal so a
// concurrent GC phase-1 drain can mark their chunks even if the
// hierarchy iteration missed both the source and target.
let mut journal_entries: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(&src_snap_path)
.with_context(|| format!("failed to list source snapshot dir {src_snap_path:?}"))?
{
let entry =
entry.with_context(|| format!("failed to read entry in {src_snap_path:?}"))?;
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if matches!(
ArchiveType::from_path(name_str),
Ok(ArchiveType::FixedIndex) | Ok(ArchiveType::DynamicIndex)
) {
journal_entries.push(dst_snap_path.join(&name));
}
}
move_journal::append_moved_indices(self.store.name(), &journal_entries)?;
match backend {
DatastoreBackend::Filesystem => {
std::fs::rename(&src_snap_path, &dst_snap_path).with_context(|| {
format!("failed to move snapshot {src_snap_path:?} to {dst_snap_path:?}")
})?;
}
DatastoreBackend::S3(s3_client) => {
let src_rel = self.relative_path();
let src_rel_str = src_rel
.to_str()
.ok_or_else(|| format_err!("invalid source snapshot path"))?;
let src_prefix_str = format!("{S3_CONTENT_PREFIX}/{src_rel_str}/");
let dst_rel = target_snap.relative_path();
let dst_rel_str = dst_rel
.to_str()
.ok_or_else(|| format_err!("invalid target snapshot path"))?;
let dst_prefix_str = format!("{S3_CONTENT_PREFIX}/{dst_rel_str}/");
let store_prefix = format!("{}/", self.store.name());
// Copy all objects for this snapshot to the target prefix. On failure the
// source snapshot remains intact and any partial target copies stay as
// leftovers (visible via the API for cleanup).
let prefix = S3PathPrefix::Some(src_prefix_str.clone());
let mut token: Option<String> = None;
let mut src_keys = Vec::new();
loop {
let result = proxmox_async::runtime::block_on(
s3_client.list_objects_v2(&prefix, token.as_deref()),
)
.context("failed to list snapshot objects on S3 backend")?;
for item in result.contents {
let full_key_str: &str = &item.key;
let rel_key =
full_key_str.strip_prefix(&store_prefix).ok_or_else(|| {
format_err!("unexpected key prefix in '{full_key_str}'")
})?;
let src_key = S3ObjectKey::try_from(rel_key)?;
let suffix = rel_key
.strip_prefix(&src_prefix_str)
.ok_or_else(|| format_err!("unexpected key format '{rel_key}'"))?;
let dst_key_str = format!("{dst_prefix_str}{suffix}");
let dst_key = S3ObjectKey::try_from(dst_key_str.as_str())?;
proxmox_async::runtime::block_on(
s3_client.copy_object(src_key.clone(), dst_key),
)
.with_context(|| format!("failed to copy S3 object '{rel_key}'"))?;
src_keys.push(src_key);
}
if result.is_truncated {
token = result.next_continuation_token;
} else {
break;
}
}
std::fs::rename(&src_snap_path, &dst_snap_path).with_context(|| {
format!("failed to move snapshot cache {src_snap_path:?} to {dst_snap_path:?}")
})?;
// Delete source S3 objects. Treat failures as warnings since the snapshot
// is already at the target.
for src_key in src_keys {
if let Err(err) =
proxmox_async::runtime::block_on(s3_client.delete_object(src_key.clone()))
{
log::warn!(
"S3 move: failed to delete source object '{src_key:?}' \
(snapshot already at target, orphaned object requires manual removal): \
{err:#}"
);
}
}
}
}
// Clean up stale source lock files under /run for this snapshot.
let _ = std::fs::remove_file(self.manifest_lock_path());
let _ = std::fs::remove_file(self.lock_path());
Ok(())
}
/// Destroy the whole snapshot, bails if it's protected
///
/// Setting `force` to true skips locking and thus ignores if the backup is currently in use.
pub(crate) fn destroy(&self, force: bool, backend: &DatastoreBackend) -> Result<(), Error> {
pub fn destroy(&self, force: bool, backend: &DatastoreBackend) -> Result<(), Error> {
let (_guard, _manifest_guard);
if !force {
_guard = self
@ -905,8 +633,7 @@ impl BackupDir {
let delete_objects_error = proxmox_async::runtime::block_on(
s3_client.delete_objects_by_prefix(&S3PathPrefix::Some(prefix)),
)?;
if !delete_objects_error.is_empty() {
crate::s3::log_s3_delete_objects_errors(&delete_objects_error);
if delete_objects_error {
bail!("deleting objects failed");
}
}
@ -1118,13 +845,13 @@ impl BackupInfo {
})
}
pub fn sort_list(list: &mut [BackupInfo], ascending: bool) {
if ascending {
pub fn sort_list(list: &mut [BackupInfo], ascendending: bool) {
if ascendending {
// oldest first
list.sort_unstable_by_key(|a| a.backup_dir.dir.time);
list.sort_unstable_by(|a, b| a.backup_dir.dir.time.cmp(&b.backup_dir.dir.time));
} else {
// newest first
list.sort_unstable_by_key(|b| std::cmp::Reverse(b.backup_dir.dir.time));
list.sort_unstable_by(|a, b| b.backup_dir.dir.time.cmp(&a.backup_dir.dir.time));
}
}
@ -1209,7 +936,7 @@ fn lock_file_path_helper(ns: &BackupNamespace, path: PathBuf) -> PathBuf {
/// deletion.
///
/// It also creates the base directory for lock files.
pub(crate) fn lock_helper<F>(
fn lock_helper<F>(
store_name: &str,
path: &std::path::Path,
lock_fn: F,

View File

@ -203,7 +203,7 @@ impl DirEntry {
/// Check if DirEntry is a symlink
pub fn is_symlink(&self) -> bool {
matches!(self.attr, DirEntryAttribute::Symlink)
matches!(self.attr, DirEntryAttribute::Symlink { .. })
}
}
@ -974,7 +974,7 @@ impl ArchiveEntry {
) -> Self {
Self {
filepath: proxmox_base64::encode(filepath),
text: String::from_utf8_lossy(filepath.split(|x| *x == b'/').next_back().unwrap())
text: String::from_utf8_lossy(filepath.split(|x| *x == b'/').last().unwrap())
.to_string(),
entry_type: match entry_type {
Some(entry_type) => CatalogEntryType::from(entry_type).to_string(),

View File

@ -51,9 +51,12 @@ impl<R: Read> Read for ChecksumReader<R> {
if count > 0 {
self.hasher.update(&buf[..count]);
if let Some(ref mut signer) = self.signer {
signer
.update(&buf[..count])
.map_err(|err| std::io::Error::other(format!("hmac update failed - {err}")))?;
signer.update(&buf[..count]).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("hmac update failed - {}", err),
)
})?;
}
}
Ok(count)

View File

@ -49,9 +49,12 @@ impl<W: Write> Write for ChecksumWriter<W> {
fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
self.hasher.update(buf);
if let Some(ref mut signer) = self.signer {
signer
.update(buf)
.map_err(|err| std::io::Error::other(format!("hmac update failed - {err}")))?;
signer.update(buf).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("hmac update failed - {}", err),
)
})?;
}
self.writer.write(buf)
}

View File

@ -5,11 +5,9 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{bail, format_err, Context, Error};
use hex::FromHex;
use tracing::{info, warn};
use pbs_api_types::{DatastoreFSyncLevel, GarbageCollectionStatus};
use pbs_config::BackupLockGuard;
use proxmox_io::ReadExt;
use proxmox_s3_client::S3Client;
use proxmox_sys::fs::{create_dir, create_path, file_type_from_file_stat, CreateOptions};
@ -18,14 +16,11 @@ use proxmox_sys::process_locker::{
};
use proxmox_worker_task::WorkerTaskContext;
use crate::backup_info::DATASTORE_LOCKS_DIR;
use crate::data_blob::DataChunkBuilder;
use crate::file_formats::{
COMPRESSED_BLOB_MAGIC_1_0, ENCRYPTED_BLOB_MAGIC_1_0, UNCOMPRESSED_BLOB_MAGIC_1_0,
};
use crate::{DataBlob, LocalDatastoreLruCache};
const USING_MARKER_FILENAME_EXT: &str = "using";
use crate::DataBlob;
/// File system based chunk store
pub struct ChunkStore {
@ -140,7 +135,7 @@ impl ChunkStore {
for i in 0..64 * 1024 {
let mut l1path = chunk_dir.clone();
l1path.push(format!("{i:04x}"));
l1path.push(format!("{:04x}", i));
if let Err(err) = create_dir(&l1path, options) {
bail!(
"unable to create chunk store '{}' subdir {:?} - {}",
@ -209,32 +204,15 @@ impl ChunkStore {
})
}
fn touch_chunk_no_lock(&self, digest: &[u8; 32]) -> Result<(), Error> {
pub fn touch_chunk(&self, digest: &[u8; 32]) -> Result<(), Error> {
// unwrap: only `None` in unit tests
assert!(self.locker.is_some());
self.cond_touch_chunk_no_lock(digest, true)?;
self.cond_touch_chunk(digest, true)?;
Ok(())
}
/// Update the chunk files atime if it exists.
///
/// If the chunk file does not exist, return with error if assert_exists is true, with
/// Ok(false) otherwise.
pub(super) fn cond_touch_chunk(
&self,
digest: &[u8; 32],
assert_exists: bool,
) -> Result<bool, Error> {
let _lock = self.mutex.lock();
self.cond_touch_chunk_no_lock(digest, assert_exists)
}
fn cond_touch_chunk_no_lock(
&self,
digest: &[u8; 32],
assert_exists: bool,
) -> Result<bool, Error> {
pub fn cond_touch_chunk(&self, digest: &[u8; 32], assert_exists: bool) -> Result<bool, Error> {
// unwrap: only `None` in unit tests
assert!(self.locker.is_some());
@ -242,7 +220,7 @@ impl ChunkStore {
self.cond_touch_path(&chunk_path, assert_exists)
}
fn cond_touch_path(&self, path: &Path, assert_exists: bool) -> Result<bool, Error> {
pub fn cond_touch_path(&self, path: &Path, assert_exists: bool) -> Result<bool, Error> {
// unwrap: only `None` in unit tests
assert!(self.locker.is_some());
@ -276,32 +254,11 @@ impl ChunkStore {
Ok(true)
}
/// Update access timestamp on all bad chunks for given digest
///
/// Gets exclusive access by acquiring the chunk store mutex guard.
pub(super) fn cond_touch_bad_chunks(&self, digest: &[u8; 32]) -> Result<bool, Error> {
let _lock = self.mutex.lock();
let (mut chunk_path, digest_str) = self.chunk_path(digest);
let mut is_bad = false;
for i in 0..=9 {
chunk_path.set_file_name(ChunkExt::bad_chunk_filename(&digest_str, i));
if self.cond_touch_path(&chunk_path, false)? {
is_bad = true;
}
}
Ok(is_bad)
}
fn get_chunk_store_iterator(
pub fn get_chunk_iterator(
&self,
) -> Result<
impl std::iter::FusedIterator<
Item = (
Result<proxmox_sys::fs::ReadDirEntry, Error>,
usize,
ChunkExt,
),
Item = (Result<proxmox_sys::fs::ReadDirEntry, Error>, usize, bool),
>,
Error,
> {
@ -336,47 +293,21 @@ impl ChunkStore {
Some(Ok(entry)) => {
// skip files if they're not a hash
let bytes = entry.file_name().to_bytes();
if bytes.len() < 64 {
if bytes.len() != 64 && bytes.len() != 64 + ".0.bad".len() {
continue;
}
if !bytes.iter().take(64).all(u8::is_ascii_hexdigit) {
continue;
}
// regular chunk
if bytes.len() == 64 {
return Some((Ok(entry), percentage, ChunkExt::None));
}
// i-th bad chunk
if bytes.len() == 64 + ".i.bad".len()
&& bytes[64] == b'.'
&& bytes[65] >= b'0'
&& bytes[65] <= b'9'
&& bytes[66] == b'.'
&& bytes.ends_with(b"bad")
{
return Some((Ok(entry), percentage, ChunkExt::Bad));
}
// chunk marker file
let marker_ext_bytes = USING_MARKER_FILENAME_EXT.as_bytes();
if bytes.len() == 64 + 1 + marker_ext_bytes.len()
&& bytes[64] == b'.'
&& bytes.ends_with(marker_ext_bytes)
{
return Some((Ok(entry), percentage, ChunkExt::UsedMarker));
}
continue;
let bad = bytes.ends_with(b".bad");
return Some((Ok(entry), percentage, bad));
}
Some(Err(err)) => {
// stop after first error
done = true;
// and pass the error through:
return Some((Err(err), percentage, ChunkExt::None));
return Some((Err(err), percentage, false));
}
None => (), // open next directory
}
@ -389,7 +320,7 @@ impl ChunkStore {
return None;
}
let subdir: &str = &format!("{at:04x}");
let subdir: &str = &format!("{:04x}", at);
percentage = (at * 100) / 0x10000;
at += 1;
match proxmox_sys::fs::read_subdir(base_handle.as_raw_fd(), subdir) {
@ -409,7 +340,7 @@ impl ChunkStore {
return Some((
Err(format_err!("unable to read subdir '{subdir}' - {err}")),
percentage,
ChunkExt::None,
false,
));
}
}
@ -433,7 +364,6 @@ impl ChunkStore {
min_atime: i64,
status: &mut GarbageCollectionStatus,
worker: &dyn WorkerTaskContext,
cache: Option<&LocalDatastoreLruCache>,
) -> Result<(), Error> {
// unwrap: only `None` in unit tests
assert!(self.locker.is_some());
@ -444,8 +374,7 @@ impl ChunkStore {
let mut last_percentage = 0;
let mut chunk_count = 0;
for (entry, percentage, chunk_ext) in self.get_chunk_store_iterator()? {
let bad = chunk_ext == ChunkExt::Bad;
for (entry, percentage, bad) in self.get_chunk_iterator()? {
if last_percentage != percentage {
last_percentage = percentage;
info!("processed {percentage}% ({chunk_count} chunks)");
@ -476,53 +405,39 @@ impl ChunkStore {
drop(lock);
continue;
}
if chunk_ext == ChunkExt::UsedMarker {
unlinkat(Some(dirfd), filename, UnlinkatFlags::NoRemoveDir).map_err(|err| {
format_err!("unlinking chunk using marker {filename:?} failed - {err}")
})?;
drop(lock);
continue;
}
chunk_count += 1;
unsafe {
self.cond_sweep_chunk(
stat.st_atime,
min_atime,
oldest_writer,
stat.st_size as u64,
bad,
status,
|| {
// non-bad S3 chunks need to be removed via cache
if let Some(cache) = cache {
if !bad {
let digest = <[u8; 32]>::from_hex(filename.to_bytes())?;
// unless there is a concurrent upload pending,
// must never block due to required locking order
if let Ok(_guard) =
self.lock_chunk(&digest, Duration::from_secs(0))
{
cache.remove(&digest)?;
}
return Ok(());
}
}
// bad or local chunks
unlinkat(Some(dirfd), filename, UnlinkatFlags::NoRemoveDir).map_err(
|err| {
format_err!(
"unlinking chunk {filename:?} failed on store '{}' - {err}",
self.name,
)
},
)
},
)?;
if stat.st_atime < min_atime {
//let age = now - stat.st_atime;
//println!("UNLINK {} {:?}", age/(3600*24), filename);
if let Err(err) = unlinkat(Some(dirfd), filename, UnlinkatFlags::NoRemoveDir) {
if bad {
status.still_bad += 1;
}
bail!(
"unlinking chunk {filename:?} failed on store '{}' - {err}",
self.name,
);
}
if bad {
status.removed_bad += 1;
} else {
status.removed_chunks += 1;
}
status.removed_bytes += stat.st_size as u64;
} else if stat.st_atime < oldest_writer {
if bad {
status.still_bad += 1;
} else {
status.pending_chunks += 1;
}
status.pending_bytes += stat.st_size as u64;
} else {
if !bad {
status.disk_chunks += 1;
}
status.disk_bytes += stat.st_size as u64;
}
}
drop(lock);
@ -531,53 +446,6 @@ impl ChunkStore {
Ok(())
}
/// Check within what range the provided chunks atime falls and update the garbage collection
/// status accordingly.
///
/// If the chunk should be removed, the [`remove_callback`] is executed.
///
/// Unsafe: requires locking and GC checks to be called
/// FIXME: make this internal with further refactoring
#[allow(clippy::too_many_arguments)]
pub(super) unsafe fn cond_sweep_chunk<T: FnOnce() -> Result<(), Error>>(
&self,
atime: i64,
min_atime: i64,
oldest_writer: i64,
size: u64,
bad: bool,
gc_status: &mut GarbageCollectionStatus,
remove_callback: T,
) -> Result<(), Error> {
if atime < min_atime {
if let Err(err) = remove_callback() {
if bad {
gc_status.still_bad += 1;
}
return Err(err);
}
if bad {
gc_status.removed_bad += 1;
} else {
gc_status.removed_chunks += 1;
}
gc_status.removed_bytes += size;
} else if atime < oldest_writer {
if bad {
gc_status.still_bad += 1;
} else {
gc_status.pending_chunks += 1;
}
gc_status.pending_bytes += size;
} else {
if !bad {
gc_status.disk_chunks += 1;
}
gc_status.disk_bytes += size;
}
Ok(())
}
/// Check if atime updates are honored by the filesystem backing the chunk store.
///
/// Checks if the atime is always updated by utimensat taking into consideration the Linux
@ -665,24 +533,10 @@ impl ChunkStore {
//println!("DIGEST {}", hex::encode(digest));
let _lock = self.mutex.lock();
// Safety: lock acquired above
unsafe { self.insert_chunk_nolock(chunk, digest, true) }
}
/// Safety: requires holding the chunk store mutex!
pub(crate) unsafe fn insert_chunk_nolock(
&self,
chunk: &DataBlob,
digest: &[u8; 32],
warn_on_overwrite_empty: bool,
) -> Result<(bool, u64), Error> {
// unwrap: only `None` in unit tests
assert!(self.locker.is_some());
let (chunk_path, digest_str) = self.chunk_path(digest);
let lock = self.mutex.lock();
let raw_data = chunk.raw_data();
let encoded_size = raw_data.len() as u64;
@ -694,12 +548,10 @@ impl ChunkStore {
}
let old_size = metadata.len();
if encoded_size == old_size {
self.touch_chunk_no_lock(digest)?;
self.touch_chunk(digest)?;
return Ok((true, old_size));
} else if old_size == 0 {
if warn_on_overwrite_empty {
log::warn!("found empty chunk '{digest_str}' in store {name}, overwriting");
}
log::warn!("found empty chunk '{digest_str}' in store {name}, overwriting");
} else if chunk.is_encrypted() {
// incoming chunk is encrypted, possible attack or hash collision!
let mut existing_file = std::fs::File::open(&chunk_path)?;
@ -721,11 +573,11 @@ impl ChunkStore {
// compressed, the size mismatch could be caused by different zstd versions
// so let's keep the one that was uploaded first, bit-rot is hopefully detected by
// verification at some point..
self.touch_chunk_no_lock(digest)?;
self.touch_chunk(digest)?;
return Ok((true, old_size));
} else if old_size < encoded_size {
log::debug!("Got another copy of chunk with digest '{digest_str}', existing chunk is smaller, discarding uploaded one.");
self.touch_chunk_no_lock(digest)?;
self.touch_chunk(digest)?;
return Ok((true, old_size));
} else {
log::debug!("Got another copy of chunk with digest '{digest_str}', existing chunk is bigger, replacing with uploaded one.");
@ -759,6 +611,8 @@ impl ChunkStore {
.map_err(|err| format_err!("fsync failed: {err}"))?;
}
drop(lock);
Ok((false, encoded_size))
}
@ -774,82 +628,6 @@ impl ChunkStore {
(chunk_path, digest_str)
}
/// Replace a chunk file with a zero size file in the chunk store.
///
/// Used to evict chunks from the local datastore cache, while keeping them as in-use markers
/// for garbage collection. Returns with success also if chunk file is not pre-existing,
/// also creating the marker file in that case.
///
/// Safety: chunk store mutex must be held!
pub(crate) unsafe fn replace_chunk_with_marker_or_create_marker(
&self,
digest: &[u8; 32],
) -> Result<(), Error> {
let (chunk_path, digest_str) = self.chunk_path(digest);
Self::create_marker_file(&chunk_path)
.map_err(|err| format_err!("clear chunk failed for {digest_str} - {err}"))?;
Ok(())
}
/// Helper to generate new empty marker file
fn create_marker_file(path: &Path) -> Result<(), Error> {
let mut create_options = CreateOptions::new();
if nix::unistd::Uid::effective().is_root() {
let uid = pbs_config::backup_user()?.uid;
let gid = pbs_config::backup_group()?.gid;
create_options = create_options.owner(uid).group(gid);
}
proxmox_sys::fs::replace_file(path, &[], create_options, false)
}
/// Mark chunk as expected to be present by writing a file the chunk store.
///
/// Used to mark chunks which are found in index files during phase 1 of garbage collection
/// for s3 datastores, but the marker file is not present and it is seemingly not a bad chunk.
/// This might happen if the local store cache is empty after datastore re-creation.
pub(crate) fn mark_chunk_as_expected(&self, digest: &[u8; 32]) -> Result<(), Error> {
let marker_path = self.chunk_expected_marker_path(digest);
Self::create_marker_file(&marker_path)
.map_err(|err| format_err!("mark chunk failed for {} - {err}", hex::encode(digest)))?;
Ok(())
}
/// Remove the chunk-expected marker file from the chunk store.
///
/// Used to remove the chunk-expected marker file during phase 2 of garbage collection. This
/// marker file is created during phase 1 of garbage collection in case the chunk or its zero
/// size marker file is not found in the chunk store, but still referenced by an index file,
/// flagging it as still required.
///
/// Returns true if the file was present and removed, false if the file did not exist.
pub(crate) fn clear_chunk_expected_mark(&self, digest: &[u8; 32]) -> Result<bool, Error> {
let marker_path = self.chunk_expected_marker_path(digest);
if let Err(err) = std::fs::remove_file(marker_path) {
if err.kind() != std::io::ErrorKind::NotFound {
return Err(err.into());
} else {
return Ok(false);
}
}
Ok(true)
}
/// Helper to generate marker file path for expected chunks
fn chunk_expected_marker_path(&self, digest: &[u8; 32]) -> PathBuf {
let (mut path, _digest_str) = self.chunk_path(digest);
path.set_extension(USING_MARKER_FILENAME_EXT);
path
}
/// Removes a chunk marker file from the `LocalDatastoreLruCache`s chunk store.
///
/// Callers must hold the per-chunk file lock in order to avoid races with renaming of corrupt
/// chunks by verifications and chunk inserts by backups.
pub(crate) fn remove_chunk_marker(&self, digest: &[u8; 32]) -> Result<(), Error> {
let (chunk_path, _digest_str) = self.chunk_path(digest);
std::fs::remove_file(chunk_path).map_err(Error::from)
}
pub fn relative_path(&self, path: &Path) -> PathBuf {
// unwrap: only `None` in unit tests
assert!(self.locker.is_some());
@ -917,7 +695,7 @@ impl ChunkStore {
// Check all .chunks subdirectories
for i in 0..64 * 1024 {
let mut l1path = chunk_dir.clone();
l1path.push(format!("{i:04x}"));
l1path.push(format!("{:04x}", i));
ChunkStore::check_permissions(&l1path, 0o750)?;
}
@ -926,76 +704,23 @@ impl ChunkStore {
ChunkStore::check_permissions(lockfile_path, 0o644)?;
Ok(())
}
/// Generates the path to the chunks lock file
pub(crate) fn chunk_lock_path(&self, digest: &[u8]) -> PathBuf {
let mut lock_path = Path::new(DATASTORE_LOCKS_DIR).join(self.name.clone());
let digest_str = hex::encode(digest);
lock_path.push(".chunks");
let prefix = digest_to_prefix(digest);
lock_path.push(&prefix);
lock_path.push(&digest_str);
lock_path
}
/// Get an exclusive lock on the chunks lock file
pub(crate) fn lock_chunk(
&self,
digest: &[u8],
timeout: Duration,
) -> Result<BackupLockGuard, Error> {
let lock_path = self.chunk_lock_path(digest);
let guard = crate::backup_info::lock_helper(self.name(), &lock_path, |path| {
pbs_config::open_backup_lockfile(path, Some(timeout), true)
})?;
Ok(guard)
}
/// Generate the next bad chunk file path for given digest. Returns the path as well as the bad
/// chunk counter.
pub(crate) fn next_bad_chunk_path(&self, digest: &[u8; 32]) -> (PathBuf, usize) {
let (mut chunk_path, digest_str) = self.chunk_path(digest);
let mut counter = 0;
loop {
chunk_path.set_file_name(ChunkExt::bad_chunk_filename(&digest_str, counter));
if chunk_path.exists() && counter < 9 {
counter += 1;
} else {
break;
}
}
(chunk_path, counter)
}
}
#[derive(PartialEq)]
/// Chunk iterator directory entry filename extension
enum ChunkExt {
None,
Bad,
UsedMarker,
}
impl ChunkExt {
fn bad_chunk_filename(digest_str: &str, counter: usize) -> String {
format!("{digest_str}.{counter}.bad")
}
}
#[test]
fn test_chunk_store1() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path();
let mut path = std::fs::canonicalize(".").unwrap(); // we need absolute path
path.push(".testdir");
let chunk_store = ChunkStore::open("test", path, DatastoreFSyncLevel::None);
if let Err(_e) = std::fs::remove_dir_all(".testdir") { /* ignore */ }
let chunk_store = ChunkStore::open("test", &path, DatastoreFSyncLevel::None);
assert!(chunk_store.is_err());
let user = nix::unistd::User::from_uid(nix::unistd::Uid::current())
.unwrap()
.unwrap();
let chunk_store =
ChunkStore::create("test", path, user.uid, user.gid, DatastoreFSyncLevel::None).unwrap();
ChunkStore::create("test", &path, user.uid, user.gid, DatastoreFSyncLevel::None).unwrap();
let (chunk, digest) = crate::data_blob::DataChunkBuilder::new(&[0u8, 1u8])
.build()
@ -1008,8 +733,8 @@ fn test_chunk_store1() {
assert!(exists);
let chunk_store =
ChunkStore::create("test", path, user.uid, user.gid, DatastoreFSyncLevel::None);
ChunkStore::create("test", &path, user.uid, user.gid, DatastoreFSyncLevel::None);
assert!(chunk_store.is_err());
temp_dir.close().unwrap();
if let Err(_e) = std::fs::remove_dir_all(".testdir") { /* ignore */ }
}

View File

@ -363,13 +363,13 @@ fn test_chunker1() {
for (_offset, len) in &chunks1 {
size1 += len;
}
println!("Chunks1:{size1}\n{chunks1:?}\n");
println!("Chunks1:{}\n{:?}\n", size1, chunks1);
let mut size2 = 0;
for (_offset, len) in &chunks2 {
size2 += len;
}
println!("Chunks2:{size2}\n{chunks2:?}\n");
println!("Chunks2:{}\n{:?}\n", size2, chunks2);
if size1 != 256 * 4 * 1024 {
panic!("wrong size for chunks1");

View File

@ -54,7 +54,12 @@ impl<W: Write> Write for CryptWriter<W> {
let count = self
.crypter
.update(&buf[..write_size], self.encr_buf.as_mut())
.map_err(|err| std::io::Error::other(format!("crypter update failed - {err}")))?;
.map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("crypter update failed - {}", err),
)
})?;
self.writer.write_all(&self.encr_buf[..count])?;

View File

@ -2,7 +2,6 @@ use std::io::Write;
use anyhow::{bail, Error};
use openssl::symm::{decrypt_aead, Mode};
use tokio::io::{AsyncRead, AsyncReadExt};
use proxmox_io::{ReadExt, WriteExt};
@ -239,26 +238,15 @@ impl DataBlob {
}
}
/// Load data blob via given sync ``reader`` and verify its CRC
/// Load blob from ``reader``, verify CRC
pub fn load_from_reader(reader: &mut dyn std::io::Read) -> Result<Self, Error> {
let mut data = Vec::with_capacity(1024 * 1024);
reader.read_to_end(&mut data)?;
Self::from_raw_with_crc_check(data)
}
/// Load data blob via given async ``reader`` and verify its CRC
pub async fn load_from_async_reader(
reader: &mut (dyn AsyncRead + Unpin + Send),
) -> Result<Self, Error> {
let mut data = Vec::with_capacity(1024 * 1024);
reader.read_to_end(&mut data).await?;
Self::from_raw_with_crc_check(data)
}
let blob = Self::from_raw(data)?;
/// Generates a data blob from raw input data and checks for matching CRC in header
fn from_raw_with_crc_check(raw_data: Vec<u8>) -> Result<Self, Error> {
let blob = Self::from_raw(raw_data)?;
blob.verify_crc()?;
Ok(blob)
}

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,19 @@ use anyhow::{bail, format_err, Error};
use proxmox_io::ReadExt;
use proxmox_sys::mmap::Mmap;
use proxmox_sys::process_locker::ProcessLockSharedGuard;
use proxmox_uuid::Uuid;
use pxar::accessor::{MaybeReady, ReadAt, ReadAtOperation};
use pbs_tools::lru_cache::LruCache;
use crate::chunk_stat::ChunkStat;
use crate::chunk_store::ChunkStore;
use crate::data_blob::{DataBlob, DataChunkBuilder};
use crate::file_formats;
use crate::index::{ChunkReadInfo, IndexFile};
use crate::read_chunk::ReadChunk;
use crate::{Chunker, ChunkerImpl};
/// Header format definition for dynamic index files (`.dixd`)
#[repr(C)]
@ -119,6 +124,8 @@ impl DynamicIndexReader {
bail!("got unknown magic number");
}
let ctime = proxmox_time::epoch_i64();
let index_size = stat.st_size as usize - header_size;
let index_count = index_size / 40;
if index_count * 40 != index_size {
@ -139,13 +146,14 @@ impl DynamicIndexReader {
_file: file,
size,
index,
ctime: i64::from_le(header.ctime),
ctime,
uuid: header.uuid,
index_csum: header.index_csum,
})
}
#[inline]
#[allow(clippy::cast_ptr_alignment)]
pub fn chunk_end(&self, pos: usize) -> u64 {
if pos >= self.index.len() {
panic!("chunk index out of range");
@ -196,7 +204,7 @@ impl IndexFile for DynamicIndexReader {
if pos >= self.index.len() {
None
} else {
Some(self.chunk_digest(pos))
Some(unsafe { &*(self.chunk_digest(pos).as_ptr() as *const [u8; 32]) })
}
}
@ -268,6 +276,8 @@ impl IndexFile for DynamicIndexReader {
/// Create dynamic index files (`.dixd`)
pub struct DynamicIndexWriter {
store: Arc<ChunkStore>,
_lock: ProcessLockSharedGuard,
writer: BufWriter<File>,
closed: bool,
filename: PathBuf,
@ -284,9 +294,10 @@ impl Drop for DynamicIndexWriter {
}
impl DynamicIndexWriter {
// Requires obtaining a shared chunk store lock beforehand
pub fn create(full_path: impl Into<PathBuf>) -> Result<Self, Error> {
let full_path = full_path.into();
pub fn create(store: Arc<ChunkStore>, path: &Path) -> Result<Self, Error> {
let shared_lock = store.try_shared_lock()?;
let full_path = store.relative_path(path);
let mut tmp_path = full_path.clone();
tmp_path.set_extension("tmp_didx");
@ -313,6 +324,8 @@ impl DynamicIndexWriter {
let csum = Some(openssl::sha::Sha256::new());
Ok(Self {
store,
_lock: shared_lock,
writer,
closed: false,
filename: full_path,
@ -323,6 +336,11 @@ impl DynamicIndexWriter {
})
}
// fixme: use add_chunk instead?
pub fn insert_chunk(&self, chunk: &DataBlob, digest: &[u8; 32]) -> Result<(bool, u64), Error> {
self.store.insert_chunk(chunk, digest)
}
pub fn close(&mut self) -> Result<[u8; 32], Error> {
if self.closed {
bail!(
@ -373,6 +391,138 @@ impl DynamicIndexWriter {
}
}
/// Writer which splits a binary stream into dynamic sized chunks
///
/// And store the resulting chunk list into the index file.
pub struct DynamicChunkWriter {
index: DynamicIndexWriter,
closed: bool,
chunker: ChunkerImpl,
stat: ChunkStat,
chunk_offset: usize,
last_chunk: usize,
chunk_buffer: Vec<u8>,
}
impl DynamicChunkWriter {
pub fn new(index: DynamicIndexWriter, chunk_size: usize) -> Self {
Self {
index,
closed: false,
chunker: ChunkerImpl::new(chunk_size),
stat: ChunkStat::new(0),
chunk_offset: 0,
last_chunk: 0,
chunk_buffer: Vec::with_capacity(chunk_size * 4),
}
}
pub fn stat(&self) -> &ChunkStat {
&self.stat
}
pub fn close(&mut self) -> Result<(), Error> {
if self.closed {
return Ok(());
}
self.closed = true;
self.write_chunk_buffer()?;
self.index.close()?;
self.stat.size = self.chunk_offset as u64;
// add size of index file
self.stat.size +=
(self.stat.chunk_count * 40 + std::mem::size_of::<DynamicIndexHeader>()) as u64;
Ok(())
}
fn write_chunk_buffer(&mut self) -> Result<(), Error> {
let chunk_size = self.chunk_buffer.len();
if chunk_size == 0 {
return Ok(());
}
let expected_chunk_size = self.chunk_offset - self.last_chunk;
if expected_chunk_size != self.chunk_buffer.len() {
bail!("wrong chunk size {} != {}", expected_chunk_size, chunk_size);
}
self.stat.chunk_count += 1;
self.last_chunk = self.chunk_offset;
let (chunk, digest) = DataChunkBuilder::new(&self.chunk_buffer)
.compress(true)
.build()?;
match self.index.insert_chunk(&chunk, &digest) {
Ok((is_duplicate, compressed_size)) => {
self.stat.compressed_size += compressed_size;
if is_duplicate {
self.stat.duplicate_chunks += 1;
} else {
self.stat.disk_size += compressed_size;
}
log::info!(
"ADD CHUNK {:016x} {} {}% {} {}",
self.chunk_offset,
chunk_size,
(compressed_size * 100) / (chunk_size as u64),
is_duplicate,
hex::encode(digest)
);
self.index.add_chunk(self.chunk_offset as u64, &digest)?;
self.chunk_buffer.truncate(0);
Ok(())
}
Err(err) => {
self.chunk_buffer.truncate(0);
Err(err)
}
}
}
}
impl Write for DynamicChunkWriter {
fn write(&mut self, data: &[u8]) -> std::result::Result<usize, std::io::Error> {
let chunker = &mut self.chunker;
let ctx = crate::chunker::Context::default();
let pos = chunker.scan(data, &ctx);
if pos > 0 {
self.chunk_buffer.extend_from_slice(&data[0..pos]);
self.chunk_offset += pos;
if let Err(err) = self.write_chunk_buffer() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
err.to_string(),
));
}
Ok(pos)
} else {
self.chunk_offset += data.len();
self.chunk_buffer.extend_from_slice(data);
Ok(data.len())
}
}
fn flush(&mut self) -> std::result::Result<(), std::io::Error> {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"please use close() instead of flush()",
))
}
}
struct CachedChunk {
range: Range<u64>,
data: Vec<u8>,
@ -503,11 +653,11 @@ impl<S: ReadChunk> BufferedDynamicReader<S> {
impl<S: ReadChunk> std::io::Read for BufferedDynamicReader<S> {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, std::io::Error> {
use std::io::Error;
use std::io::{Error, ErrorKind};
let data = match self.buffered_read(self.read_offset) {
Ok(v) => v,
Err(err) => return Err(Error::other(err.to_string())),
Err(err) => return Err(Error::new(ErrorKind::Other, err.to_string())),
};
let n = if data.len() > buf.len() {
@ -532,12 +682,15 @@ impl<S: ReadChunk> std::io::Seek for BufferedDynamicReader<S> {
SeekFrom::Current(offset) => (self.read_offset as i64) + offset,
};
use std::io::Error;
use std::io::{Error, ErrorKind};
if (new_offset < 0) || (new_offset > (self.archive_size as i64)) {
return Err(Error::other(format!(
"seek is out of range {} ([0..{}])",
new_offset, self.archive_size
)));
return Err(Error::new(
ErrorKind::Other,
format!(
"seek is out of range {} ([0..{}])",
new_offset, self.archive_size
),
));
}
self.read_offset = new_offset as u64;

View File

@ -1,14 +1,20 @@
use std::fs::File;
use std::io::Write;
use std::io::{Seek, SeekFrom};
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::ptr::NonNull;
use std::sync::Arc;
use anyhow::{bail, format_err, Context, Error};
use anyhow::{bail, format_err, Error};
use proxmox_io::ReadExt;
use proxmox_sys::process_locker::ProcessLockSharedGuard;
use proxmox_uuid::Uuid;
use crate::chunk_stat::ChunkStat;
use crate::chunk_store::ChunkStore;
use crate::data_blob::ChunkInfo;
use crate::file_formats;
use crate::index::{ChunkReadInfo, IndexFile};
@ -85,10 +91,6 @@ impl FixedIndexReader {
let ctime = i64::from_le(header.ctime);
let chunk_size = u64::from_le(header.chunk_size);
if !chunk_size.is_power_of_two() {
bail!("got non-power-of-two chunk size: {chunk_size}");
}
let index_length = size.div_ceil(chunk_size) as usize;
let index_size = index_length * 32;
@ -101,8 +103,6 @@ impl FixedIndexReader {
);
}
let chunk_size = usize::try_from(chunk_size)?;
let data = unsafe {
nix::sys::mman::mmap(
None,
@ -119,7 +119,7 @@ impl FixedIndexReader {
Ok(Self {
_file: file,
chunk_size,
chunk_size: chunk_size as usize,
size,
index_length,
index: data,
@ -215,32 +215,18 @@ impl IndexFile for FixedIndexReader {
}
}
struct MmapPtr(NonNull<std::ffi::c_void>);
impl MmapPtr {
fn header(&self) -> NonNull<FixedIndexHeader> {
self.0.cast::<FixedIndexHeader>()
}
fn index(&self) -> NonNull<u8> {
unsafe { self.0.byte_add(size_of::<FixedIndexHeader>()).cast::<u8>() }
}
}
pub struct FixedIndexWriter {
store: Arc<ChunkStore>,
file: File,
_lock: ProcessLockSharedGuard,
filename: PathBuf,
tmp_filename: PathBuf,
/// Most places use u32 because values are just a few MiB, but here
/// u64 is sightly more convenient for calculations involving size.
chunk_size: u64,
size: u64,
chunk_size: usize,
size: usize,
index_length: usize,
index_capacity: usize,
memory: Option<MmapPtr>,
index: *mut u8,
pub uuid: [u8; 16],
pub ctime: i64,
growable_size: bool,
}
// `index` is mmap()ed which cannot be thread-local so should be sendable
@ -256,27 +242,20 @@ impl Drop for FixedIndexWriter {
}
impl FixedIndexWriter {
/// The initial capacity, if the total size is unknown.
///
/// This capacity takes up the same amount of space as the header
/// and can refer to 128 Blocks * 4 MiB/Block = 512 MiB of content.
///
/// On systems with 4 KiB page size this value ensures that the
/// mapped length is a multiple of the page size, but this is not
/// strictly necessary.
const INITIAL_CAPACITY: usize = 4096 / 32;
// Requires obtaining a shared chunk store lock beforehand
#[allow(clippy::cast_ptr_alignment)]
pub fn create(
full_path: impl Into<PathBuf>,
known_size: Option<u64>,
chunk_size: u32,
store: Arc<ChunkStore>,
path: &Path,
size: usize,
chunk_size: usize,
) -> Result<Self, Error> {
let full_path = full_path.into();
let shared_lock = store.try_shared_lock()?;
let full_path = store.relative_path(path);
let mut tmp_path = full_path.clone();
tmp_path.set_extension("tmp_fidx");
let file = std::fs::OpenOptions::new()
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
@ -290,196 +269,91 @@ impl FixedIndexWriter {
panic!("got unexpected header size");
}
let chunk_size = u64::from(chunk_size);
if !chunk_size.is_power_of_two() {
bail!("got non-power-of-two chunk size: {chunk_size}");
}
let ctime = proxmox_time::epoch_i64();
let size = known_size.unwrap_or(0);
let uuid = Uuid::generate();
let (index_length, index_capacity) = match known_size {
Some(s) => {
let len = s.div_ceil(chunk_size).try_into()?;
(len, len)
}
None => (0, Self::INITIAL_CAPACITY),
};
let buffer = vec![0u8; header_size];
let header = unsafe { &mut *(buffer.as_ptr() as *mut FixedIndexHeader) };
let file_size = Self::file_size(index_capacity)?;
nix::unistd::ftruncate(&file, file_size)?;
header.magic = file_formats::FIXED_SIZED_CHUNK_INDEX_1_0;
header.ctime = i64::to_le(ctime);
header.size = u64::to_le(size as u64);
header.chunk_size = u64::to_le(chunk_size as u64);
header.uuid = *uuid.as_bytes();
let memory = MmapPtr(unsafe {
header.index_csum = [0u8; 32];
file.write_all(&buffer)?;
let index_length = size.div_ceil(chunk_size);
let index_size = index_length * 32;
nix::unistd::ftruncate(&file, (header_size + index_size) as i64)?;
let data = unsafe {
nix::sys::mman::mmap(
None,
std::num::NonZeroUsize::new(file_size as usize)
.ok_or_else(|| format_err!("index file size cannot be zero"))?,
std::num::NonZeroUsize::new(index_size)
.ok_or_else(|| format_err!("invalid index size"))?,
nix::sys::mman::ProtFlags::PROT_READ | nix::sys::mman::ProtFlags::PROT_WRITE,
nix::sys::mman::MapFlags::MAP_SHARED,
&file,
0,
header_size as i64,
)
}?);
let header = unsafe { memory.header().as_mut() };
header.magic = file_formats::FIXED_SIZED_CHUNK_INDEX_1_0;
header.ctime = i64::to_le(ctime);
header.chunk_size = u64::to_le(chunk_size);
header.uuid = *uuid.as_bytes();
}?
.as_ptr()
.cast::<u8>();
Ok(Self {
store,
file,
_lock: shared_lock,
filename: full_path,
tmp_filename: tmp_path,
chunk_size,
size,
index_length,
index_capacity,
memory: Some(memory),
index: data,
ctime,
uuid: *uuid.as_bytes(),
growable_size: known_size.is_none(),
})
}
/// Computes the size of a fidx file containing `index_length`
/// chunk digests.
///
/// Guarantees that the size fits into usize, isize and i64.
fn file_size(index_length: usize) -> Result<i64, Error> {
if index_length == 0 {
bail!("fidx file must have at least one chunk");
}
index_length
.checked_mul(32)
.and_then(|s| s.checked_add(size_of::<FixedIndexHeader>()))
.filter(|s| *s <= isize::MAX as usize)
.and_then(|s| i64::try_from(s).ok())
.ok_or_else(|| format_err!("fidx file size overflow for {index_length} chunks"))
}
/// If this returns an error, the sizes may be out of sync,
/// which is especially bad if the capacity was reduced.
fn set_index_capacity(&mut self, new_capacity: usize) -> Result<(), Error> {
if new_capacity == self.index_capacity {
return Ok(());
}
let old_size = Self::file_size(self.index_capacity)?;
let new_size = Self::file_size(new_capacity)?;
let Some(MmapPtr(index_addr)) = self.memory else {
bail!("Can't resize unmapped FixedIndexWriter");
};
nix::unistd::ftruncate(&self.file, new_size)?;
let new_index = unsafe {
nix::sys::mman::mremap(
index_addr,
old_size as usize,
new_size as usize,
nix::sys::mman::MRemapFlags::MREMAP_MAYMOVE,
None,
)
}?;
self.memory = Some(MmapPtr(new_index));
self.index_capacity = new_capacity;
Ok(())
}
/// Unmapping ensures future add and close operations fail.
fn set_index_capacity_or_unmap(&mut self, new_capacity: usize) -> Result<(), Error> {
self.set_index_capacity(new_capacity).map_err(|e| {
let unmap_result = self.unmap();
let message = format!(
"failed to resize index capacity from {} to {new_capacity} with backing file: {:?}",
self.index_capacity, self.tmp_filename
);
if let Err(unmap_err) = unmap_result {
e.context(message).context(unmap_err)
} else {
e.context(message)
}
})
}
/// Increase the content size to be at least `requested_size` and
/// ensure there is enough capacity.
///
/// Only writers that were created without a known size can grow.
/// The size also becomes fixed as soon as it is no longer divisible
/// by the block size, to ensure that only the last block can be
/// smaller.
pub fn grow_to_size(&mut self, requested_size: u64) -> Result<(), Error> {
if self.size < requested_size {
if !self.growable_size {
bail!("refusing to resize from {} to {requested_size}", self.size);
}
let new_len = requested_size.div_ceil(self.chunk_size).try_into()?;
if new_len as u64 * self.chunk_size != requested_size {
// not a full chunk, so this must be the last one
self.growable_size = false;
self.set_index_capacity_or_unmap(new_len)?;
} else if new_len > self.index_capacity {
let new_capacity = new_len
.checked_next_power_of_two()
.ok_or_else(|| format_err!("capacity overflow"))?;
self.set_index_capacity_or_unmap(new_capacity)?;
}
assert!(new_len <= self.index_capacity);
self.index_length = new_len;
self.size = requested_size;
}
Ok(())
}
/// The current length of the index.
pub fn index_length(&self) -> usize {
self.index_length
}
/// The current total size of the referenced content.
pub fn size(&self) -> u64 {
self.size
}
fn unmap(&mut self) -> Result<(), Error> {
if let Some(ptr) = self.memory.take() {
let len = Self::file_size(self.index_capacity).context(
"calculation of index file size for unmapping failed - this should never happen!",
)?;
if let Err(err) = unsafe { nix::sys::mman::munmap(ptr.0, len as usize) } {
bail!("unmap file {:?} failed - {}", self.tmp_filename, err);
}
let Some(index) = NonNull::new(self.index as *mut std::ffi::c_void) else {
return Ok(());
};
let index_size = self.index_length * 32;
if let Err(err) = unsafe { nix::sys::mman::munmap(index, index_size) } {
bail!("unmap file {:?} failed - {}", self.tmp_filename, err);
}
self.index = std::ptr::null_mut();
Ok(())
}
pub fn close(&mut self) -> Result<[u8; 32], Error> {
let Some(ptr) = &self.memory else {
if self.index.is_null() {
bail!("cannot close already closed index file.");
};
}
let index_size = self.index_length * 32;
let data = unsafe { std::slice::from_raw_parts(ptr.index().as_ptr(), index_size) };
let data = unsafe { std::slice::from_raw_parts(self.index, index_size) };
let index_csum = openssl::sha::sha256(data);
{
let header = unsafe { ptr.header().as_mut() };
header.index_csum = index_csum;
header.size = self.size.to_le();
}
self.unmap()?;
if self.index_length < self.index_capacity {
let file_size = Self::file_size(self.index_length)?;
nix::unistd::ftruncate(&self.file, file_size)?;
self.index_capacity = self.index_length;
}
let csum_offset = std::mem::offset_of!(FixedIndexHeader, index_csum);
self.file.seek(SeekFrom::Start(csum_offset as u64))?;
self.file.write_all(&index_csum)?;
self.file.flush()?;
if let Err(err) = std::fs::rename(&self.tmp_filename, &self.filename) {
bail!("Atomic rename file {:?} failed - {}", self.filename, err);
@ -488,10 +362,12 @@ impl FixedIndexWriter {
Ok(index_csum)
}
fn check_chunk_alignment(&self, offset: u64, chunk_len: u64) -> Result<usize, Error> {
let Some(pos) = offset.checked_sub(chunk_len) else {
pub fn check_chunk_alignment(&self, offset: usize, chunk_len: usize) -> Result<usize, Error> {
if offset < chunk_len {
bail!("got chunk with small offset ({} < {}", offset, chunk_len);
};
}
let pos = offset - chunk_len;
if offset > self.size {
bail!("chunk data exceeds size ({} >= {})", offset, self.size);
@ -513,10 +389,44 @@ impl FixedIndexWriter {
bail!("got unaligned chunk (pos = {})", pos);
}
Ok((pos / self.chunk_size) as usize)
Ok(pos / self.chunk_size)
}
fn add_digest(&mut self, index: usize, digest: &[u8; 32]) -> Result<(), Error> {
// Note: We want to add data out of order, so do not assume any order here.
pub fn add_chunk(&mut self, chunk_info: &ChunkInfo, stat: &mut ChunkStat) -> Result<(), Error> {
let chunk_len = chunk_info.chunk_len as usize;
let offset = chunk_info.offset as usize; // end of chunk
let idx = self.check_chunk_alignment(offset, chunk_len)?;
let (is_duplicate, compressed_size) = self
.store
.insert_chunk(&chunk_info.chunk, &chunk_info.digest)?;
stat.chunk_count += 1;
stat.compressed_size += compressed_size;
let digest = &chunk_info.digest;
log::info!(
"ADD CHUNK {} {} {}% {} {}",
idx,
chunk_len,
(compressed_size * 100) / (chunk_len as u64),
is_duplicate,
hex::encode(digest)
);
if is_duplicate {
stat.duplicate_chunks += 1;
} else {
stat.disk_size += compressed_size;
}
self.add_digest(idx, digest)
}
pub fn add_digest(&mut self, index: usize, digest: &[u8; 32]) -> Result<(), Error> {
if index >= self.index_length {
bail!(
"add digest failed - index out of range ({} >= {})",
@ -524,282 +434,29 @@ impl FixedIndexWriter {
self.index_length
);
}
self.add_digest_unchecked(index, digest)
}
fn add_digest_unchecked(&mut self, index: usize, digest: &[u8; 32]) -> Result<(), Error> {
let Some(ptr) = &self.memory else {
if self.index.is_null() {
bail!("cannot write to closed index file.");
};
}
let index_pos = index * 32;
unsafe {
let dst = ptr.index().as_ptr().add(index_pos);
let dst = self.index.add(index_pos);
dst.copy_from_nonoverlapping(digest.as_ptr(), 32);
}
Ok(())
}
/// Write the digest of a chunk into this index file.
///
/// The `start` and `size` parameters encode the range of
/// content that is backed up. It is verified that `start` is
/// aligned and that only the last chunk may be smaller.
///
/// If this writer has been created without a fixed size, the
/// index capacity and content size are increased automatically
/// until an incomplete chunk is encountered.
pub fn add_chunk(&mut self, start: u64, size: u32, digest: &[u8; 32]) -> Result<(), Error> {
let size = u64::from(size);
let Some(end) = start.checked_add(size) else {
bail!("add_chunk: start and size are too large: {start}+{size}");
};
self.grow_to_size(end)?;
let idx = self.check_chunk_alignment(end, size)?;
self.add_digest(idx, digest)
}
/// Copy the chunk hashes from a Reader to the start of this Writer.
///
/// If this writer is resizable the capacity may increase,
/// but the size and length stay the same.
pub fn clone_data_from(&mut self, reader: &FixedIndexReader) -> Result<(), Error> {
if self.chunk_size != reader.chunk_size as u64 {
bail!("can't reuse file with different chunk size");
if self.index_length != reader.index_count() {
bail!("clone_data_from failed - index sizes not equal");
}
let count = reader.index_count();
if self.growable_size && self.index_capacity < count {
self.set_index_capacity_or_unmap(count)?;
for i in 0..self.index_length {
self.add_digest(i, reader.index_digest(i).unwrap())?;
}
for i in 0..count.min(self.index_capacity) {
self.add_digest_unchecked(i, reader.index_digest(i).unwrap())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
const CS: u32 = 4096;
#[test]
fn test_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test_empty");
let mut w = FixedIndexWriter::create(&path, None, CS).unwrap();
assert!(w.add_digest(0, &[1u8; 32]).is_err(), "out of bounds");
assert_eq!(0, w.size);
assert_eq!(0, w.index_length(), "returns length, not capacity");
assert_eq!(FixedIndexWriter::INITIAL_CAPACITY, w.index_capacity);
assert!(w.close().is_err(), "should refuse to create empty file");
drop(w);
assert!(!fs::exists(path).unwrap());
dir.close().unwrap();
}
#[test]
fn test_single_partial_chunk() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test_single_partial_chunk");
let mut w = FixedIndexWriter::create(&path, None, CS).unwrap();
let size = CS as u64 - 1;
let expected = test_data(size);
w.grow_to_size(size).unwrap();
expected[0].add_to(&mut w);
w.close().unwrap();
drop(w);
check_with_reader(&path, size, &expected);
compare_to_known_size_writer(&path, size, &expected);
dir.close().unwrap();
}
#[test]
fn test_grow_to_multiples_of_chunk_size() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test_grow_to_multiples_of_chunk_size");
let mut w = FixedIndexWriter::create(&path, None, CS).unwrap();
let initial = FixedIndexWriter::INITIAL_CAPACITY;
let steps = [1, 2, initial, initial + 1, 5 * initial, 10 * initial + 1];
let expected = test_data(*steps.last().unwrap() as u64 * CS as u64);
let mut begin = 0;
for chunk_count in steps {
let last = &expected[chunk_count - 1];
w.grow_to_size(last.end).unwrap();
assert_eq!(last.index + 1, w.index_length());
assert!(w.add_digest(last.index + 1, &[1u8; 32]).is_err());
for c in expected[begin..chunk_count].iter().rev() {
c.add_to(&mut w);
}
begin = chunk_count;
}
w.close().unwrap();
drop(w);
let size = expected.len() as u64 * CS as u64;
check_with_reader(&path, size, &expected);
compare_to_known_size_writer(&path, size, &expected);
dir.close().unwrap();
}
#[test]
fn test_grow_to_misaligned_size() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test_grow_to_misaligned_size");
let mut w = FixedIndexWriter::create(&path, None, CS).unwrap();
let size = (FixedIndexWriter::INITIAL_CAPACITY as u64 + 42) * CS as u64 - 1; // last is not full
let expected = test_data(size);
w.grow_to_size(size).unwrap();
assert!(w.grow_to_size(size + 1).is_err(), "size must be fixed now");
assert_eq!(expected.len(), w.index_length());
assert!(w.add_digest(expected.len(), &[1u8; 32]).is_err());
for c in expected.iter().rev() {
c.add_to(&mut w);
}
w.close().unwrap();
drop(w);
check_with_reader(&path, size, &expected);
compare_to_known_size_writer(&path, size, &expected);
dir.close().unwrap();
}
#[test]
fn test_clone_data_from() {
let dir = TempDir::new().unwrap();
let size = (FixedIndexWriter::INITIAL_CAPACITY as u64 + 3) * CS as u64;
let mut expected = test_data(size);
let reused = dir.path().join("reused");
let mut w = FixedIndexWriter::create(&reused, Some(size), CS).unwrap();
for c in expected.iter() {
c.add_to(&mut w);
}
w.close().unwrap();
drop(w);
let reused = FixedIndexReader::open(&reused).unwrap();
let truncated = dir.path().join("truncated");
let size = size - CS as u64;
expected.pop();
let mut w = FixedIndexWriter::create(&truncated, Some(size), CS).unwrap();
w.clone_data_from(&reused).unwrap();
w.close().unwrap();
drop(w);
check_with_reader(&truncated, size, &expected);
compare_to_known_size_writer(&truncated, size, &expected);
let modified = dir.path().join("modified");
let mut w = FixedIndexWriter::create(&modified, None, CS).unwrap();
w.clone_data_from(&reused).unwrap();
{
let i = expected.len() / 2;
expected[i].digest[1] += 1;
let chunk = &expected[i];
let chunk_pos = chunk.end - chunk.size as u64;
w.add_chunk(chunk_pos, chunk.size, &chunk.digest).unwrap();
}
w.grow_to_size(size).unwrap();
w.close().unwrap();
drop(w);
check_with_reader(&modified, size, &expected);
compare_to_known_size_writer(&modified, size, &expected);
dir.close().unwrap();
}
struct TestChunk {
digest: [u8; 32],
index: usize,
size: u32,
end: u64,
}
impl TestChunk {
fn add_to(&self, w: &mut FixedIndexWriter) {
assert_eq!(
self.index,
w.check_chunk_alignment(self.end, self.size as u64).unwrap()
);
w.add_digest(self.index, &self.digest).unwrap();
}
}
fn test_data(size: u64) -> Vec<TestChunk> {
(0..size.div_ceil(CS as u64))
.map(|index| {
let mut digest = [0u8; 32];
let i = &index.to_le_bytes();
for c in digest.chunks_mut(i.len()) {
c.copy_from_slice(i);
}
let size = if ((index + 1) * CS as u64) <= size {
CS
} else {
(size % CS as u64) as u32
};
TestChunk {
digest,
index: index as usize,
size,
end: index * CS as u64 + size as u64,
}
})
.collect()
}
fn check_with_reader(path: &Path, size: u64, chunks: &[TestChunk]) {
let reader = FixedIndexReader::open(path).unwrap();
assert_eq!(size, reader.index_bytes());
assert_eq!(chunks.len(), reader.index_count());
for c in chunks {
assert_eq!(&c.digest, reader.index_digest(c.index).unwrap());
}
}
fn compare_to_known_size_writer(file: &Path, size: u64, chunks: &[TestChunk]) {
let mut path = file.to_path_buf();
path.set_extension("reference");
let mut w = FixedIndexWriter::create(&path, Some(size), CS).unwrap();
for c in chunks {
c.add_to(&mut w);
}
w.close().unwrap();
drop(w);
let mut reference = fs::read(file).unwrap();
let mut tested = fs::read(path).unwrap();
// ignore uuid and ctime
reference[8..32].fill(0);
tested[8..32].fill(0);
assert_eq!(reference, tested);
}
}

View File

@ -81,19 +81,6 @@
//! because running these operations concurrently is treated as a feature
//! on its own.
//!
//! For datastores with S3 backend there are further restrictions since
//! there are 3 types of locking mechanisms involved:
//! - per-chunk file lock
//! - chunk store mutex lock
//! - lru cache mutex lock
//!
//! Locks must always be acquired in this specific order to avoid deadlocks.
//! The per-chunk file lock is used to avoid holding a mutex lock during calls
//! into async contexts, which can deadlock otherwise. It must be held for the
//! whole time from starting an operation on the chunk until it is persisted
//! to s3 backend, local datastore cache and in-memory LRU cache where
//! required.
//!
//! ## Inter-process Locking
//!
//! We need to be able to restart the proxmox-backup service daemons, so
@ -192,7 +179,6 @@ pub mod data_blob_reader;
pub mod file_formats;
pub mod index;
pub mod manifest;
pub mod move_journal;
pub mod paperkey;
pub mod prune;
pub mod read_chunk;
@ -217,9 +203,8 @@ pub use store_progress::StoreProgress;
mod datastore;
pub use datastore::{
check_backup_owner, check_namespace_depth_limit, ensure_datastore_is_mounted,
get_datastore_mount_status, DataStore, DataStoreLookup, DatastoreBackend,
S3_CLIENT_REQUEST_COUNTER_BASE_PATH, S3_DATASTORE_IN_USE_MARKER,
check_backup_owner, ensure_datastore_is_mounted, get_datastore_mount_status, DataStore,
DatastoreBackend,
};
mod hierarchy;

View File

@ -70,11 +70,13 @@ impl ReadChunk for LocalChunkReader {
DatastoreBackend::S3(s3_client) => match self.store.cache() {
None => proxmox_async::runtime::block_on(fetch(Arc::clone(s3_client), digest))?,
Some(cache) => {
proxmox_async::runtime::block_on(cache.access(digest, s3_client.clone()))?
.ok_or(format_err!(
"unable to access chunk with digest {}",
hex::encode(digest)
))?
let mut cacher = self
.store
.cacher()?
.ok_or(format_err!("no cacher for datastore"))?;
proxmox_async::runtime::block_on(cache.access(digest, &mut cacher))?.ok_or(
format_err!("unable to access chunk with digest {}", hex::encode(digest)),
)?
}
},
};
@ -107,13 +109,14 @@ impl AsyncReadChunk for LocalChunkReader {
DatastoreBackend::S3(s3_client) => match self.store.cache() {
None => fetch(Arc::clone(s3_client), digest).await?,
Some(cache) => {
cache
.access(digest, s3_client.clone())
.await?
.ok_or(format_err!(
"unable to access chunk with digest {}",
hex::encode(digest)
))?
let mut cacher = self
.store
.cacher()?
.ok_or(format_err!("no cacher for datastore"))?;
cache.access(digest, &mut cacher).await?.ok_or(format_err!(
"unable to access chunk with digest {}",
hex::encode(digest)
))?
}
},
};

View File

@ -1,17 +1,53 @@
//! Use a local datastore as cache for operations on a datastore attached via
//! a network layer (e.g. via the S3 backend).
use std::future::Future;
use std::sync::Arc;
use anyhow::{bail, Error};
use http_body_util::BodyExt;
use pbs_tools::async_lru_cache::AsyncLruCache;
use pbs_tools::async_lru_cache::{AsyncCacher, AsyncLruCache};
use proxmox_s3_client::S3Client;
use crate::ChunkStore;
use crate::DataBlob;
#[derive(Clone)]
/// Cacher to fetch chunks from the S3 object store and insert them in the local cache store.
pub struct S3Cacher {
client: Arc<S3Client>,
store: Arc<ChunkStore>,
}
impl AsyncCacher<[u8; 32], ()> for S3Cacher {
fn fetch(
&self,
key: [u8; 32],
) -> Box<dyn Future<Output = Result<Option<()>, Error>> + Send + 'static> {
let client = Arc::clone(&self.client);
let store = Arc::clone(&self.store);
Box::new(async move {
let object_key = crate::s3::object_key_from_digest(&key)?;
match client.get_object(object_key).await? {
None => bail!("could not fetch object with key {}", hex::encode(key)),
Some(response) => {
let bytes = response.content.collect().await?.to_bytes();
let chunk = DataBlob::from_raw(bytes.to_vec())?;
store.insert_chunk(&chunk, &key)?;
Ok(Some(()))
}
}
})
}
}
impl S3Cacher {
pub fn new(client: Arc<S3Client>, store: Arc<ChunkStore>) -> Self {
Self { client, store }
}
}
/// LRU cache using local datastore for caching chunks
///
/// Uses a LRU cache, but without storing the values in-memory but rather
@ -34,35 +70,27 @@ impl LocalDatastoreLruCache {
///
/// Fails if the chunk cannot be inserted successfully.
pub fn insert(&self, digest: &[u8; 32], chunk: &DataBlob) -> Result<(), Error> {
let _lock = self.store.mutex().lock().unwrap();
// Safety: lock acquire above
unsafe {
self.store.insert_chunk_nolock(chunk, digest, false)?;
}
self.store.insert_chunk(chunk, digest)?;
self.cache.insert(*digest, (), |digest| {
// Safety: lock acquired above, this is executed inline!
unsafe {
self.store
.replace_chunk_with_marker_or_create_marker(&digest)
let (path, _digest_str) = self.store.chunk_path(&digest);
// Truncate to free up space but keep the inode around, since that
// is used as marker for chunks in use by garbage collection.
if let Err(err) = nix::unistd::truncate(&path, 0) {
if err != nix::errno::Errno::ENOENT {
return Err(Error::from(err));
}
}
Ok(())
})
}
/// Remove a chunk from the local datastore cache.
///
/// Callers to this method must assure that:
/// - no concurrent insert is being performed, the chunk store's mutex must be held.
/// - the chunk to be removed is no longer referenced by an index file.
/// - the chunk to be removed has not been inserted by an active writer (atime newer than
/// writer start time).
/// - there is no active writer in an old process, which could have inserted the chunk to be
/// deleted.
///
/// Fails if the chunk cannot be deleted successfully.
pub(crate) unsafe fn remove(&self, digest: &[u8; 32]) -> Result<(), Error> {
pub fn remove(&self, digest: &[u8; 32]) -> Result<(), Error> {
self.cache.remove(*digest);
self.store.remove_chunk_marker(digest)
let (path, _digest_str) = self.store.chunk_path(digest);
std::fs::remove_file(path).map_err(Error::from)
}
/// Access the locally cached chunk or fetch it from the S3 object store via the provided
@ -73,44 +101,75 @@ impl LocalDatastoreLruCache {
pub async fn access(
&self,
digest: &[u8; 32],
client: Arc<S3Client>,
cacher: &mut S3Cacher,
) -> Result<Option<DataBlob>, Error> {
let (path, _digest_str) = self.store.chunk_path(digest);
match std::fs::File::open(&path) {
Ok(mut file) => match DataBlob::load_from_reader(&mut file) {
// File was still cached with contents, load response from file
Ok(chunk) => {
let _lock = self.store.mutex().lock().unwrap();
self.cache.insert(*digest, (), |digest| {
// Safety: lock acquired above, this is executed inline
unsafe {
self.store
.replace_chunk_with_marker_or_create_marker(&digest)
}
})?;
Ok(Some(chunk))
if self
.cache
.access(*digest, cacher, |digest| {
let (path, _digest_str) = self.store.chunk_path(&digest);
// Truncate to free up space but keep the inode around, since that
// is used as marker for chunks in use by garbage collection.
if let Err(err) = nix::unistd::truncate(&path, 0) {
if err != nix::errno::Errno::ENOENT {
return Err(Error::from(err));
}
}
// File was empty, might have been evicted since
Ok(())
})
.await?
.is_some()
{
let (path, _digest_str) = self.store.chunk_path(digest);
let mut file = match std::fs::File::open(&path) {
Ok(file) => file,
Err(err) => {
// Expected chunk to be present since LRU cache has it, but it is missing
// locally, try to fetch again
if err.kind() == std::io::ErrorKind::NotFound {
let object_key = crate::s3::object_key_from_digest(digest)?;
match cacher.client.get_object(object_key).await? {
None => {
bail!("could not fetch object with key {}", hex::encode(digest))
}
Some(response) => {
let bytes = response.content.collect().await?.to_bytes();
let chunk = DataBlob::from_raw(bytes.to_vec())?;
self.store.insert_chunk(&chunk, digest)?;
std::fs::File::open(&path)?
}
}
} else {
return Err(Error::from(err));
}
}
};
let chunk = match DataBlob::load_from_reader(&mut file) {
Ok(chunk) => chunk,
Err(err) => {
use std::io::Seek;
// Check if file is empty marker file, try fetching content if so
if file.seek(std::io::SeekFrom::End(0))? == 0 {
let chunk = self.fetch_and_insert(client.clone(), digest).await?;
Ok(Some(chunk))
let object_key = crate::s3::object_key_from_digest(digest)?;
match cacher.client.get_object(object_key).await? {
None => {
bail!("could not fetch object with key {}", hex::encode(digest))
}
Some(response) => {
let bytes = response.content.collect().await?.to_bytes();
let chunk = DataBlob::from_raw(bytes.to_vec())?;
self.store.insert_chunk(&chunk, digest)?;
let mut file = std::fs::File::open(&path)?;
DataBlob::load_from_reader(&mut file)?
}
}
} else {
Err(err)
return Err(err);
}
}
},
Err(err) => {
// Failed to open file, missing
if err.kind() == std::io::ErrorKind::NotFound {
let chunk = self.fetch_and_insert(client.clone(), digest).await?;
Ok(Some(chunk))
} else {
Err(Error::from(err))
}
}
};
Ok(Some(chunk))
} else {
Ok(None)
}
}
@ -118,23 +177,4 @@ impl LocalDatastoreLruCache {
pub fn contains(&self, digest: &[u8; 32]) -> bool {
self.cache.contains(*digest)
}
async fn fetch_and_insert(
&self,
client: Arc<S3Client>,
digest: &[u8; 32],
) -> Result<DataBlob, Error> {
let object_key = crate::s3::object_key_from_digest(digest)?;
match client.get_object(object_key).await? {
None => {
bail!("could not fetch object with key {}", hex::encode(digest))
}
Some(response) => {
let bytes = response.content.collect().await?.to_bytes();
let chunk = DataBlob::from_raw(bytes.to_vec())?;
self.insert(digest, &chunk)?;
Ok(chunk)
}
}
}
}

View File

@ -195,39 +195,34 @@ impl BackupManifest {
crypt_config: Option<&CryptConfig>,
) -> Result<BackupManifest, Error> {
let json: Value = serde_json::from_slice(data)?;
let manifest: BackupManifest = serde_json::from_value(json)?;
let signature = json["signature"].as_str().map(String::from);
if let Some(crypt_config) = crypt_config {
manifest.check_signature(crypt_config)?;
}
if let Some(signature) = signature {
let expected_signature = hex::encode(Self::json_signature(&json, crypt_config)?);
Ok(manifest)
}
/// Verify the signature of the manifest by given crypt config.
pub fn check_signature(&self, crypt_config: &CryptConfig) -> Result<(), Error> {
if let Some(signature) = &self.signature {
let expected_signature = hex::encode(self.signature(crypt_config)?);
let fingerprint = &self.unprotected["key-fingerprint"];
if fingerprint != &Value::Null {
let fingerprint = Fingerprint::deserialize(fingerprint)?;
let config_fp = Fingerprint::new(crypt_config.fingerprint());
if config_fp != fingerprint {
bail!(
"wrong key - unable to verify signature since manifest's key {} does not match provided key {}",
fingerprint,
config_fp
);
let fingerprint = &json["unprotected"]["key-fingerprint"];
if fingerprint != &Value::Null {
let fingerprint = Fingerprint::deserialize(fingerprint)?;
let config_fp = Fingerprint::new(crypt_config.fingerprint());
if config_fp != fingerprint {
bail!(
"wrong key - unable to verify signature since manifest's key {} does not match provided key {}",
fingerprint,
config_fp
);
}
}
if signature != expected_signature {
bail!("wrong signature in manifest");
}
} else {
// not signed: warn/fail?
}
if *signature != expected_signature {
bail!("wrong signature in manifest");
}
} else {
// not signed: warn/fail?
}
Ok(())
let manifest: BackupManifest = serde_json::from_value(json)?;
Ok(manifest)
}
/// Get the verify state of the snapshot
@ -241,36 +236,6 @@ impl BackupManifest {
}
Ok(Some(serde_json::from_value::<SnapshotVerifyState>(verify)?))
}
/// Set the sync-source-signature used to detect snapshot changes across encrypted/decrypted
/// sync flows.
///
/// Always an HMAC-SHA256 like [`Self::signature`]; the input is the source's plain manifest for
/// encrypt-push, or the source's `signature` field (HMAC over its encrypted manifest) for
/// decrypt-pull from a regular encrypted backup. Not interchangeable across flows.
pub fn set_sync_source_signature(&mut self, signature: &[u8; 32]) -> Result<(), Error> {
let fingerprint = Fingerprint::new(*signature);
self.unprotected["sync-source-signature"] = serde_json::to_value(fingerprint)?;
Ok(())
}
/// Get the sync-source-signature used to detect snapshot changes across encrypted/decrypted
/// sync flows.
///
/// See [`Self::set_sync_source_signature`] for the two distinct flows that set this field
/// with different interpretations.
pub fn get_sync_source_signature(&self) -> Result<Option<Fingerprint>, Error> {
let value = if !self.unprotected["sync-source-signature"].is_null() {
&self.unprotected["sync-source-signature"]
} else if !self.unprotected["change-detection-fingerprint"].is_null() {
// fallback to legacy field for sync-source-signature
&self.unprotected["change-detection-fingerprint"]
} else {
return Ok(None);
};
Ok(Some(Deserialize::deserialize(value)?))
}
}
impl TryFrom<super::DataBlob> for BackupManifest {

View File

@ -1,149 +0,0 @@
//! Per-datastore journal used to coordinate snapshot renames with a concurrent garbage collection
//! phase-1 mark.
//!
//! # Race fixed
//!
//! GC phase 1 first calls `list_index_files()` to snapshot the set of absolute index-file paths in
//! the datastore, then iterates namespaces live and touches the atime of every referenced chunk.
//! If a `move_group`/`move_namespace` relocates a snapshot between those two steps, and the target
//! namespace is visited by GC before the source (`readdir(2)` order, not deterministic), the moved
//! index is at neither location when GC looks: missing from the target (already iterated) and
//! missing from the source (iterated after the rename). Its old path lands in the leftover
//! `unprocessed_index_list` and is discarded as a vanished file. Chunks referenced only by that
//! index never get their atime bumped and phase 2 sweeps them.
//!
//! # Protocol
//!
//! Write-ahead journal for renames:
//!
//! - **Before** renaming a snapshot, the move records the new path of each index file it is about
//! to create, under a brief exclusive flock.
//! - At the end of phase-1 mark, GC acquires the same exclusive flock, reads every recorded path,
//! runs the normal `index_mark_used_chunks` on each, truncates, and releases before entering phase 2.
//!
//! Why write-before-rename rather than write-after-rename with a long-held shared lock by each
//! mover: the invariant is "if the new path exists, a journal entry for it exists too". So the
//! drain - which runs only after iteration finishes - is guaranteed to catch anything iteration
//! missed:
//!
//! - If the source-ns iteration found the index at the old path, its chunks are already marked.
//! The journal entry is then either a redundant re-mark (rename completed before the drain, LRU
//! dedups it) or a no-op skip (rename not yet, `open_index_reader` returns `None`) - harmless
//! either way.
//! - If the source-ns iteration missed it, then the rename already happened by the time iteration
//! reached source, which is before the drain, so `open_index_reader(new_path)` at drain time
//! succeeds and marks the chunks.
//!
//! A move that crashes between the journal write and the rename leaves a "ghost" entry. The
//! drain's `open_index_reader` returns `None` and skips, and the truncate step clears it. This is
//! handled by the existing vanished-file logic in the caller.
//!
//! The file lives under `/run/proxmox-backup/locks/<datastore>/move-journal`. Tmpfs is correct
//! here: a reboot aborts any in-progress GC, and the next GC rebuilds state from a fresh
//! `list_index_files()` against the post-move filesystem - there is nothing worth persisting.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{bail, format_err, Context, Error};
use nix::sys::stat::Mode;
use proxmox_sys::fs::{open_file_locked, CreateOptions};
use pbs_config::backup_user;
use crate::backup_info::DATASTORE_LOCKS_DIR;
const JOURNAL_FILENAME: &str = "move-journal";
const APPEND_LOCK_TIMEOUT: Duration = Duration::from_secs(10);
// Long enough to cover any in-flight append, if it takes longer than this something is very wrong
// and we'd rather fail GC than hang forever.
const DRAIN_LOCK_TIMEOUT: Duration = Duration::from_secs(10);
fn journal_path(datastore_name: &str) -> PathBuf {
Path::new(DATASTORE_LOCKS_DIR)
.join(datastore_name)
.join(JOURNAL_FILENAME)
}
fn ensure_parent(path: &Path) -> Result<(), Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create move-journal parent dir {parent:?}"))?;
}
Ok(())
}
fn open_locked_exclusive(path: &Path, timeout: Duration) -> Result<File, Error> {
ensure_parent(path)?;
let user = backup_user()?;
let options = CreateOptions::new()
.perm(Mode::from_bits_truncate(0o660))
.owner(user.uid)
.group(user.gid);
open_file_locked(path, timeout, true, options)
.with_context(|| format!("failed to acquire exclusive move-journal lock at {path:?}"))
}
/// Append one or more absolute index-file paths to the journal under a brief exclusive flock. The
/// caller passes the *post-rename* paths, the rename must happen after this returns.
pub fn append_moved_indices(datastore_name: &str, paths: &[PathBuf]) -> Result<(), Error> {
if paths.is_empty() {
return Ok(());
}
let mut buf = Vec::new();
for path in paths {
if !path.is_absolute() {
bail!("move-journal: refusing to record non-absolute path {path:?}");
}
let s = path
.to_str()
.ok_or_else(|| format_err!("move-journal: non-UTF-8 path {path:?}"))?;
if s.as_bytes().contains(&b'\n') {
bail!("move-journal: path contains newline {path:?}");
}
buf.extend_from_slice(s.as_bytes());
buf.push(b'\n');
}
let path = journal_path(datastore_name);
let mut file = open_locked_exclusive(&path, APPEND_LOCK_TIMEOUT)?;
file.write_all(&buf)
.context("failed to append to move journal")?;
Ok(())
}
/// Drain the journal under an exclusive lock, calling `f` for each recorded path. Blocks only for
/// the brief window of a concurrent append. After the callback runs on every entry, the journal is
/// truncated under the same lock.
///
/// On a processing error the entry is left in the journal (no truncate) so the next GC will retry.
pub fn drain_move_journal<F>(datastore_name: &str, mut f: F) -> Result<(), Error>
where
F: FnMut(&Path) -> Result<(), Error>,
{
let path = journal_path(datastore_name);
let mut file = open_locked_exclusive(&path, DRAIN_LOCK_TIMEOUT)?;
file.seek(SeekFrom::Start(0))
.context("failed to rewind move journal for draining")?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.context("failed to read move journal")?;
for line in contents.lines() {
let entry = line.trim();
if entry.is_empty() {
continue;
}
f(Path::new(entry))
.with_context(|| format!("move-journal: processing '{entry}' failed"))?;
}
file.set_len(0)
.context("failed to truncate move journal after drain")?;
Ok(())
}

View File

@ -110,7 +110,7 @@ fn paperkey_html<W: Write>(
writeln!(output, "<body>")?;
if let Some(subject) = subject {
writeln!(output, "<p>Subject: {subject}</p>")?;
writeln!(output, "<p>Subject: {}</p>", subject)?;
}
if is_master {
@ -136,9 +136,10 @@ fn paperkey_html<W: Write>(
writeln!(output, "<img")?;
writeln!(
output,
"width=\"{img_size_pt}pt\" height=\"{img_size_pt}pt\""
"width=\"{}pt\" height=\"{}pt\"",
img_size_pt, img_size_pt
)?;
writeln!(output, "src=\"data:image/svg+xml;base64,{qr_code}\"/>")?;
writeln!(output, "src=\"data:image/svg+xml;base64,{}\"/>", qr_code)?;
writeln!(output, "</center>")?;
writeln!(output, "</div>")?;
}
@ -155,7 +156,7 @@ fn paperkey_html<W: Write>(
writeln!(output, "-----BEGIN PROXMOX BACKUP KEY-----")?;
for line in lines {
writeln!(output, "{line}")?;
writeln!(output, "{}", line)?;
}
writeln!(output, "-----END PROXMOX BACKUP KEY-----")?;
@ -169,9 +170,10 @@ fn paperkey_html<W: Write>(
writeln!(output, "<img")?;
writeln!(
output,
"width=\"{img_size_pt}pt\" height=\"{img_size_pt}pt\""
"width=\"{}pt\" height=\"{}pt\"",
img_size_pt, img_size_pt
)?;
writeln!(output, "src=\"data:image/svg+xml;base64,{qr_code}\"/>")?;
writeln!(output, "src=\"data:image/svg+xml;base64,{}\"/>", qr_code)?;
writeln!(output, "</center>")?;
writeln!(output, "</div>")?;
@ -189,7 +191,7 @@ fn paperkey_text<W: Write>(
is_private: bool,
) -> Result<(), Error> {
if let Some(subject) = subject {
writeln!(output, "Subject: {subject}\n")?;
writeln!(output, "Subject: {}\n", subject)?;
}
if is_private {
@ -202,7 +204,7 @@ fn paperkey_text<W: Write>(
let qr_code = generate_qr_code("utf8i", block)?;
let qr_code = String::from_utf8(qr_code)
.map_err(|_| format_err!("Failed to read qr code (got non-utf8 data)"))?;
writeln!(output, "{qr_code}")?;
writeln!(output, "{}", qr_code)?;
writeln!(output, "{}", char::from(12u8))?; // page break
}
return Ok(());
@ -210,7 +212,7 @@ fn paperkey_text<W: Write>(
writeln!(output, "-----BEGIN PROXMOX BACKUP KEY-----")?;
for line in lines {
writeln!(output, "{line}")?;
writeln!(output, "{}", line)?;
}
writeln!(output, "-----END PROXMOX BACKUP KEY-----")?;
@ -218,7 +220,7 @@ fn paperkey_text<W: Write>(
let qr_code = String::from_utf8(qr_code)
.map_err(|_| format_err!("Failed to read qr code (got non-utf8 data)"))?;
writeln!(output, "{qr_code}")?;
writeln!(output, "{}", qr_code)?;
Ok(())
}

View File

@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, format_err, Error};
use proxmox_s3_client::{DeleteObjectError, S3ObjectKey};
use proxmox_s3_client::S3ObjectKey;
/// Object key prefix to group regular datastore contents (not chunks)
pub const S3_CONTENT_PREFIX: &str = ".cnt";
@ -48,22 +48,6 @@ pub fn object_key_from_digest_with_suffix(
S3ObjectKey::try_from(object_key_string.as_str())
}
/// Log errors from delete objects api calls
pub(crate) fn log_s3_delete_objects_errors(errors: &[DeleteObjectError]) {
for error in errors {
log::error!(
"delete object failed: {} {} {}",
error
.key
.as_ref()
.map(|key| key.to_string())
.unwrap_or_else(|| "None".into()),
error.code.as_deref().unwrap_or("None"),
error.message.as_deref().unwrap_or("None"),
);
}
}
#[test]
fn test_object_key_from_path() {
let path = Path::new("vm/100/2025-07-14T14:20:02Z");

View File

@ -16,7 +16,6 @@ use pbs_api_types::{
};
use crate::backup_info::BackupDir;
use crate::datastore::DataStoreLookup;
use crate::dynamic_index::DynamicIndexReader;
use crate::fixed_index::FixedIndexReader;
use crate::index::IndexFile;
@ -123,9 +122,9 @@ impl SnapshotReader {
/// Returns an iterator for all chunks not skipped by `skip_fn`.
pub fn chunk_iterator<F: Fn(&[u8; 32]) -> bool>(
&'_ self,
&self,
skip_fn: F,
) -> Result<SnapshotChunkIterator<'_, F>, Error> {
) -> Result<SnapshotChunkIterator<F>, Error> {
SnapshotChunkIterator::new(self, skip_fn)
}
}
@ -163,12 +162,10 @@ impl<F: Fn(&[u8; 32]) -> bool> Iterator for SnapshotChunkIterator<'_, F> {
),
};
let lookup = DataStoreLookup::with(
let datastore = DataStore::lookup_datastore(
self.snapshot_reader.datastore_name(),
Operation::Read,
None,
);
let datastore = DataStore::lookup_datastore(lookup)?;
Some(Operation::Read),
)?;
let order =
datastore.get_chunks_in_order(&*index, &self.skip_fn, |_| Ok(()))?;

View File

@ -108,7 +108,6 @@ pub fn update_active_operations(
Operation::Write => ActiveOperationStats { read: 0, write: 1 },
Operation::Lookup => ActiveOperationStats { read: 0, write: 0 },
};
let mut found_entry = false;
let mut updated_tasks: Vec<TaskOperations> = match file_read_optional_string(&path)? {
Some(data) => serde_json::from_str::<Vec<TaskOperations>>(&data)?
.iter_mut()
@ -117,7 +116,6 @@ pub fn update_active_operations(
Some(stat) if pid == task.pid && stat.starttime != task.starttime => None,
Some(_) => {
if pid == task.pid {
found_entry = true;
match operation {
Operation::Read => task.active_operations.read += count,
Operation::Write => task.active_operations.write += count,
@ -134,7 +132,7 @@ pub fn update_active_operations(
None => Vec::new(),
};
if !found_entry {
if updated_tasks.is_empty() {
updated_tasks.push(TaskOperations {
pid,
starttime,

View File

@ -97,7 +97,7 @@ impl<R: AsyncRead + AsyncSeek + Unpin> FuseLoopSession<R> {
fn write_pidfile(path: &Path) -> Result<(), Error> {
let pid = unsafe { libc::getpid() };
let mut file = File::create(path)?;
write!(file, "{pid}")?;
write!(file, "{}", pid)?;
Ok(())
}
@ -267,7 +267,8 @@ fn get_backing_file(loopdev: &str) -> Result<String, Error> {
})?;
let block_path = PathBuf::from(format!(
"/sys/devices/virtual/block/loop{num}/loop/backing_file"
"/sys/devices/virtual/block/loop{}/loop/backing_file",
num
));
let backing_file = read_to_string(block_path).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {

View File

@ -65,7 +65,7 @@ use loop_ioctl::*;
pub fn get_or_create_free_dev() -> Result<String, Error> {
let ctrl_file = File::open(LOOP_CONTROL)?;
let free_num = unsafe { ioctl_ctrl_get_free(ctrl_file.as_raw_fd())? };
let loop_file_path = format!("{LOOP_NAME}{free_num}");
let loop_file_path = format!("{}{}", LOOP_NAME, free_num);
Ok(loop_file_path)
}

View File

@ -1,10 +1,7 @@
use std::io::Write;
use std::os::fd::AsRawFd;
use std::path::Path;
use anyhow::{bail, format_err, Context, Error};
use nix::sys::stat::Mode;
use nix::unistd::{Gid, Uid};
use serde::{Deserialize, Serialize};
use proxmox_lang::try_block;
@ -239,49 +236,24 @@ impl KeyConfig {
/// Store a KeyConfig to path
pub fn store<P: AsRef<Path>>(&self, path: P, replace: bool) -> Result<(), Error> {
self.store_with(path, replace, None, None, None)
}
/// Store a KeyConfig to path with given ownership and mode.
/// Requires the process to run with permissions to do so.
pub fn store_with<P: AsRef<Path>>(
&self,
path: P,
replace: bool,
mode: Option<Mode>,
owner: Option<Uid>,
group: Option<Gid>,
) -> Result<(), Error> {
let path: &Path = path.as_ref();
let data = serde_json::to_string(self)?;
try_block!({
if replace {
let mode =
mode.unwrap_or(nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR);
let mut create_options = CreateOptions::new().perm(mode);
if let Some(owner) = owner {
create_options = create_options.owner(owner);
}
if let Some(group) = group {
create_options = create_options.group(group);
}
replace_file(path, data.as_bytes(), create_options, true)?;
let mode = nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR;
replace_file(path, data.as_bytes(), CreateOptions::new().perm(mode), true)?;
} else {
use std::os::unix::fs::OpenOptionsExt;
let mode = mode.map(|m| m.bits()).unwrap_or(0o0600);
let mut file = std::fs::OpenOptions::new()
.write(true)
.mode(mode)
.mode(0o0600)
.create_new(true)
.open(path)?;
file.write_all(data.as_bytes())?;
let fd = file.as_raw_fd();
nix::unistd::fchown(fd, owner, group)?;
nix::unistd::fsync(fd)?;
}
Ok(())
@ -298,7 +270,7 @@ pub fn load_and_decrypt_key(
passphrase: &dyn Fn() -> Result<Vec<u8>, Error>,
) -> Result<([u8; 32], i64, Fingerprint), Error> {
decrypt_key(&file_get_contents(path)?, passphrase)
.with_context(|| format!("failed to load decryption key from {path:?}"))
.with_context(|| format!("failed to load decryption key from {:?}", path))
}
/// Decrypt a KeyConfig from raw keydata.

Some files were not shown because too many files have changed in this diff Show More