Compare commits

..

38 Commits

Author SHA1 Message Date
Thomas Lamprecht
4be9fd98f2 bump version to 3.4.8-3
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2026-02-18 15:55:15 +01:00
Thomas Lamprecht
abcfbac411 update proxmox-rest-server to 0.8.10
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2026-02-18 15:55:11 +01:00
Thomas Lamprecht
7016c96b28 bump version to 3.4.8-2
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-12-22 17:32:18 +01:00
Thomas Lamprecht
f42b10bcfb update proxmox-apt and related api-types
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-12-22 17:31:44 +01:00
Thomas Lamprecht
3c27db8a53 bump version to 3.4.8-1
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-10-27 18:07:27 +01:00
Thomas Lamprecht
b13f9efc5e d/changelog: improve historic entry
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-10-27 18:07:27 +01:00
Dominik Csapak
d90d7ff230 api: admin: datastore: optimize groups api call
Currently we always touch all files for each snapshot in a group when
listing them, even though we don't need all that info.

We're only interested in getting either the last finished snapshot
information, or the last unfinished one (which must the only one in
normal use, we can't have multiple unfinished snapshots usually)

Instead of getting all the information upfront, use the snapshot
iterator of the group to get only the id, sort them by time, and
use the first we're interested in, getting the snapshot specific info
only for those we want to check.

In my (admittedly extreme) setup with ~600 groups with ~1000 snapshots
each, this changes the time this api call needs from ~40s to <1s on a
relatively fast disk.

While at it, lift the restriction of only returning groups with
snapshots in them, now returning also empty ones.

To keep api compatibility, use a timestamp of 0 for those, as no valid
backup could have been made at that time anyway.

This API call is currently used by the web UI to add the group notes
to the backup snapshot tree in the datastore content view, so while it
will help in a few cases, it will not make the listing of all
snapshots itself faster.

(cherry picked from commit e59d33f4ce)

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
Link: https://lore.proxmox.com/20251008134344.3512958-7-d.csapak@proxmox.com
 [TL: add context for where the UI uses this API call]
Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
Link: https://lore.proxmox.com/20251027103123.101013-4-c.ebner@proxmox.com
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-10-27 17:33:30 +01:00
Dominik Csapak
7c7a05cf6b api: admin: datastore: factor out 'get_group_owner'
and change the `eprintln` to a `log::warn`

(cherry picked from commit 9af3b391b6)

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
Link: https://lore.proxmox.com/20251008134344.3512958-6-d.csapak@proxmox.com
Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
Link: https://lore.proxmox.com/20251027103123.101013-3-c.ebner@proxmox.com
2025-10-27 17:33:30 +01:00
Dominik Csapak
ca08eba0b3 api: admin: datastore: refactor BackupGroup to GroupListItem conversion
We will reuse this later.

No functionial change intended.

(cherry picked from commit 45693bfa7a)

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
Link: https://lore.proxmox.com/20251008134344.3512958-5-d.csapak@proxmox.com
Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
Link: https://lore.proxmox.com/20251027103123.101013-2-c.ebner@proxmox.com
2025-10-27 17:33:30 +01:00
Christian Ebner
c096925472 fix #6566: backup: api: conditionally drop group and snapshot locks
To guarantee consistency by possible concurrent operations, the
backup protocol locks the backup group, the previous backup
snapshot (if any) and holds a lock for the newly created backup
snapshot. All of these are currently stored in the backup worker
task, only released on its destruction.

The backup API however signals a successful backup via the return
status of the `finish` call, while still holding the locks.
Therefore, an immediate subsequent backup of the client to the same
group can fail because the locks cannot be acquired until the previous
backup task is completely destroyed, which can however outlive the
`finish` return for some time. This manifests in e.g. a push sync job
failing.

To fix this, store the lock guards inside the RPC environments shared
state instead, allowing to selectively drop the locks on successful
backup finish. On error, hold the locks until the cleanup was
successful.

Immediate verification of new snapshots already downgraded the lock
by dropping the exclusive lock and getting a shared lock. Since the
dropping is now already handled by the finish call, only gathering
the shared lock is required. While there is now a larger time window
for concurrent prunes, the underlying possible race between
verification and prune remains in place.

Backported from https://git.proxmox.com/?p=proxmox-backup.git;a=commit;h=b1ece6c70c7785191321525576ed6f53e9c4bc18

Fixes: https://bugzilla.proxmox.com/show_bug.cgi?id=6566
Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
Link: https://lore.proxmox.com/all/20251001112251.3788-1-c.ebner@proxmox.com
2025-10-02 10:00:19 +02:00
Fabian Grünbichler
48c7a01008 bump version to 3.4.7-1
Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
2025-10-01 13:53:36 +02:00
Fabian Grünbichler
0a9cb27e68 update to proxmox-auth-api 0.4.9
it includes fixes for the HTTP-only auth flow.

Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
2025-10-01 13:52:32 +02:00
Stoiko Ivanov
847d39c353 pbs3to4: bootloader: only allow systemd-boot before upgrade when used
This carries over the changes from pve-manager:
7e168453 ("pve8to9: only allow systemd-boot when it is actually used before upgrade")

additionally it pushes the check for systemd-boot being present to the
bottom (there is an early return for the single case which is not
problematic (used by p-b-t on a system still on bookworm) - and logs a
failure with a link to the upgrade guide in all cases.
The previous suggestion of installing systemd-boot-tools and
systemd-boot-efi explicitly is not fitting when systemd-boot is not
really used for booting (mostly: secure-boot enabled p-b-t setups)

Signed-off-by: Stoiko Ivanov <s.ivanov@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
Tested-by: Shannon Sterz <s.sterz@proxmox.com>
Link: https://lore.proxmox.com/20250821141719.4130062-4-s.ivanov@proxmox.com
2025-08-22 15:41:33 +02:00
Stoiko Ivanov
2a36f7016c pbs3to4: use boolean variable for systemd-boot installation state
while it's not saving too much run-time wise (each branch stats the
file only once), it's a bit easier to read.

Signed-off-by: Stoiko Ivanov <s.ivanov@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
Tested-by: Shannon Sterz <s.sterz@proxmox.com>
Link: https://lore.proxmox.com/20250821141719.4130062-3-s.ivanov@proxmox.com
2025-08-22 15:41:33 +02:00
Stoiko Ivanov
e7beb64391 pbs3to4: fix logic error and typo in log message
/usr/share/doc/systemd-boot/changelog.Debian.gz existing indicates
that system-boot is installed (the `!` was in error).

additionally fix a typo in the log message.

Fixes: 94cc9903 ("bin: pbs3to4: adapt boot-loader checks to trixie")
Signed-off-by: Stoiko Ivanov <s.ivanov@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
Tested-by: Shannon Sterz <s.sterz@proxmox.com>
Link: https://lore.proxmox.com/20250821141719.4130062-2-s.ivanov@proxmox.com
2025-08-22 15:41:33 +02:00
Fabian Grünbichler
f37354cea2 bump version to 3.4.6-1
Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
2025-08-13 13:11:58 +02:00
Dominik Csapak
aa829571cf ui: webauthn view: make sure renderers are html encoded
to avoid interpreting html elements in these options

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
2025-08-13 13:07:19 +02:00
Fabian Grünbichler
d543ff8b27 bump network-api dep, add to d/control
Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
2025-08-13 11:10:14 +02:00
Dominik Csapak
d41a83268b fixup type move from pbs-api-types to proxmox-network-api
some types moved crate, so adapt here to the correct location, otherwise
it'll not build with newer pbs-api-types on bookworm

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
Tested-by: Stefan Hanreich <s.hanreich@proxmox.com>
Link: https://lore.proxmox.com/all/20250813081959.1112692-1-d.csapak@proxmox.com
2025-08-13 11:04:00 +02:00
Fabian Grünbichler
5327a402a4 bump version to 3.4.5-1
Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
2025-08-11 15:26:05 +02:00
Fabian Grünbichler
524e5c09a0 pbs3to4: slightly reflow systemd-boot messages
these are rather long otherwise..

Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
(cherry picked from commit e48de65ce1)
2025-08-11 12:54:04 +02:00
Fabian Grünbichler
cc50ee772a pbs3to4: reformat long messages
Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
(cherry picked from commit 50a65d681d)
2025-08-11 12:54:04 +02:00
Stoiko Ivanov
cf8a658ea2 bin: pbs3to4: adapt boot-loader checks to trixie
basically carry-over the checks from pve-manger for pve8to9 [0]:
* 65ffcdd0 ("cli: pve8to9: rework boot-loader suggestions for trixie")
* 7cc36772 ("8 to 9 checks: do not ask bootctl if systemd-boot is used.")
* 2d79b567 ("8 to 9 checks: check for removable grub-install")

Signed-off-by: Stoiko Ivanov <s.ivanov@proxmox.com>
Link: https://lore.proxmox.com/all/20250811091135.127299-1-s.ivanov@proxmox.com
(cherry picked from commit 94cc99033b)
2025-08-11 12:54:04 +02:00
Thomas Lamprecht
8ac8330818 d/control: recommend proxmox-network-interface-pinning package for PBS
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-08-06 13:03:22 +02:00
Thomas Lamprecht
9bcaa05731 bump version to 3.4.4-1
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-08-06 12:30:32 +02:00
Fiona Ebner
c6d5c11639 pbs3to4: clippy: allow always true comparision against minimal value
MIN_PBS_PKGREL is a constant that is zero, so the check is currently
superfluous, but that might not always be the case.

Signed-off-by: Fiona Ebner <f.ebner@proxmox.com>
Link: https://lore.proxmox.com/20250806100941.59272-3-f.ebner@proxmox.com
2025-08-06 12:22:44 +02:00
Fiona Ebner
cf9334627c pbs3to4: add check for spelling of pbs-test repo
Logic copied and messages adapted from the pve8to9 checker script in
Proxmox VE.

Signed-off-by: Fiona Ebner <f.ebner@proxmox.com>
Link: https://lore.proxmox.com/20250806100941.59272-2-f.ebner@proxmox.com
2025-08-06 12:22:44 +02:00
Thomas Lamprecht
f3d48c390b add pbs-network-config-commit systemd service
This mirrors the 'pvenetcommit' service from Proxmox VE, but is even a
bit simpler as we do not care about OVS.

Order the service after the 'pvenetcommit' service so that we ensure
that it can nicely work in a PVE + PBS co-installed environment, even
if one service (probably PVE) would get more complex in the future.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-29 20:08:48 +02:00
Dominik Csapak
b6a34622ee tape: increase timeout for moving medium in changer to 45 minutes
Increase the time out from the current 5 minutes to 45 minutes.
According to documentation from vendors (e.g., for HP see [0]) moving
a medium between slots or to/from a drive can take up a much longer
time than 5 minutes. (up to 38 minutes in a HP library) so increase
the timeout we use here to something that exceeds these
recommendations.

[0]: https://support.hpe.com/hpesc/public/docDisplay?docId=sd00001714en_us&page=GUID-D7147C7F-2016-0901-065E-00000000072C.html

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
Link: https://lore.proxmox.com/20250725113657.3815270-1-d.csapak@proxmox.com
 [TL: commit message fix-ups]
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
(cherry picked from commit ac5bcc36a1)
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-25 15:58:43 +02:00
Thomas Lamprecht
6e5862770e ui: do not show consent banner twice for OIDC login
We unconditionally showed the consent banner when constructing the
login view, but for an OIDC based authentication flow the user might
visit that view twice, once when first loading the UI and the second
one when getting redirected back by their OIDC provider.

Checking if there was such an OIDC redirect and skip showing the
banner in that cases avoids this issue.

Fix is similar in principle to what we do for pve-manager when closing
issue #6311 but replaces the if guard with a reverse early-return.

Report: https://bugzilla.proxmox.com/show_bug.cgi?id=6311
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-22 07:45:11 +02:00
Thomas Lamprecht
48f936f1a8 ui: update online help info
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-22 07:43:34 +02:00
Thomas Lamprecht
d84d10125d bump version to 3.4.3-1
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-16 01:02:31 +02:00
Christian Ebner
566121ceb0 docs: rephrase and extend rate limiting description for sync jobs
Since commit 37a85cf6 ("fix: ui: sync job: edit rate limit based on
sync direction") rate limits for sync jobs can be correctly applied
for both directions. State this in the documentation and explicitley
mention the directions to reduce confusion.

Further, also mention the burst parameters, as they are not mentioned
at all.

Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
Link: https://lore.proxmox.com/20250623124543.590388-1-c.ebner@proxmox.com
2025-07-16 00:17:34 +02:00
Christian Ebner
484f096250 build: Adapt from pbs2to3 to pbs3to4
Build and package the new version of the upgrade check binary,
alongside the previous one, which is still good to have for
post-installation checks for those slow to upgrade from PBS 3.

Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
Link: https://lore.proxmox.com/20250714100841.25268-2-c.ebner@proxmox.com
 [TL: do not replace old checker tool on stable branch]
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-15 22:26:21 +02:00
Thomas Lamprecht
241f4acb2a bin: add pbs3to4 upgrade check-list script
Copied over pbs2to3 as base and did minimal adaptions to expected code
names and package and kernel versions, might need more work though.

Link: https://lore.proxmox.com/20250714100841.25268-1-c.ebner@proxmox.com
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2025-07-15 22:21:31 +02:00
Fabian Grünbichler
3122e40d94 fix static build
`cargo rustc` only passes the flags (like `target-feature` in this case) for
the final invocation, not for any dependency compilation.

unfortunately, switching to `cargo build` is not straight-forward:
- during a package build, $CARGO is the cargo wrapper which only honors
  RUSTFLAGS in its `prepare-debian` invocation
- rustflags in cargo's config.toml are global/per target
- the unstable override that would allow setting them per profile is broken
- and it would only work for the final invocation anyway, just like `cargo rustc`

as a stop-gap measure, let's duplicate and adapt the generated config.toml, and
select it explicitly when doing the static compilation as part of the package
build. manual `make proxmox-backup-client-static` can still just pass RUSTFLAGS
via the environment..

Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
[WB: separate -i and -e in sed invocation, add -r, drop backslashes]
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
2025-06-30 15:41:26 +02:00
Wolfgang Bumiller
c0368c11bf build: set OPENSSL_STATIC=1 when building static binaries
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
(cherry picked from commit 63ece39a17)
2025-06-30 15:21:51 +02:00
Wolfgang Bumiller
6b7ac16ebd build: replace .do-* helpers with grouped targets
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
(cherry picked from commit f2a4b46379)
2025-06-30 15:21:49 +02:00
346 changed files with 11736 additions and 19109 deletions

View File

@ -1,5 +1,5 @@
[workspace.package]
version = "4.2.0"
version = "3.4.8"
authors = [
"Dietmar Maurer <dietmar@proxmox.com>",
"Dominik Csapak <d.csapak@proxmox.com>",
@ -53,59 +53,50 @@ path = "src/lib.rs"
[workspace.dependencies]
# proxmox workspace
proxmox-apt = { version = "0.99.7", features = [ "cache" ] }
proxmox-apt-api-types = "2.0.5"
proxmox-async = "0.5"
proxmox-auth-api = "1.0.5"
proxmox-base64 = "1"
proxmox-apt = { version = "0.11.9", features = [ "cache" ] }
proxmox-apt-api-types = "1.0.4"
proxmox-async = "0.4"
proxmox-auth-api = "0.4.9"
proxmox-borrow = "1"
proxmox-compression = "1.0.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-human-byte = "1"
proxmox-compression = "0.2"
proxmox-config-digest = "0.1.0"
proxmox-daemon = "0.1.0"
proxmox-fuse = "0.1.3"
proxmox-http = { version = "0.9.5", features = [ "client", "http-helpers", "websocket" ] } # see below
proxmox-human-byte = "0.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-log = "0.2.6"
proxmox-ldap = "0.2.1"
proxmox-metrics = "0.3.1"
proxmox-network-api = "0.1.6"
proxmox-notify = "0.5.1"
proxmox-openid = "0.10.0"
proxmox-rest-server = { version = "0.8.10", 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-router = { version = "3.0.0", default-features = false }
proxmox-rrd = "0.4"
proxmox-rrd-api-types = "1.0.2"
proxmox-s3-client = { version = "1.4", 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"
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-serde = "0.1.1"
proxmox-shared-cache = "0.1"
proxmox-shared-memory = "0.3.0"
proxmox-sortable-macro = "0.1.2"
proxmox-subscription = { version = "0.5.0", features = [ "api-types" ] }
proxmox-sys = "0.6.7"
proxmox-systemd = "0.1"
proxmox-tfa = { version = "5", 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"
proxmox-worker-task = "0.1"
pbs-api-types = "0.2.2"
# other proxmox crates
pathpatterns = "1"
proxmox-acme = "1.1"
proxmox-acme-api = { version = "1.0.2", features = [ "impl" ] }
pxar = "1.0.1"
pathpatterns = "0.3"
proxmox-acme = "0.5.3"
pxar = "0.12.1"
# PBS workspace
pbs-buildcfg = { path = "pbs-buildcfg" }
@ -122,9 +113,10 @@ pbs-tools = { path = "pbs-tools" }
anyhow = "1.0"
async-trait = "0.1.56"
apt-pkg-native = "0.3.2"
base64 = "0.13"
bitflags = "2.4"
bytes = "1.0"
cidr = "0.3"
cidr = "0.2.1"
crc32fast = "1"
const_format = "0.2"
crossbeam-channel = "0.5"
@ -133,49 +125,46 @@ env_logger = "0.11"
flate2 = "1.0"
foreign-types = "0.3"
futures = "0.3"
h2 = { version = "0.4", features = [ "stream" ] }
h2 = { version = "0.4", features = [ "legacy", "stream" ] }
handlebars = "3.0"
hex = "0.4.3"
hickory-resolver = { version = "0.24.1", default-features = false, features = [ "system-config", "tokio-runtime" ] }
http-body-util = "0.1"
hyper-util = "0.1"
hyper = { version = "1", features = [ "full" ] }
hyper = { version = "0.14", features = [ "backports", "deprecated", "full" ] }
libc = "0.2"
log = "0.4.17"
nix = "0.29"
nix = "0.26.1"
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"
rustyline = "14"
rustyline = "9"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
siphasher = "0.3"
syslog = "6"
tar = "0.4"
tempfile = "3.15.0"
termcolor = "1.1.2"
thiserror = "2"
thiserror = "1.0"
tokio = "1.6"
tokio-openssl = "0.6.1"
tokio-stream = "0.1.0"
tokio-util = { version = "0.7", features = [ "io" ] }
tracing = "0.1"
tower-service = "0.3.0"
udev = "0.9"
udev = "0.4"
url = "2.1"
walkdir = "2"
xdg = "2.2"
zstd = "0.13"
zstd-safe = "7"
zstd = { version = "0.12", features = [ "bindgen" ] }
zstd-safe = "6.0"
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
base64.workspace = true
bytes.workspace = true
cidr.workspace = true
const_format.workspace = true
@ -185,9 +174,7 @@ endian_trait.workspace = true
futures.workspace = true
h2.workspace = true
hex.workspace = true
http-body-util.workspace = true
hyper.workspace = true
hyper-util = { workspace = true, features = ["server", "server-auto", "server-graceful"] }
libc.workspace = true
log.workspace = true
nix.workspace = true
@ -220,28 +207,21 @@ proxmox-apt.workspace = true
proxmox-apt-api-types.workspace = true
proxmox-async.workspace = true
proxmox-auth-api = { workspace = true, features = [ "api", "pam-authenticator" ] }
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-http = { workspace = true, features = [ "client-trait", "proxmox-async", "rate-limited-stream" ] } # pbs-client doesn't use these
proxmox-human-byte.workspace = true
proxmox-io.workspace = true
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-network-api.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
proxmox-schema = { workspace = true, features = [ "api-macro" ] }
proxmox-section-config.workspace = true
proxmox-serde = { workspace = true, features = [ "serde_json" ] }
@ -253,14 +233,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,43 +257,31 @@ 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" }
#proxmox-auth-api = { path = "../proxmox/proxmox-auth-api" }
#proxmox-base64 = { path = "../proxmox/proxmox-base64" }
#proxmox-borrow = { path = "../proxmox/proxmox-borrow" }
#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 +289,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

@ -195,6 +195,7 @@ $(COMPILED_BINS) $(COMPILEDIR)/dump-catalog-shell-cli $(COMPILEDIR)/docgen &:
--bin proxmox-restore-daemon \
--package proxmox-backup \
--bin docgen \
--bin pbs2to3 \
--bin pbs3to4 \
--bin proxmox-backup-api \
--bin proxmox-backup-manager \
@ -233,6 +234,7 @@ install: $(COMPILED_BINS) $(STATIC_BINS)
$(foreach i,$(USR_SBIN), \
install -m755 $(COMPILEDIR)/$(i) $(DESTDIR)$(SBINDIR)/ ; \
install -m644 zsh-completions/_$(i) $(DESTDIR)$(ZSH_COMPL_DEST)/ ;)
install -m755 $(COMPILEDIR)/pbs2to3 $(DESTDIR)$(SBINDIR)/
install -m755 $(COMPILEDIR)/pbs3to4 $(DESTDIR)$(SBINDIR)/
install -dm755 $(DESTDIR)$(LIBEXECDIR)/proxmox-backup
install -dm755 $(DESTDIR)$(LIBEXECDIR)/proxmox-backup/file-restore
@ -254,7 +256,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

1029
debian/changelog vendored

File diff suppressed because it is too large Load Diff

182
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>,
@ -16,9 +16,10 @@ Build-Depends: debhelper (>= 12~),
libfuse3-dev,
librust-anyhow-1+default-dev,
librust-async-trait-0.1+default-dev (>= 0.1.56-~~),
librust-base64-0.13+default-dev,
librust-bitflags-2+default-dev (>= 2.4-~~),
librust-bytes-1+default-dev,
librust-cidr-0.3+default-dev,
librust-cidr-0.2+default-dev (>= 0.2.1-~~),
librust-const-format-0.2+default-dev,
librust-crc32fast-1+default-dev,
librust-crossbeam-channel-0.5+default-dev,
@ -28,123 +29,99 @@ Build-Depends: debhelper (>= 12~),
librust-foreign-types-0.3+default-dev,
librust-futures-0.3+default-dev,
librust-h2-0.4+default-dev,
librust-h2-0.4+legacy-dev,
librust-h2-0.4+stream-dev,
librust-hex-0.4+default-dev (>= 0.4.3-~~),
librust-hex-0.4+serde-dev (>= 0.4.3-~~),
librust-hickory-resolver-0.24+system-config-dev (>= 0.24.1-~~),
librust-hickory-resolver-0.24+tokio-runtime-dev (>= 0.24.1-~~),
librust-http-body-util-0.1+default-dev,
librust-hyper-1+default-dev,
librust-hyper-1+full-dev,
librust-hyper-util-0.1+client-dev,
librust-hyper-util-0.1+client-legacy-dev,
librust-hyper-util-0.1+default-dev,
librust-hyper-util-0.1+http1-dev,
librust-hyper-util-0.1+http2-dev,
librust-hyper-util-0.1+server-auto-dev,
librust-hyper-util-0.1+server-dev,
librust-hyper-util-0.1+server-graceful-dev,
librust-hyper-util-0.1+service-dev,
librust-hyper-util-0.1+tokio-dev,
librust-hyper-0.14+backports-dev,
librust-hyper-0.14+default-dev,
librust-hyper-0.14+deprecated-dev,
librust-hyper-0.14+full-dev,
librust-libc-0.2+default-dev,
librust-log-0.4+default-dev (>= 0.4.17-~~),
librust-nix-0.29+default-dev,
librust-nix-0.29+feature-dev,
librust-nix-0.29+mount-dev,
librust-nix-0.29+reboot-dev,
librust-nix-0.26+default-dev (>= 0.26.1-~~),
librust-nom-7+default-dev,
librust-num-traits-0.2+default-dev,
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-pathpatterns-0.3+default-dev,
librust-pbs-api-types-0.2+default-dev (>= 0.2.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-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-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-base64-1+default-dev,
librust-proxmox-acme-0.5+default-dev (>= 0.5.3-~~),
librust-proxmox-apt-0.11+cache-dev,
librust-proxmox-apt-0.11+default-dev (>= 0.11.9-~~),
librust-proxmox-apt-api-types-1+default-dev (>= 1.0.4-~~),
librust-proxmox-async-0.4+default-dev,
librust-proxmox-auth-api-0.4+api-dev (>= 0.4.9-~~),
librust-proxmox-auth-api-0.4+default-dev (>= 0.4.9-~~),
librust-proxmox-auth-api-0.4+pam-authenticator-dev (>= 0.4.9-~~),
librust-proxmox-borrow-1+default-dev,
librust-proxmox-compression-1+default-dev (>= 1.0.1-~~),
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-human-byte-1+default-dev,
librust-proxmox-compression-0.2+default-dev,
librust-proxmox-config-digest-0.1+default-dev,
librust-proxmox-daemon-0.1+default-dev,
librust-proxmox-fuse-0.1+default-dev (>= 0.1.3-~~),
librust-proxmox-http-0.9+client-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+client-trait-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+default-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+http-helpers-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+proxmox-async-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+rate-limited-stream-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+rate-limiter-dev (>= 0.9.5-~~),
librust-proxmox-http-0.9+websocket-dev (>= 0.9.5-~~),
librust-proxmox-human-byte-0.1+default-dev,
librust-proxmox-io-1+default-dev (>= 1.0.1-~~),
librust-proxmox-io-1+tokio-dev (>= 1.0.1-~~),
librust-proxmox-lang-1+default-dev (>= 1.1-~~),
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-rrd-1+default-dev,
librust-proxmox-ldap-0.2+default-dev (>= 0.2.1-~~),
librust-proxmox-log-0.2+default-dev (>= 0.2.6-~~),
librust-proxmox-metrics-0.3+default-dev (>= 0.3.1-~~),
librust-proxmox-network-api-0.1-dev (>= 0.1.6-~~),
librust-proxmox-notify-0.5+default-dev (>= 0.5.1-~~),
librust-proxmox-notify-0.5+pbs-context-dev (>= 0.5.1-~~),
librust-proxmox-openid-0.10+default-dev,
librust-proxmox-rest-server-0.8+default-dev (>= 0.8.10-~~),
librust-proxmox-rest-server-0.8+rate-limited-stream-dev (>= 0.8.10-~~),
librust-proxmox-rest-server-0.8+templates-dev (>= 0.8.10-~~),
librust-proxmox-router-3+cli-dev,
librust-proxmox-router-3+server-dev,
librust-proxmox-rrd-0.4+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-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,
librust-proxmox-shared-cache-1+default-dev,
librust-proxmox-shared-memory-1+default-dev,
librust-proxmox-sortable-macro-1+default-dev,
librust-proxmox-subscription-1+api-types-dev,
librust-proxmox-subscription-1+default-dev,
librust-proxmox-sys-1+acl-dev,
librust-proxmox-sys-1+crypt-dev,
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-serde-0.1+default-dev (>= 0.1.1-~~),
librust-proxmox-serde-0.1+serde-json-dev (>= 0.1.1-~~),
librust-proxmox-shared-cache-0.1+default-dev,
librust-proxmox-shared-memory-0.3+default-dev,
librust-proxmox-sortable-macro-0.1+default-dev (>= 0.1.2-~~),
librust-proxmox-subscription-0.5+api-types-dev,
librust-proxmox-subscription-0.5+default-dev,
librust-proxmox-sys-0.6+acl-dev (>= 0.6.7-~~),
librust-proxmox-sys-0.6+crypt-dev (>= 0.6.7-~~),
librust-proxmox-sys-0.6+default-dev (>= 0.6.7-~~),
librust-proxmox-sys-0.6+logrotate-dev (>= 0.6.7-~~),
librust-proxmox-sys-0.6+timer-dev (>= 0.6.7-~~),
librust-proxmox-systemd-0.1+default-dev,
librust-proxmox-tfa-5+api-dev,
librust-proxmox-tfa-5+api-types-dev,
librust-proxmox-tfa-5+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-proxmox-worker-task-0.1+default-dev,
librust-pxar-0.12+default-dev (>= 0.12.1-~~),
librust-regex-1+default-dev (>= 1.5.5-~~),
librust-rustyline-14+default-dev,
librust-rustyline-9+default-dev,
librust-serde-1+default-dev,
librust-serde-1+derive-dev,
librust-serde-json-1+default-dev,
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-thiserror-1+default-dev,
librust-tokio-1+default-dev (>= 1.6-~~),
librust-tokio-1+fs-dev (>= 1.6-~~),
librust-tokio-1+io-std-dev (>= 1.6-~~),
@ -165,23 +142,24 @@ Build-Depends: debhelper (>= 12~),
librust-tokio-util-0.7+io-dev,
librust-tower-service-0.3+default-dev,
librust-tracing-0.1+default-dev,
librust-udev-0.9+default-dev,
librust-udev-0.4+default-dev,
librust-url-2+default-dev (>= 2.1-~~),
librust-walkdir-2+default-dev,
librust-xdg-2+default-dev (>= 2.2-~~),
librust-zstd-0.13+default-dev,
librust-zstd-safe-7+default-dev,
librust-zstd-0.12+bindgen-dev,
librust-zstd-0.12+default-dev,
librust-zstd-safe-6+default-dev,
libsgutils2-dev,
libstd-rust-dev,
libsystemd-dev (>= 246-~~),
patchelf,
proxmox-biome,
proxmox-widget-toolkit-dev <!nodoc>,
proxmox-biome,
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,24 +174,20 @@ 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,
libsgutils2-1.48,
libsgutils2-1.46-2,
libzstd1 (>= 1.3.8),
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,

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,9 @@
proxmox-backup-server: elevated-privileges 4755 root/root [usr/lib/*/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: 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: package-installs-apt-sources [etc/apt/sources.list.d/pbs-enterprise.list]
proxmox-backup-server: elevated-privileges 4755 root/root [usr/lib/x86_64-linux-gnu/proxmox-backup/sg-tape-cmd]
proxmox-backup-server: systemd-service-file-refers-to-unusual-wantedby-target getty.target [lib/systemd/system/proxmox-backup-banner.service]
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/pbs2to3]
proxmox-backup-server: uses-dpkg-database-directly [usr/sbin/pbs3to4]
proxmox-backup-server: uses-dpkg-database-directly [usr/sbin/proxmox-backup-debug]

60
debian/postinst vendored
View File

@ -4,18 +4,19 @@ set -e
#DEBHELPER#
update_sync_job() {
job="$1"
echo "Updating sync job '$job' to make old 'remove-vanished' default explicit.."
proxmox-backup-manager sync-job update "$job" --remove-vanished true \
|| echo "Failed, please check sync.cfg manually!"
}
case "$1" in
configure)
# need to have user backup in the tape group
usermod -a -G tape backup
# 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
# modeled after dh_systemd_start output
systemctl --system daemon-reload >/dev/null || true
if [ -n "$2" ]; then
@ -32,25 +33,50 @@ case "$1" in
flock -w 30 /var/log/proxmox-backup/tasks/active.lock sed -i 's/:termproxy::\([^@]\+\): /:termproxy::\1@pam: /' /var/log/proxmox-backup/tasks/active || true
fi
# TODO: remove with 4.1+, this was just exposed on internal testing.
if dpkg --compare-versions "$2" 'lt' '4.0.5~'; then
if [ -e /etc/proxmox-backup/s3.cfg ]; then
sed -i 's/^s3client:/s3-endpoint:/' /etc/proxmox-backup/s3.cfg \
|| echo "Failed to rename 's3client' config section-type to 's3-endpoint' in /etc/proxmox-backup/s3.cfg."
if dpkg --compare-versions "$2" 'lt' '2.2.2~'; then
echo "moving prune schedule from datacenter config to new prune job config"
proxmox-backup-manager update-to-prune-jobs-config \
|| echo "Failed to move prune jobs, please check manually"
true
fi
if dpkg --compare-versions "$2" 'lt' '2.3.1~' && test -e /etc/proxmox-backup/.datastore.lck; then
lock_user="$(stat --format '%U' /etc/proxmox-backup/.datastore.lck)"
if [ "${lock_user}" != "backup" ]; then
echo "updating /etc/proxmox-backup/.datastore.lck from wrong user '${lock_user}' to 'backup'"
chown backup:backup /etc/proxmox-backup/.datastore.lck \
|| printf "Failed to fix datastore.lck user, please retry manually with:\n\n\t%s\n\n" \
"chown backup:backup /etc/proxmox-backup/.datastore.lck"
fi
fi
# TODO: remove with PBS 5+
if dpkg --compare-versions "$2" 'lt' '4.0.2~'; then
proxmox-backup-manager migrate-config default-notification-mode \
|| echo "Failed migrate tape-job/datastore notification mode, please check manually"
if dpkg --compare-versions "$2" 'lt' '2.1.3~' && test -e /etc/proxmox-backup/sync.cfg; then
prev_job=""
# read from HERE doc because POSIX sh limitations
while read -r key value; do
if test "$key" = "sync:"; then
if test -n "$prev_job"; then
# previous job doesn't have an explicit value
update_sync_job "$prev_job"
fi
prev_job=$value
else
prev_job=""
fi
done <<EOF
$(grep -e '^sync:' -e 'remove-vanished' /etc/proxmox-backup/sync.cfg)
EOF
if test -n "$prev_job"; then
# last job doesn't have an explicit value
update_sync_job "$prev_job"
fi
fi
if dpkg --compare-versions "$2" 'lt' '3.3.5~'; then
# ensure old locking is used by the daemon until a reboot happened
touch "/run/proxmox-backup/old-locking"
fi
fi
;;

View File

@ -1,2 +0,0 @@
proxmox-backup-client-static: embedded-library zlib [usr/bin/proxmox-backup-client]
proxmox-backup-client-static: embedded-library zlib [usr/bin/pxar]

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/x86_64-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,20 @@
etc/pbs-enterprise.sources /etc/apt/sources.list.d/
etc/pbs-network-config-commit.service /usr/lib/systemd/system/
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/
etc/pbs-enterprise.list /etc/apt/sources.list.d/
etc/pbs-network-config-commit.service /lib/systemd/system/
etc/proxmox-backup-banner.service /lib/systemd/system/
etc/proxmox-backup-daily-update.service /lib/systemd/system/
etc/proxmox-backup-daily-update.timer /lib/systemd/system/
etc/proxmox-backup-proxy.service /lib/systemd/system/
etc/proxmox-backup.service /lib/systemd/system/
etc/removable-device-attach@.service /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/pbs2to3
usr/sbin/pbs3to4
usr/sbin/proxmox-backup-debug
usr/sbin/proxmox-backup-manager
@ -22,6 +22,7 @@ usr/share/javascript/proxmox-backup/css/ext6-pbs.css
usr/share/javascript/proxmox-backup/images
usr/share/javascript/proxmox-backup/index.hbs
usr/share/javascript/proxmox-backup/js/proxmox-backup-gui.js
usr/share/man/man1/pbs2to3.1
usr/share/man/man1/pbs3to4.1
usr/share/man/man1/pmt.1
usr/share/man/man1/pmtx.1
@ -67,8 +68,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

View File

@ -5,7 +5,7 @@
ACTION=="remove", GOTO="persistent_storage_tape_end"
ENV{UDEV_DISABLE_PERSISTENT_STORAGE_RULES_FLAG}=="1", GOTO="persistent_storage_tape_end"
# also see: /usr/lib/udev/rules.d/60-persistent-storage-tape.rules
# also see: /lib/udev/rules.d/60-persistent-storage-tape.rules
SUBSYSTEM=="scsi_generic", SUBSYSTEMS=="scsi", ATTRS{type}=="1", IMPORT{program}="scsi_id --sg-version=3 --export --whitelisted -d $devnode", \
SYMLINK+="tape/by-id/scsi-$env{ID_SERIAL}-sg"
@ -18,4 +18,4 @@ SUBSYSTEM=="scsi_generic", SUBSYSTEMS=="scsi", ATTRS{type}=="1", ENV{ID_SCSI_SER
LABEL="persistent_storage_tape_end"
# triggers the mounting of a removable device
ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", TAG+="systemd", ENV{SYSTEMD_WANTS}="removable-device-attach@$env{ID_FS_UUID}"
ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", TAG+="systemd", ENV{SYSTEMD_WANTS}="removable-device-attach@$env{ID_FS_UUID}"

View File

@ -25,6 +25,7 @@ GENERATED_SYNOPSIS := \
pxar/synopsis.rst \
MAN1_PAGES := \
pbs2to3.1 \
pbs3to4.1 \
pmt.1 \
pmtx.1 \

View File

@ -88,7 +88,7 @@ class ReflabelMapper(Builder):
#pprint(vars(node))
if hasattr(node, 'expect_referenced_by_id') and len(node['ids']) > 1: # explicit labels
filename = str(self.env.doc2path(docname))
filename = self.env.doc2path(docname)
filename_html = re.sub('.rst', '.html', filename)
# node['ids'][0] contains a normalized version of the

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

@ -102,6 +102,7 @@ man_pages = [
('pxar/man1', 'pxar', 'Proxmox File Archive CLI Tool', [author], 1),
('pmt/man1', 'pmt', 'Control Linux Tape Devices', [author], 1),
('pmtx/man1', 'pmtx', 'Control SCSI media changer devices (tape autoloaders)', [author], 1),
('pbs2to3/man1', 'pbs2to3', 'Proxmox Backup Server upgrade checker script for 2.4+ to current 3.x major upgrades', [author], 1),
('pbs3to4/man1', 'pbs3to4', 'Proxmox Backup Server upgrade checker script for 3.4+ to current 4.x major upgrades', [author], 1),
# configs
('config/acl/man5', 'acl.cfg', 'Access Control Configuration', [author], 5),

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:
@ -156,24 +145,6 @@ job needs to be run before a sync job with 'resync-corrupt' can be carried out.
that a 'resync-corrupt'-job needs to check the manifests of all snapshots in a datastore
and might take much longer than regular sync jobs.
If the ``run-on-mount`` flag is set, the sync job will be automatically started whenever a
relevant removable datastore is mounted. If mounting a removable datastore would start
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 +282,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

@ -7,9 +7,9 @@ Overview
--------
* Proxmox Backup Server emits :ref:`notification_events` in case of noteworthy
events in the system. These events are processed based on the global
notification settings. Each notification event includes metadata, such as a
timestamp, severity level, type, and additional event-specific fields.
events in the system. These events are handled by the notification system. A
notification event has metadata, for example a timestamp, a severity level, a
type and other metadata fields.
* :ref:`notification_matchers` route a notification event to one or more
notification targets. A matcher can have match rules to selectively route
based on the metadata of a notification event.
@ -17,7 +17,12 @@ Overview
is routed to by a matcher. There are multiple types of target, mail-based
(Sendmail and SMTP) and Gotify.
Global notification settings can be configured in the GUI under *Configuration →
Datastores and tape backup jobs have a configurable :ref:`notification_mode`.
It allows you to choose between the notification system and a legacy mode for
sending notification emails. The legacy mode is equivalent to the way
notifications were handled before Proxmox Backup Server 3.2.
The notification system can be configured in the GUI under *Configuration →
Notifications*. The configuration is stored in :ref:`notifications.cfg` and
:ref:`notifications_priv.cfg` - the latter contains sensitive configuration
options such as passwords or authentication tokens for notification targets and
@ -259,7 +264,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.
================================ ==================== ========== ==============================================================
@ -299,8 +304,9 @@ Metadata field Description
System Mail Forwarding
----------------------
Certain local system daemons, such as ``smartd``, send notification emails to
the local ``root`` user. These mails are converted into notification events
with the type ``system-mail`` and with a severity of ``unknown``.
the local ``root`` user. Proxmox Backup Server will feed these mails into the
notification system as a notification of type ``system-mail`` and with severity
``unknown``.
When the email is forwarded to a sendmail target, the mail's content and
headers are forwarded as-is. For all other targets, the system tries to extract
@ -321,14 +327,24 @@ Notification Mode
Datastores and tape backup/restore job configuration have a
``notification-mode`` option which can have one of two values:
* Send notifications based on the global notification settings (``notification-system``).
* ``legacy-sendmail``: Send notification emails via the system's ``sendmail``
command. The notification system will be bypassed and any configured
targets/matchers will be ignored. This mode is equivalent to the notification
behavior for version before Proxmox Backup Server 3.2.
* Send notification emails via the system's ``sendmail`` command
(``legacy-sendmail``). Any targets or matchers from the global notification
settings are ignored. This mode is equivalent to the notification behavior
for Proxmox Backup Server versions before 3.2. It might be removed in a
later release of Proxmox Backup Server.
* ``notification-system``: Use the new, flexible notification system.
If the ``notification-mode`` option is not set, Proxmox Backup Server will
default to ``legacy-sendmail``.
Starting with Proxmox Backup Server 3.2, a datastore created in the UI will
automatically opt in to the new notification system. If the datastore is
created via the API or the ``proxmox-backup-manager`` CLI, the
``notification-mode`` option has to be set explicitly to
``notification-system`` if the notification system shall be used.
The ``legacy-sendmail`` mode might be removed in a later release of
Proxmox Backup Server.
Settings for ``legacy-sendmail`` notification mode
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -348,9 +364,8 @@ task type via the ``notify`` option.
* Never: do not send any notification at all
The ``notify-user`` and ``notify`` options are ignored when using the global
notification settings (``notification-mode`` is set to
``notification-system``).
The ``notify-user`` and ``notify`` options are ignored if ``notification-mode``
is set to ``notification-system``.
Overriding Notification Templates
---------------------------------
@ -371,50 +386,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

@ -4,62 +4,25 @@ Debian Package Repositories
---------------------------
All Debian based systems use APT_ as a package management tool. The lists of
repositories are defined in ``/etc/apt/sources.list`` and the ``.list`` or
``.sources`` files found in the ``/etc/apt/sources.d/`` directory. Updates can
be installed directly with the ``apt`` command-line tool, or via the GUI.
repositories are defined in ``/etc/apt/sources.list`` and the ``.list`` files found
in the ``/etc/apt/sources.d/`` directory. Updates can be installed directly
with the ``apt`` command-line tool, or via the GUI.
.. _package_repos_repository_formats:
APT_ ``sources.list`` files list one package repository per line, with the most
preferred source listed first. Empty lines are ignored and a ``#`` character
anywhere on a line marks the remainder of that line as a comment. The
information available from the configured sources is acquired by ``apt
update``.
Repository Formats
~~~~~~~~~~~~~~~~~~
.. code-block:: sources.list
:caption: File: ``/etc/apt/sources.list``
APT_ repositories can be configured in two distinct formats, the old single
line format and the newer deb822 format. No matter what format you choose,
``apt update`` will fetch the information from all configured sources.
deb http://deb.debian.org/debian bookworm main contrib
deb http://deb.debian.org/debian bookworm-updates main contrib
Single Line
^^^^^^^^^^^
# security updates
deb http://security.debian.org/debian-security bookworm-security main contrib
Single line repositories are defined in ``.list`` files list one package
repository per line, with the most preferred source listed first. Empty lines
are ignored and a ``#`` character anywhere on a line marks the remainder of
that line as a comment.
deb822 Style
^^^^^^^^^^^^
The newer deb822 multiline format is used in ``.sources`` files. Each
repository consists of a stanza with multiple key value pairs. A stanza is
simply a group of lines. One file can contain multiple stanzas by separating
them with a blank line. You can still use ``#`` to comment out lines.
.. note:: Modernizing your repositories is recommended under Debian Trixie, as
``apt`` will complain about older repository definitions otherwise. You can
run the command ``apt modernize-sources`` to modernize your existing
repositories automatically.
.. _package_repos_debian_base_repositories:
Debian Base Repositories
~~~~~~~~~~~~~~~~~~~~~~~~
You will need a Debian base repository as a minimum to get updates for all
packages provided by Debian itself:
.. code-block:: debian.sources
:caption: File: ``/etc/apt/sources.list.d/debian.sources``
Types: deb
URIs: http://deb.debian.org/debian/
Suites: trixie trixie-updates
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://security.debian.org/debian-security/
Suites: trixie-security
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
In addition, you need a package repository from Proxmox to get Proxmox Backup
updates.
@ -69,6 +32,38 @@ updates.
:align: right
:alt: APT Repository Management in the Web Interface
.. _package_repos_secure_apt:
SecureApt
~~~~~~~~~
The `Release` files in the repositories are signed with GnuPG. APT is using
these signatures to verify that all packages are from a trusted source.
If you install Proxmox Backup Server from an official ISO image, the
verification key is already installed.
If you install Proxmox Backup Server on top of Debian, download and install the
key with the following commands:
.. code-block:: console
# wget https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg -O /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg
Verify the SHA512 checksum afterwards with the expected output below:
.. code-block:: console
# sha512sum /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg
7da6fe34168adc6e479327ba517796d4702fa2f8b4f0a9833f5ea6e6b48f6507a6da403a274fe201595edc86a84463d50383d07f64bdde2e3658108db7d6dc87 /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg
and the md5sum, with the expected output below:
.. code-block:: console
# md5sum /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg
41558dc019ef90bd0f6067644a51cf5b /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg
.. _sysadmin_package_repos_enterprise:
`Proxmox Backup`_ Enterprise Repository
@ -79,14 +74,11 @@ all Proxmox Backup subscription users. It contains the most stable packages,
and is suitable for production use. The ``pbs-enterprise`` repository is
enabled by default:
.. code-block:: debian.sources
:caption: File: ``/etc/apt/sources.list.d/pbs-enterprise.sources``
.. code-block:: sources.list
:caption: File: ``/etc/apt/sources.list.d/pbs-enterprise.list``
deb https://enterprise.proxmox.com/debian/pbs bookworm pbs-enterprise
Types: deb
URIs: https://enterprise.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-enterprise
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
To never miss important security fixes, the superuser (``root@pam`` user) is
notified via email about new packages as soon as they are available. The
@ -96,8 +88,11 @@ Please note that you need a valid subscription key to access this
repository. More information regarding subscription levels and pricing can be
found at https://www.proxmox.com/en/proxmox-backup-server/pricing
.. note:: You can disable this repository by adding the line ``Enabled: false``
to the stanza.
.. note:: You can disable this repository by commenting out the above line
using a `#` (at the start of the line). This prevents error messages if you do
not have a subscription key. Please configure the ``pbs-no-subscription``
repository in that case.
`Proxmox Backup`_ No-Subscription Repository
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -107,17 +102,21 @@ this repository. It can be used for testing and non-production
use. It is not recommended to use it on production servers, because these
packages are not always heavily tested and validated.
We recommend to configure this repository in
``/etc/apt/sources.list.d/proxmox.sources``.
We recommend to configure this repository in ``/etc/apt/sources.list``.
.. code-block:: debian.sources
:caption: File: ``/etc/apt/sources.list.d/proxmox.sources``
.. code-block:: sources.list
:caption: File: ``/etc/apt/sources.list``
deb http://deb.debian.org/debian bookworm main contrib
deb http://deb.debian.org/debian bookworm-updates main contrib
# Proxmox Backup Server pbs-no-subscription repository provided by proxmox.com,
# NOT recommended for production use
deb http://download.proxmox.com/debian/pbs bookworm pbs-no-subscription
# security updates
deb http://security.debian.org/debian-security bookworm-security main contrib
Types: deb
URIs: http://download.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
`Proxmox Backup`_ Test Repository
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -128,17 +127,13 @@ to test new features.
.. .. warning:: the ``pbstest`` repository should (as the name implies)
only be used to test new features or bug fixes.
You can access this repository by adding the following stanza to
``/etc/apt/sources.list.d/proxmox.sources``:
You can access this repository by adding the following line to
``/etc/apt/sources.list``:
.. code-block:: debian.sources
.. code-block:: sources.list
:caption: sources.list entry for ``pbstest``
Types: deb
URIs: http://download.proxmox.com/debian/pbs
Suites: trixie
Components: pbs-test
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
deb http://download.proxmox.com/debian/pbs bookworm pbstest
.. _package_repositories_client_only:
@ -163,24 +158,6 @@ In order to configure this repository you need to first :ref:`setup the Proxmox
release key <package_repos_secure_apt>`. After that, add the repository URL to
the APT sources lists.
**Repositories for Debian 13 (Trixie) based releases**
This repository is tested with:
- Debian Trixie
Edit the file ``/etc/apt/sources.list.d/pbs-client.sources`` and add the following
snippet
.. code-block:: debian.sources
:caption: File: ``/etc/apt/sources.list.d/pbs``
Types: deb
URIs: http://download.proxmox.com/debian/pbs-client
Suites: trixie
Components: main
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
**Repositories for Debian 12 (Bookworm) based releases**
This repository is tested with:
@ -226,50 +203,6 @@ snippet
deb http://download.proxmox.com/debian/pbs-client buster main
.. _package_repos_secure_apt:
SecureApt
~~~~~~~~~
The `Release` files in the repositories are signed with GnuPG. APT is using
these signatures to verify that all packages are from a trusted source.
If you install Proxmox Backup Server from an official ISO image, the
verification key is already installed.
If you install Proxmox Backup Server on top of Debian, download and install the
key with the following commands:
.. code-block:: console
# wget https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg -O /usr/share/keyrings/proxmox-archive-keyring.gpg
.. note:: The `wget` command above adds the keyring for Proxmox releases based
on Debian Trixie. Once the `proxmox-archive-keyring` package is installed,
it will manage this file. At that point, the hashes below may no longer
match the hashes of this file, as keys for new Proxmox releases get added or
removed. This is intended, `apt` will ensure that only trusted keys are
being used. **Modifying this file is discouraged once
`proxmox-archive-keyring` is installed.**
Verify the SHA256 checksum afterwards with the expected output below:
.. code-block:: console
# sha256sum /usr/share/keyrings/proxmox-archive-keyring.gpg
136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45 /usr/share/keyrings/proxmox-archive-keyring.gpg
and the md5sum, with the expected output below:
.. code-block:: console
# md5sum /usr/share/keyrings/proxmox-archive-keyring.gpg
77c8b1166d15ce8350102ab1bca2fcbf /usr/share/keyrings/proxmox-archive-keyring.gpg
.. note:: Make sure that the path that you download the key to, matches the
path specified in the ``Signed-By:`` lines in your repository stanzas from
above.
.. _node_options_http_proxy:
Repository Access Behind HTTP Proxy

16
docs/pbs2to3/man1.rst Normal file
View File

@ -0,0 +1,16 @@
:orphan:
=======
pbs2to3
=======
Description
===========
This tool will help you to detect common pitfalls and misconfiguration before,
and during the upgrade of a Proxmox Backup Server system. Any failures or
warnings must be addressed prior to the upgrade. If you suspect that a message
is a false positive, you have to make carefully sure that it really is.
.. include:: ../pbs-copyright.rst

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,170 +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
^^^^^^^^^^^^^^^^^^^^^^^^^^
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:: 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
costs is highly recommended.
In the endpoint configuration, provide the REST API endpoint for the object store. The endpoint
is provider dependent and allows for the bucket and region templating. For example, configuring
the endpoint as e.g. ``{{bucket}}.s3.{{region}}.amazonaws.com`` will be expanded to
``my-pbs-bucket.s3.eu-central-1.amazonaws.com`` with a configured bucket of name ``my-pbs-bucket``
located in region ``eu-central-1``.
The bucket name is part of the datastore backend configuration rather than the endpoint
configuration, as the same endpoint might be reused for multiple bucket. Objects placed in the
bucket are prefixed by the datastore name, therefore it is possible to create multiple datastores
using the same bucket.
.. note:: Proxmox Backup Server does not handle bucket creation and access control. The bucket used
to store the datastore's objects as well as the access key have to be setup beforehand in your S3
provider interface. The Proxmox Backup Server acts as client and requires permissions to get, put
list and delete objects in the bucket.
Most providers allow to access buckets either using a vhost style addressing, the bucket name being
part of the endpoint address, or via path style addressing, the bucket name being the prefix to
the path components of requests. Proxmox Backup Server supports both styles, favoring the vhost
style urls over the path style. To use path style addresses, set the corresponding configuration
flag.
Proxmox Backup Server does not support plain text communication with the S3 API, all communication
is encrypted using HTTPS in transit. Therefore, for self-hosted S3 object stores using a self-signed
certificate, the matching fingerprint has to be provided to the endpoint configuration. Otherwise
the client refuses connections to the S3 object store.
The following example shows the setup of a new s3 endpoint configuration:
.. code-block:: console
# proxmox-backup-manager s3 endpoint create my-s3-ep --access-key 'my-access-key' --secret-key 'my-secret-key' --endpoint '{{bucket}}.s3.{{region}}.amazonaws.com' --region eu-central-1
To list your s3 endpoint configuration, run:
.. code-block:: console
# 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.
To setup a new datastore called ``my-s3-store`` placed in a bucket called ``pbs-s3-bucket``, run:
.. code-block:: console
# proxmox-backup-manager datastore create my-s3-store /mnt/datastore/my-s3-store-cache --backend type=s3,client=my-s3-ep,bucket=pbs-s3-bucket
A datastore cannot be shared between multiple Proxmox Backup Server instances, only one instance can
operate on the datastore at a time. However, datastore contents used on an instance which is no
longer available can be reused on a fresh installation. To recreate the datastore, you must pass the
``reuse-datastore`` and ``overwrite-in-use`` flags. Since the datastore name is used as prefix, the
same datastore name must be used.
.. code-block:: console
# proxmox-backup-manager datastore create my-s3-store /mnt/datastore/my-new-s3-store-cache --backend type=s3,client=my-s3-ep,bucket=pbs-s3-bucket --reuse-datastore true --overwrite-in-use true
.. note:: If your S3 object store runs out of space while performing write operation to it, most
likely cleanup operations such as cleaning up of contents within a snapshot directory will fail
as well. The recommended procedure is to cleanup any stray objects corresponding to this snapshot
on the S3 object store manually and refresh the contents via an ``S3 refresh``, either via the
CLI or UI.
.. _datastore_s3_endpoint_examples:
S3 Datastore Backend Configuration Examples
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The following shows example configurations for some typical S3 object store providers as excerpts
(data relevant to S3 config only) from ``/etc/proxmox-backup/s3.cfg`` and
``/etc/proxmox-backup/datastore.cfg``:
Self hosted S3 object store with Ceph Rados Gateway using plain IP address, custom port, self-signed
certificate and path-style bucket:
.. code-block:: console
# cat /etc/proxmox-backup/s3.cfg
s3-endpoint: ceph-s3-rados-gw
access-key XXXXXXXXXXXXXXXXXXXX
endpoint 172.16.0.200
fingerprint XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
path-style true
port 7480
secret-key XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# cat /etc/proxmox-backup/datastore.cfg
datastore: ceph-s3-rgw-store
backend bucket=pbs-ceph-bucket,client=ceph-s3-rados-gw,type=s3
path /mnt/datastore/ceph-s3-rgw-store-local-cache
AWS S3 with vhost style bucket addressing, using bucket name and region templating for the endpoint
url:
.. code-block:: console
# cat /etc/proxmox-backup/s3.cfg
s3-endpoint: aws-s3
access-key XXXXXXXXXXXXXXXXXXXX
endpoint {{bucket}}.s3.{{region}}.amazonaws.com
region eu-central-1
secret-key XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# cat /etc/proxmox-backup/datastore.cfg
datastore: aws-s3-store
backend bucket=pbs-s3-bucket,client=aws-s3,type=s3
path /mnt/datastore/aws-s3-store-local-cache
Cloudflare R2 with path style bucket addressing, note that region must be set to ``auto`` as
otherwise request authentication might fail:
.. code-block:: console
# cat /etc/proxmox-backup/s3.cfg
s3-endpoint: cloudflare-r2
access-key XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
endpoint XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.r2.cloudflarestorage.com
path-style true
region auto
secret-key XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# cat /etc/proxmox-backup/datastore.cfg
datastore: r2-s3-store
backend bucket=pbs-r2-bucket,client=cloudflare-r2,type=s3
path /mnt/datastore/r2-s3-store-local-cache
Managing Datastores
^^^^^^^^^^^^^^^^^^^
@ -533,67 +369,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 +384,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 +460,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

@ -11,7 +11,7 @@ DYNAMIC_UNITS := \
proxmox-backup.service \
proxmox-backup-proxy.service
all: $(UNITS) $(DYNAMIC_UNITS) pbs-enterprise.sources
all: $(UNITS) $(DYNAMIC_UNITS) pbs-enterprise.list
clean:
rm -f $(DYNAMIC_UNITS)

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

@ -0,0 +1 @@
deb https://enterprise.proxmox.com/debian/pbs bookworm pbs-enterprise

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,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

@ -1,5 +1,4 @@
use anyhow::Error;
use rustyline::history::MemHistory;
use proxmox_router::cli::*;
use proxmox_schema::*;
@ -18,7 +17,7 @@ use proxmox_schema::*;
///
/// Returns: nothing
fn echo_command(text: String) -> Result<(), Error> {
println!("{text}");
println!("{}", text);
Ok(())
}
@ -72,10 +71,7 @@ fn cli_definition() -> CommandLineInterface {
fn main() -> Result<(), Error> {
let helper = CliHelper::new(cli_definition());
let mut rl = rustyline::Editor::<CliHelper, MemHistory>::with_history(
Default::default(),
MemHistory::new(),
)?;
let mut rl = rustyline::Editor::<CliHelper>::new();
rl.set_helper(Some(helper));
while let Ok(line) = rl.readline("# prompt: ") {
@ -86,7 +82,7 @@ fn main() -> Result<(), Error> {
let rpcenv = CliEnvironment::new();
let _ = handle_command(helper.cmd_def(), "", args, rpcenv, None);
rl.add_history_entry(line)?;
rl.add_history_entry(line);
}
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)));
}));
}

109
examples/h2client.rs Normal file
View File

@ -0,0 +1,109 @@
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use anyhow::Error;
use futures::future::TryFutureExt;
use futures::stream::Stream;
use tokio::net::TcpStream;
// Simple H2 client to test H2 download speed using h2server.rs
struct Process {
body: h2::legacy::RecvStream,
trailers: bool,
bytes: usize,
}
impl Future for Process {
type Output = Result<usize, Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
let this = self.get_mut();
loop {
if this.trailers {
match futures::ready!(this.body.poll_trailers(cx)) {
Ok(Some(trailers)) => println!("trailers: {:?}", trailers),
Ok(None) => (),
Err(err) => return Poll::Ready(Err(Error::from(err))),
}
println!("Received {} bytes", this.bytes);
return Poll::Ready(Ok(this.bytes));
} else {
match futures::ready!(Pin::new(&mut this.body).poll_next(cx)) {
Some(Ok(chunk)) => {
this.body.flow_control().release_capacity(chunk.len())?;
this.bytes += chunk.len();
// println!("GOT FRAME {}", chunk.len());
}
Some(Err(err)) => return Poll::Ready(Err(Error::from(err))),
None => {
this.trailers = true;
}
}
}
}
}
}
fn send_request(
mut client: h2::legacy::client::SendRequest<bytes::Bytes>,
) -> impl Future<Output = Result<usize, Error>> {
println!("sending request");
let request = hyper::http::Request::builder()
.uri("http://localhost/")
.body(())
.unwrap();
let (response, _stream) = client.send_request(request, true).unwrap();
response.map_err(Error::from).and_then(|response| Process {
body: response.into_body(),
trailers: false,
bytes: 0,
})
}
fn main() -> Result<(), Error> {
proxmox_async::runtime::main(run())
}
async fn run() -> Result<(), Error> {
let start = std::time::SystemTime::now();
let conn = TcpStream::connect(std::net::SocketAddr::from(([127, 0, 0, 1], 8008))).await?;
conn.set_nodelay(true).unwrap();
let (client, h2) = h2::legacy::client::Builder::new()
.initial_connection_window_size(1024 * 1024 * 1024)
.initial_window_size(1024 * 1024 * 1024)
.max_frame_size(4 * 1024 * 1024)
.handshake(conn)
.await?;
tokio::spawn(async move {
if let Err(err) = h2.await {
println!("GOT ERR={:?}", err);
}
});
let mut bytes = 0;
for _ in 0..2000 {
bytes += send_request(client.clone()).await?;
}
let elapsed = start.elapsed().unwrap();
let elapsed = (elapsed.as_secs() as f64) + (elapsed.subsec_millis() as f64) / 1000.0;
println!(
"Downloaded {} bytes, {} MB/s",
bytes,
(bytes as f64) / (elapsed * 1024.0 * 1024.0)
);
Ok(())
}

125
examples/h2s-client.rs Normal file
View File

@ -0,0 +1,125 @@
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use anyhow::{format_err, Error};
use futures::future::TryFutureExt;
use futures::stream::Stream;
use tokio::net::TcpStream;
// Simple H2 client to test H2 download speed using h2s-server.rs
struct Process {
body: h2::legacy::RecvStream,
trailers: bool,
bytes: usize,
}
impl Future for Process {
type Output = Result<usize, Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
let this = self.get_mut();
loop {
if this.trailers {
match futures::ready!(this.body.poll_trailers(cx)) {
Ok(Some(trailers)) => println!("trailers: {:?}", trailers),
Ok(None) => (),
Err(err) => return Poll::Ready(Err(Error::from(err))),
}
println!("Received {} bytes", this.bytes);
return Poll::Ready(Ok(this.bytes));
} else {
match futures::ready!(Pin::new(&mut this.body).poll_next(cx)) {
Some(Ok(chunk)) => {
this.body.flow_control().release_capacity(chunk.len())?;
this.bytes += chunk.len();
// println!("GOT FRAME {}", chunk.len());
}
Some(Err(err)) => return Poll::Ready(Err(Error::from(err))),
None => {
this.trailers = true;
}
}
}
}
}
}
fn send_request(
mut client: h2::legacy::client::SendRequest<bytes::Bytes>,
) -> impl Future<Output = Result<usize, Error>> {
println!("sending request");
let request = hyper::http::Request::builder()
.uri("http://localhost/")
.body(())
.unwrap();
let (response, _stream) = client.send_request(request, true).unwrap();
response.map_err(Error::from).and_then(|response| Process {
body: response.into_body(),
trailers: false,
bytes: 0,
})
}
fn main() -> Result<(), Error> {
proxmox_async::runtime::main(run())
}
async fn run() -> Result<(), Error> {
let start = std::time::SystemTime::now();
let conn = TcpStream::connect(std::net::SocketAddr::from(([127, 0, 0, 1], 8008))).await?;
conn.set_nodelay(true).unwrap();
use openssl::ssl::{SslConnector, SslMethod};
let mut ssl_connector_builder = SslConnector::builder(SslMethod::tls()).unwrap();
ssl_connector_builder.set_verify(openssl::ssl::SslVerifyMode::NONE);
let ssl = ssl_connector_builder
.build()
.configure()?
.into_ssl("localhost")?;
let conn = tokio_openssl::SslStream::new(ssl, conn)?;
let mut conn = Box::pin(conn);
conn.as_mut()
.connect()
.await
.map_err(|err| format_err!("connect failed - {}", err))?;
let (client, h2) = h2::legacy::client::Builder::new()
.initial_connection_window_size(1024 * 1024 * 1024)
.initial_window_size(1024 * 1024 * 1024)
.max_frame_size(4 * 1024 * 1024)
.handshake(conn)
.await?;
tokio::spawn(async move {
if let Err(err) = h2.await {
println!("GOT ERR={:?}", err);
}
});
let mut bytes = 0;
for _ in 0..2000 {
bytes += send_request(client.clone()).await?;
}
let elapsed = start.elapsed().unwrap();
let elapsed = (elapsed.as_secs() as f64) + (elapsed.subsec_millis() as f64) / 1000.0;
println!(
"Downloaded {} bytes, {} MB/s",
bytes,
(bytes as f64) / (elapsed * 1024.0 * 1024.0)
);
Ok(())
}

94
examples/h2s-server.rs Normal file
View File

@ -0,0 +1,94 @@
use std::sync::Arc;
use anyhow::{format_err, Error};
use futures::*;
use hyper::{Body, Request, Response};
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod};
use tokio::net::{TcpListener, TcpStream};
use pbs_buildcfg::configdir;
#[derive(Clone, Copy)]
struct H2SExecutor;
impl<Fut> hyper::rt::Executor<Fut> for H2SExecutor
where
Fut: Future + Send + 'static,
Fut::Output: Send,
{
fn execute(&self, fut: Fut) {
tokio::spawn(fut);
}
}
fn main() -> Result<(), Error> {
proxmox_async::runtime::main(run())
}
async fn run() -> Result<(), Error> {
let key_path = configdir!("/proxy.key");
let cert_path = configdir!("/proxy.pem");
let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
acceptor
.set_private_key_file(key_path, SslFiletype::PEM)
.map_err(|err| format_err!("unable to read proxy key {} - {}", key_path, err))?;
acceptor
.set_certificate_chain_file(cert_path)
.map_err(|err| format_err!("unable to read proxy cert {} - {}", cert_path, err))?;
acceptor.check_private_key().unwrap();
let acceptor = Arc::new(acceptor.build());
let listener = TcpListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], 8008))).await?;
println!("listening on {:?}", listener.local_addr());
loop {
let (socket, _addr) = listener.accept().await?;
tokio::spawn(handle_connection(socket, Arc::clone(&acceptor)).map(|res| {
if let Err(err) = res {
eprintln!("Error: {}", err);
}
}));
}
}
async fn handle_connection(socket: TcpStream, acceptor: Arc<SslAcceptor>) -> Result<(), Error> {
socket.set_nodelay(true).unwrap();
let ssl = openssl::ssl::Ssl::new(acceptor.context())?;
let stream = tokio_openssl::SslStream::new(ssl, socket)?;
let mut stream = Box::pin(stream);
stream.as_mut().accept().await?;
let mut http = hyper::server::conn::http2::Builder::new(H2SExecutor);
// increase window size: todo - find optiomal size
let max_window_size = (1 << 31) - 2;
http.initial_stream_window_size(max_window_size);
http.initial_connection_window_size(max_window_size);
let service = hyper::service::service_fn(|_req: Request<Body>| {
println!("Got request");
let buffer = vec![65u8; 4 * 1024 * 1024]; // nonsense [A,A,A,A...]
let body = Body::from(buffer);
let response = Response::builder()
.status(hyper::http::StatusCode::OK)
.header(
hyper::http::header::CONTENT_TYPE,
"application/octet-stream",
)
.body(body)
.unwrap();
future::ok::<_, Error>(response)
});
http.serve_connection(stream, service)
.map_err(Error::from)
.await?;
println!("H2 connection CLOSE !");
Ok(())
}

72
examples/h2server.rs Normal file
View File

@ -0,0 +1,72 @@
use std::future::Future;
use anyhow::Error;
use futures::*;
use hyper::{Body, Request, Response};
use tokio::net::{TcpListener, TcpStream};
#[derive(Clone, Copy)]
struct H2Executor;
impl<Fut> hyper::rt::Executor<Fut> for H2Executor
where
Fut: Future + Send + 'static,
Fut::Output: Send,
{
fn execute(&self, fut: Fut) {
tokio::spawn(fut);
}
}
fn main() -> Result<(), Error> {
proxmox_async::runtime::main(run())
}
async fn run() -> Result<(), Error> {
let listener = TcpListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], 8008))).await?;
println!("listening on {:?}", listener.local_addr());
loop {
let (socket, _addr) = listener.accept().await?;
tokio::spawn(handle_connection(socket).map(|res| {
if let Err(err) = res {
eprintln!("Error: {}", err);
}
}));
}
}
async fn handle_connection(socket: TcpStream) -> Result<(), Error> {
socket.set_nodelay(true).unwrap();
let mut http = hyper::server::conn::http2::Builder::new(H2Executor);
// increase window size: todo - find optiomal size
let max_window_size = (1 << 31) - 2;
http.initial_stream_window_size(max_window_size);
http.initial_connection_window_size(max_window_size);
let service = hyper::service::service_fn(|_req: Request<Body>| {
println!("Got request");
let buffer = vec![65u8; 4 * 1024 * 1024]; // nonsense [A,A,A,A...]
let body = Body::from(buffer);
let response = Response::builder()
.status(hyper::http::StatusCode::OK)
.header(
hyper::http::header::CONTENT_TYPE,
"application/octet-stream",
)
.body(body)
.unwrap();
future::ok::<_, Error>(response)
});
http.serve_connection(socket, service)
.map_err(Error::from)
.await?;
println!("H2 connection CLOSE !");
Ok(())
}

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

@ -1,7 +1,7 @@
use anyhow::Error;
use pbs_api_types::{Authid, BackupNamespace, BackupType};
use pbs_client::{BackupWriter, BackupWriterOptions, HttpClient, HttpClientOptions};
use pbs_client::{BackupWriter, HttpClient, HttpClientOptions};
async fn upload_speed() -> Result<f64, Error> {
let host = "localhost";
@ -19,15 +19,12 @@ async fn upload_speed() -> Result<f64, Error> {
let client = BackupWriter::start(
&client,
BackupWriterOptions {
datastore,
ns: &BackupNamespace::root(),
backup: &(BackupType::Host, "speedtest".to_string(), backup_time).into(),
crypt_config: None,
debug: false,
benchmark: true,
no_cache: false,
},
None,
datastore,
&BackupNamespace::root(),
&(BackupType::Host, "speedtest".to_string(), backup_time).into(),
false,
true,
)
.await?;
@ -40,10 +37,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 {
#[macro_export]
macro_rules! PROXMOX_BACKUP_FILE_RESTORE_BIN_DIR_M {
() => {
concat!("/usr/lib/", env!("DEB_HOST_MULTIARCH"), "/proxmox-backup")
"/usr/lib/x86_64-linux-gnu/proxmox-backup/file-restore"
};
}
/// 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,8 @@ 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 const PROXMOX_BACKUP_KERNEL_FN: &str =
concat!(PROXMOX_BACKUP_FILE_RESTORE_BIN_DIR_M!(), "/bzImage");
pub const PROXMOX_BACKUP_SUBSCRIPTION_FN: &str = configdir!("/subscription");

View File

@ -12,8 +12,6 @@ bytes.workspace = true
futures.workspace = true
h2.workspace = true
hex.workspace = true
http-body-util.workspace = true
hyper-util = { workspace = true, features = ["client", "client-legacy", "http1", "http2", "tokio" ]}
hyper.workspace = true
libc.workspace = true
nix.workspace = true
@ -36,11 +34,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 = [ "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,54 +52,17 @@ 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 {
future: h2::client::ResponseFuture,
future: h2::legacy::client::ResponseFuture,
size: usize,
}
type UploadQueueSender = mpsc::Sender<(MergedChunkInfo, Option<ChunkUploadResponse>)>;
type UploadResultReceiver = oneshot::Receiver<Result<(), Error>>;
/// Additional configuration options for BackupWriter instance
pub struct BackupWriterOptions<'a> {
/// Target datastore
pub datastore: &'a str,
/// Target namespace
pub ns: &'a BackupNamespace,
/// Target snapshot
pub backup: &'a BackupDir,
/// Crypto configuration
pub crypt_config: Option<Arc<CryptConfig>>,
/// Run in debug mode
pub debug: bool,
/// Start benchmark
pub benchmark: bool,
/// Skip datastore cache
pub no_cache: bool,
}
impl BackupWriter {
fn new(h2: H2Client, abort: AbortHandle, crypt_config: Option<Arc<CryptConfig>>) -> Arc<Self> {
Arc::new(Self {
@ -109,25 +72,28 @@ impl BackupWriter {
})
}
// FIXME: extract into (flattened) parameter struct?
#[allow(clippy::too_many_arguments)]
pub async fn start(
client: &HttpClient,
writer_options: BackupWriterOptions<'_>,
crypt_config: Option<Arc<CryptConfig>>,
datastore: &str,
ns: &BackupNamespace,
backup: &BackupDir,
debug: bool,
benchmark: bool,
) -> Result<Arc<BackupWriter>, Error> {
let mut param = json!({
"backup-type": writer_options.backup.ty(),
"backup-id": writer_options.backup.id(),
"backup-time": writer_options.backup.time,
"store": writer_options.datastore,
"debug": writer_options.debug,
"benchmark": writer_options.benchmark,
"backup-type": backup.ty(),
"backup-id": backup.id(),
"backup-time": backup.time,
"store": datastore,
"debug": debug,
"benchmark": benchmark
});
if writer_options.no_cache {
param["no-cache"] = serde_json::to_value(writer_options.no_cache)?;
}
if !writer_options.ns.is_root() {
param["ns"] = serde_json::to_value(writer_options.ns)?;
if !ns.is_root() {
param["ns"] = serde_json::to_value(ns)?;
}
let req = HttpClient::request_builder(
@ -143,7 +109,7 @@ impl BackupWriter {
.start_h2_connection(req, String::from(PROXMOX_BACKUP_PROTOCOL_ID_V1!()))
.await?;
Ok(BackupWriter::new(h2, abort, writer_options.crypt_config))
Ok(BackupWriter::new(h2, abort, crypt_config))
}
pub async fn get(&self, path: &str, param: Option<Value>) -> Result<Value, Error> {
@ -177,7 +143,7 @@ impl BackupWriter {
param: Option<Value>,
content_type: &str,
data: Vec<u8>,
) -> Result<h2::client::ResponseFuture, Error> {
) -> Result<h2::legacy::client::ResponseFuture, Error> {
let request =
H2Client::request_builder("localhost", method, path, param, Some(content_type))
.unwrap();
@ -311,10 +277,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 +372,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
@ -544,7 +514,7 @@ impl BackupWriter {
}
fn response_queue() -> (
mpsc::Sender<h2::client::ResponseFuture>,
mpsc::Sender<h2::legacy::client::ResponseFuture>,
oneshot::Receiver<Result<(), Error>>,
) {
let (verify_queue_tx, verify_queue_rx) = mpsc::channel(100);
@ -567,7 +537,7 @@ impl BackupWriter {
tokio::spawn(
ReceiverStream::new(verify_queue_rx)
.map(Ok::<_, Error>)
.try_for_each(move |response: h2::client::ResponseFuture| {
.try_for_each(move |response: h2::legacy::client::ResponseFuture| {
response
.map_err(Error::from)
.and_then(H2Client::h2api_response)

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>,
/// Interactive prompt.
prompt: String,
@ -351,6 +351,10 @@ impl Shell {
archive_name: &str,
archive: Accessor,
) -> Result<Self, Error> {
let cli_helper = CliHelper::new(catalog_shell_cli());
let mut rl = rustyline::Editor::<CliHelper>::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 +382,7 @@ impl Shell {
}
let mut this = Self {
rl,
prompt: String::new(),
catalog,
selected: HashMap::new(),
@ -388,40 +393,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 +426,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");
}
this.rl.add_history_entry(line);
this.update_prompt();
}
*SHELL.lock().unwrap() = None;
Ok(())
}
@ -474,7 +460,7 @@ impl Shell {
)
.await?;
let path = Self::format_path_stack(&stack);
println!("{path:?}");
println!("{:?}", path);
Ok(())
}
@ -565,7 +551,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 +576,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 +611,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 +654,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 +665,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 +675,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 +859,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 +880,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 +904,7 @@ impl Shell {
async fn list_selected_patterns(&self) -> Result<(), Error> {
for entry in self.selected.keys() {
println!("{entry:?}");
println!("{:?}", entry);
}
Ok(())
}
@ -1091,8 +1077,7 @@ impl Shell {
extractor,
match_list,
&self.accessor,
)
.await?;
)?;
extractor.extract().await
}
@ -1131,7 +1116,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 +1129,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 +1226,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

@ -3,18 +3,14 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use anyhow::{bail, format_err, Error};
use bytes::Bytes;
use futures::*;
use http_body_util::{BodyDataStream, BodyExt};
use hyper::body::Incoming;
use hyper::header::SET_COOKIE;
#[cfg(not(target_feature = "crt-static"))]
use hyper::client::connect::dns::GaiResolver;
use hyper::client::{Client, HttpConnector};
use hyper::http::header::HeaderValue;
use hyper::http::Uri;
use hyper::http::{Request, Response};
#[cfg(not(target_feature = "crt-static"))]
use hyper_util::client::legacy::connect::dns::GaiResolver;
use hyper_util::client::legacy::{connect::HttpConnector, Client};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper::{body::HttpBody, Body};
use openssl::{
ssl::{SslConnector, SslMethod},
x509::X509StoreContextRef,
@ -30,10 +26,8 @@ use proxmox_sys::linux::tty;
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};
@ -58,7 +52,7 @@ mod resolver {
use hickory_resolver::error::ResolveError;
use hickory_resolver::lookup_ip::LookupIpIntoIter;
use hickory_resolver::TokioAsyncResolver;
use hyper_util::client::legacy::connect::dns::Name;
use hyper::client::connect::dns::Name;
use tower_service::Service;
pub(crate) struct SocketAddrIter {
@ -113,16 +107,11 @@ mod resolver {
/// certain error conditions. Keep it generous, to avoid false-positive under high load.
const HTTP_TIMEOUT: Duration = Duration::from_secs(2 * 60);
const PROXMOX_BACKUP_AUTH_COOKIE: &str = "PBSAuthCookie";
const PROXMOX_BACKUP_PREFIXED_AUTH_COOKIE: &str = "__Host-PBSAuthCookie";
#[derive(Clone)]
pub struct AuthInfo {
pub auth_id: Authid,
pub ticket: String,
pub token: String,
// Whether the server uses HttpOnly cookies for authentication
pub http_only: bool,
}
pub struct HttpClientOptions {
@ -134,7 +123,6 @@ pub struct HttpClientOptions {
fingerprint_cache: bool,
verify_cert: bool,
limit: RateLimitConfig,
proxy: Option<ProxyConfig>,
}
impl HttpClientOptions {
@ -197,11 +185,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,14 +198,13 @@ impl Default for HttpClientOptions {
fingerprint_cache: false,
verify_cert: true,
limit: RateLimitConfig::default(), // unlimited
proxy: None,
}
}
}
/// HTTP(S) API client
pub struct HttpClient {
client: Client<HttpsConnector<DnsResolver>, Body>,
client: Client<HttpsConnector<DnsResolver>>,
server: String,
port: u16,
fingerprint: Arc<Mutex<Option<String>>>,
@ -244,11 +226,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 +370,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 +415,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,15 +463,13 @@ 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);
}
let client = Client::builder(TokioExecutor::new())
let client = Client::builder()
//.http2_initial_stream_window_size( (1 << 31) - 2)
//.http2_initial_connection_window_size( (1 << 31) - 2)
.build::<_, Body>(https);
@ -524,7 +500,6 @@ impl HttpClient {
auth_id: auth_id.clone(),
ticket: password.clone(),
token: "".to_string(),
http_only: false,
}));
let server2 = server.to_string();
@ -549,18 +524,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 +561,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 +624,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)?)?);
}
@ -751,18 +721,10 @@ impl HttpClient {
HeaderValue::from_str(&enc_api_token).unwrap(),
);
} else {
let cookie_name = if auth.http_only {
// server has switched to http only flow, provide ticket in properly prefixed cookie
PROXMOX_BACKUP_PREFIXED_AUTH_COOKIE
} else {
PROXMOX_BACKUP_AUTH_COOKIE
};
let enc_ticket = format!(
"{cookie_name}={}",
"PBSAuthCookie={}",
percent_encode(auth.ticket.as_bytes(), DEFAULT_ENCODE_SET)
);
req.headers_mut()
.insert("Cookie", HeaderValue::from_str(&enc_ticket).unwrap());
req.headers_mut().insert(
@ -801,18 +763,10 @@ impl HttpClient {
let auth = self.login().await?;
let cookie_name = if auth.http_only {
// server has switched to http only flow, provide ticket in properly prefixed cookie
PROXMOX_BACKUP_PREFIXED_AUTH_COOKIE
} else {
PROXMOX_BACKUP_AUTH_COOKIE
};
let enc_ticket = format!(
"{cookie_name}={}",
"PBSAuthCookie={}",
percent_encode(auth.ticket.as_bytes(), DEFAULT_ENCODE_SET)
);
req.headers_mut()
.insert("Cookie", HeaderValue::from_str(&enc_ticket).unwrap());
@ -825,7 +779,7 @@ impl HttpClient {
.map(|_| Err(format_err!("unknown error")))
.await?
} else {
futures::TryStreamExt::map_err(BodyDataStream::new(resp.into_body()), Error::from)
futures::TryStreamExt::map_err(resp.into_body(), Error::from)
.try_fold(output, move |acc, chunk| async move {
acc.write_all(&chunk)?;
Ok::<_, Error>(acc)
@ -878,18 +832,10 @@ impl HttpClient {
HeaderValue::from_str(&enc_api_token).unwrap(),
);
} else {
let cookie_name = if auth.http_only {
// server has switched to http only flow, provide ticket in properly prefixed cookie
PROXMOX_BACKUP_PREFIXED_AUTH_COOKIE
} else {
PROXMOX_BACKUP_AUTH_COOKIE
};
let enc_ticket = format!(
"{cookie_name}={}",
"PBSAuthCookie={}",
percent_encode(auth.ticket.as_bytes(), DEFAULT_ENCODE_SET)
);
req.headers_mut()
.insert("Cookie", HeaderValue::from_str(&enc_ticket).unwrap());
req.headers_mut().insert(
@ -913,11 +859,11 @@ impl HttpClient {
bail!("unknown error");
}
let upgraded = TokioIo::new(hyper::upgrade::on(resp).await?);
let upgraded = hyper::upgrade::on(resp).await?;
let max_window_size = (1 << 31) - 2;
let (h2, connection) = h2::client::Builder::new()
let (h2, connection) = h2::legacy::client::Builder::new()
.initial_connection_window_size(max_window_size)
.initial_window_size(max_window_size)
.max_frame_size(4 * 1024 * 1024)
@ -941,7 +887,7 @@ impl HttpClient {
}
async fn credentials(
client: Client<HttpsConnector<DnsResolver>, Body>,
client: Client<HttpsConnector<DnsResolver>>,
server: String,
port: u16,
username: Userid,
@ -955,51 +901,22 @@ impl HttpClient {
"/api2/json/access/ticket",
Some(data),
)?;
let res = tokio::time::timeout(HTTP_TIMEOUT, client.request(req))
.await
.map_err(|_| format_err!("http request timed out"))??;
// check if the headers contain a newer HttpOnly cookie
let http_only_ticket = res
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|c| c.to_str().ok())
.filter_map(|c| match (c.find('='), c.find(';')) {
(Some(begin), Some(end))
if begin < end && &c[..begin] == PROXMOX_BACKUP_PREFIXED_AUTH_COOKIE =>
{
Some(c[begin + 1..end].to_string())
}
_ => None,
})
.next();
// if the headers contained a new HttpOnly cookie, the server switched to providing these
// by default. this means that older cookies may no longer be supported, so switch to using
// the new cookie name.
let http_only = http_only_ticket.is_some();
let cred = Self::api_response(res).await?;
let cred = Self::api_request(client, req).await?;
let auth = AuthInfo {
auth_id: cred["data"]["username"].as_str().unwrap().parse()?,
ticket: http_only_ticket
.or(cred["data"]["ticket"].as_str().map(|t| t.to_string()))
.unwrap(),
ticket: cred["data"]["ticket"].as_str().unwrap().to_owned(),
token: cred["data"]["CSRFPreventionToken"]
.as_str()
.unwrap()
.to_owned(),
http_only,
};
Ok(auth)
}
async fn api_response(response: Response<Incoming>) -> Result<Value, Error> {
async fn api_response(response: Response<Body>) -> Result<Value, Error> {
let status = response.status();
let data = response.into_body().collect().await?.to_bytes();
let data = HttpBody::collect(response.into_body()).await?.to_bytes();
let text = String::from_utf8(data.to_vec()).unwrap();
if status.is_success() {
@ -1015,7 +932,7 @@ impl HttpClient {
}
async fn api_request(
client: Client<HttpsConnector<DnsResolver>, Body>,
client: Client<HttpsConnector<DnsResolver>>,
req: Request<Body>,
) -> Result<Value, Error> {
Self::api_response(
@ -1050,7 +967,7 @@ impl HttpClient {
.uri(url)
.header("User-Agent", "proxmox-backup-client/1.0")
.header(hyper::header::CONTENT_TYPE, "application/json")
.body(data.to_string().into())?;
.body(Body::from(data.to_string()))?;
Ok(request)
} else {
let query = json_object_to_query(data)?;
@ -1091,11 +1008,11 @@ impl Drop for HttpClient {
#[derive(Clone)]
pub struct H2Client {
h2: h2::client::SendRequest<Bytes>,
h2: h2::legacy::client::SendRequest<bytes::Bytes>,
}
impl H2Client {
pub fn new(h2: h2::client::SendRequest<Bytes>) -> Self {
pub fn new(h2: h2::legacy::client::SendRequest<bytes::Bytes>) -> Self {
Self { h2 }
}
@ -1175,7 +1092,7 @@ impl H2Client {
&self,
request: Request<()>,
data: Option<bytes::Bytes>,
) -> impl Future<Output = Result<h2::client::ResponseFuture, Error>> {
) -> impl Future<Output = Result<h2::legacy::client::ResponseFuture, Error>> {
self.h2
.clone()
.ready()
@ -1192,7 +1109,9 @@ impl H2Client {
})
}
pub async fn h2api_response(response: Response<h2::RecvStream>) -> Result<Value, Error> {
pub async fn h2api_response(
response: Response<h2::legacy::RecvStream>,
) -> Result<Value, Error> {
let status = response.status();
let (_head, mut body) = response.into_parts();

View File

@ -8,7 +8,7 @@ use std::task::{Context, Poll};
use anyhow::{format_err, Error};
use bytes::Bytes;
use futures::{ready, Future};
use h2::SendStream;
use h2::legacy::SendStream;
pub struct PipeToSendStream {
body_tx: SendStream<Bytes>,

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));
@ -679,11 +630,7 @@ impl Archiver {
let mut stat_results: Option<FileStat> = None;
let get_file_mode = || {
nix::sys::stat::fstatat(
Some(dir_fd),
file_name,
nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
)
nix::sys::stat::fstatat(dir_fd, file_name, nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW)
};
let match_result = self
@ -1063,10 +1010,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 +1040,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 {
@ -1366,7 +1309,7 @@ impl Archiver {
file_name: &Path,
metadata: &Metadata,
) -> Result<(), Error> {
let dest = match nix::fcntl::readlinkat(Some(fd.as_raw_fd()), &b""[..]) {
let dest = match nix::fcntl::readlinkat(fd.as_raw_fd(), &b""[..]) {
Ok(dest) => dest,
Err(Errno::ESTALE) => {
self.report_stale_file_handle(None);
@ -1402,10 +1345,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 +1357,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 +1384,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 +1990,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 +2023,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,12 +36,12 @@ 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),
parent,
self.file_name.as_os_str(),
perms_from_metadata(&self.metadata)?,
) {
@ -53,9 +53,9 @@ 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),
parent,
self.file_name.as_os_str(),
OFlag::O_DIRECTORY | OFlag::O_CLOEXEC,
Mode::empty(),
@ -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;
@ -627,7 +627,7 @@ impl Extractor {
target.as_c_str(),
Some(parent),
file_name,
nix::fcntl::AtFlags::empty(),
nix::unistd::LinkatFlags::NoSymlinkFollow,
)
};
@ -697,13 +697,8 @@ impl Extractor {
}
let mut file = unsafe {
std::fs::File::from_raw_fd(
nix::fcntl::openat(
Some(parent),
file_name,
oflags,
Mode::from_bits(0o600).unwrap(),
)
.with_context(|| format!("failed to create file {file_name:?}"))?,
nix::fcntl::openat(parent, file_name, oflags, Mode::from_bits(0o600).unwrap())
.with_context(|| format!("failed to create file {file_name:?}"))?,
)
};
@ -727,7 +722,7 @@ impl Extractor {
}
if result.seeked_last {
while match nix::unistd::ftruncate(&file, size as i64) {
while match nix::unistd::ftruncate(file.as_raw_fd(), size as i64) {
Ok(_) => false,
Err(nix::errno::Errno::EINTR) => true,
Err(err) => return Err(err).context("error setting file size"),
@ -763,13 +758,8 @@ impl Extractor {
}
let mut file = tokio::fs::File::from_std(unsafe {
std::fs::File::from_raw_fd(
nix::fcntl::openat(
Some(parent),
file_name,
oflags,
Mode::from_bits(0o600).unwrap(),
)
.with_context(|| format!("failed to create file {file_name:?}"))?,
nix::fcntl::openat(parent, file_name, oflags, Mode::from_bits(0o600).unwrap())
.with_context(|| format!("failed to create file {file_name:?}"))?,
)
});
@ -794,7 +784,7 @@ impl Extractor {
}
if result.seeked_last {
while match nix::unistd::ftruncate(&file, size as i64) {
while match nix::unistd::ftruncate(file.as_raw_fd(), size as i64) {
Ok(_) => false,
Err(nix::errno::Errno::EINTR) => true,
Err(err) => return Err(err).context("error setting file size"),
@ -1034,7 +1024,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 +1043,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 +1054,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 +1062,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 +1075,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 +1312,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

@ -1,23 +1,19 @@
use std::os::fd::{AsRawFd, IntoRawFd};
use std::pin::Pin;
use std::task::{Context, Poll};
use anyhow::{bail, format_err, Error};
use futures::*;
use http_body_util::{BodyDataStream, BodyExt};
use hyper::body::Incoming;
use hyper::client::connect::{Connected, Connection};
use hyper::client::Client;
use hyper::http::Uri;
use hyper::http::{Request, Response};
use hyper_util::client::legacy::connect::{Connected, Connection};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper::{body::HttpBody, Body};
use pin_project_lite::pin_project;
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::UnixStream;
use proxmox_http::uri::json_object_to_query;
use proxmox_http::Body;
use proxmox_router::HttpError;
pub const DEFAULT_VSOCK_PORT: u16 = 807;
@ -34,9 +30,9 @@ pin_project! {
}
impl tower_service::Service<Uri> for VsockConnector {
type Response = TokioIo<UnixConnection>;
type Response = UnixConnection;
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<TokioIo<UnixConnection>, Error>> + Send>>;
type Future = Pin<Box<dyn Future<Output = Result<UnixConnection, Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut task::Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
@ -77,17 +73,16 @@ impl tower_service::Service<Uri> for VsockConnector {
)?;
let sock_addr = VsockAddr::new(cid, port as u32);
connect(sock_fd.as_raw_fd(), &sock_addr)?;
connect(sock_fd, &sock_addr)?;
// connect sync, but set nonblock after (tokio requires it)
let std_stream =
unsafe { std::os::unix::net::UnixStream::from_raw_fd(sock_fd.into_raw_fd()) };
let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(sock_fd) };
std_stream.set_nonblocking(true)?;
let stream = tokio::net::UnixStream::from_std(std_stream)?;
let connection = UnixConnection { stream };
Ok(TokioIo::new(connection))
Ok(connection)
})
// unravel the thread JoinHandle to a usable future
.map(|res| match res {
@ -138,7 +133,7 @@ impl AsyncWrite for UnixConnection {
/// Slimmed down version of HttpClient for virtio-vsock connections (file restore daemon)
pub struct VsockClient {
client: Client<VsockConnector, Body>,
client: Client<VsockConnector>,
cid: i32,
port: u16,
auth: Option<String>,
@ -147,7 +142,7 @@ pub struct VsockClient {
impl VsockClient {
pub fn new(cid: i32, port: u16, auth: Option<String>) -> Self {
let conn = VsockConnector {};
let client = Client::builder(TokioExecutor::new()).build::<_, Body>(conn);
let client = Client::builder().build::<_, Body>(conn);
Self {
client,
cid,
@ -184,7 +179,7 @@ impl VsockClient {
if !status.is_success() {
Self::api_response(resp).await.map(|_| ())?
} else {
futures::TryStreamExt::map_err(BodyDataStream::new(resp.into_body()), Error::from)
futures::TryStreamExt::map_err(resp.into_body(), Error::from)
.try_fold(output, move |acc, chunk| async move {
acc.write_all(&chunk).await?;
Ok::<_, Error>(acc)
@ -194,9 +189,9 @@ impl VsockClient {
Ok(())
}
async fn api_response(response: Response<Incoming>) -> Result<Value, Error> {
async fn api_response(response: Response<Body>) -> Result<Value, Error> {
let status = response.status();
let data = response.into_body().collect().await?.to_bytes();
let data = HttpBody::collect(response.into_body()).await?.to_bytes();
let text = String::from_utf8(data.to_vec()).unwrap();
if status.is_success() {
@ -242,7 +237,7 @@ impl VsockClient {
if let Some(data) = data {
if method == "POST" {
let builder = make_builder("application/json", &url);
let request = builder.body(data.to_string().into())?;
let request = builder.body(Body::from(data.to_string()))?;
return Ok(request);
} else {
let query = json_object_to_query(data)?;

View File

@ -9,21 +9,17 @@ 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-network-api.workspace = true
proxmox-router = { workspace = true, default-features = false }
proxmox-s3-client.workspace = true
proxmox-schema.workspace = true
proxmox-section-config.workspace = true
proxmox-shared-memory.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));
}
}
@ -113,16 +113,3 @@ pub fn complete_calendar_event(_arg: &str, _param: &HashMap<String, String>) ->
.map(|s| String::from(*s))
.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.
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)?;
Ok(parse_backend_config(&store_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,15 +4,12 @@ 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;
pub mod s3;
pub mod sync;
pub mod tape_job;
pub mod token_shadow;
@ -23,8 +20,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 +47,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 +139,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,227 @@
use std::collections::HashMap;
use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd};
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 = unsafe {
OwnedFd::from_raw_fd(
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 proxmox_network_api::{
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,68 +0,0 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use anyhow::Error;
use proxmox_s3_client::{S3ClientConf, S3_CLIENT_ID_SCHEMA};
use proxmox_schema::*;
use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
use pbs_buildcfg::configdir;
use crate::{open_backup_lockfile, replace_backup_config, BackupLockGuard};
pub static CONFIG: LazyLock<SectionConfig> = LazyLock::new(init);
fn init() -> SectionConfig {
let obj_schema = S3ClientConf::API_SCHEMA.unwrap_all_of_schema();
let plugin = SectionConfigPlugin::new(
"s3-endpoint".to_string(),
Some(String::from("id")),
obj_schema,
);
let mut config = SectionConfig::new(&S3_CLIENT_ID_SCHEMA);
config.register_plugin(plugin);
config
}
/// Configuration file location for S3 client.
pub const S3_CFG_FILENAME: &str = configdir!("/s3.cfg");
/// Configuration lock file used to prevent concurrent configuration update operations.
pub const S3_CFG_LOCKFILE: &str = configdir!("/.s3.lck");
/// Config type for s3 client config entries
pub const S3_CFG_TYPE_ID: &str = "s3-endpoint";
/// Get exclusive lock for S3 client configuration update.
pub fn lock_config() -> Result<BackupLockGuard, Error> {
open_backup_lockfile(S3_CFG_LOCKFILE, None, true)
}
/// Load s3 client configuration from file.
pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> {
parse_config(S3_CFG_FILENAME)
}
/// Save given s3 client configuration to file.
pub fn save_config(config: &SectionConfigData) -> Result<(), Error> {
let raw = CONFIG.write(S3_CFG_FILENAME, config)?;
replace_backup_config(S3_CFG_FILENAME, raw.as_bytes())?;
Ok(())
}
/// Shell completion helper to complete s3 client id's as found in the config.
pub fn complete_s3_client_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(),
}
}
fn parse_config(path: &str) -> Result<(SectionConfigData, [u8; 32]), Error> {
let content = proxmox_sys::fs::file_read_optional_string(path)?;
let content = content.unwrap_or_default();
let digest = openssl::sha::sha256(content.as_bytes());
let data = CONFIG.parse(path, &content)?;
Ok((data, digest))
}

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

@ -8,13 +8,12 @@ rust-version.workspace = true
[dependencies]
anyhow.workspace = true
base64.workspace = true
const_format.workspace = true
crc32fast.workspace = true
endian_trait.workspace = true
futures.workspace = true
hex = { workspace = true, features = [ "serde" ] }
http-body-util.workspace = true
hyper.workspace = true
libc.workspace = true
log.workspace = true
nix.workspace = true
@ -31,16 +30,11 @@ zstd-safe.workspace = true
pathpatterns.workspace = true
pxar.workspace = true
proxmox-async.workspace = true
proxmox-base64.workspace = true
proxmox-borrow.workspace = true
proxmox-http.workspace = true
proxmox-human-byte.workspace = true
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 +47,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,26 +8,21 @@ 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_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};
use crate::{DataBlob, DataStore};
pub const DATASTORE_LOCKS_DIR: &str = "/run/proxmox-backup/locks";
pub const PROTECTED_MARKER_FILENAME: &str = ".protected";
const PROTECTED_MARKER_FILENAME: &str = ".protected";
proxmox_schema::const_regex! {
pub BACKUP_FILES_AND_PROTECTED_REGEX = concatcp!(r"^(.*\.([fd]idx|blob)|\", PROTECTED_MARKER_FILENAME, ")$");
@ -166,7 +161,7 @@ impl BackupGroup {
use nix::fcntl::{openat, OFlag};
match openat(
Some(l2_fd),
l2_fd,
&manifest_path,
OFlag::O_RDONLY | OFlag::O_CLOEXEC,
nix::sys::stat::Mode::empty(),
@ -219,18 +214,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) -> 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);
@ -241,31 +232,10 @@ impl BackupGroup {
delete_stats.increment_protected_snapshots();
continue;
}
// also for S3 cleanup local only, the actual S3 objects will be removed below,
// reducing the number of required API calls.
snap.destroy(false, &DatastoreBackend::Filesystem)?;
snap.destroy(false)?;
delete_stats.increment_removed_snapshots();
}
if let DatastoreBackend::S3(s3_client) = backend {
let path = self.relative_group_path();
let group_prefix = path
.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(
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);
bail!("deleting objects failed");
}
}
// Note: make sure the old locking mechanism isn't used as `remove_dir_all` is not safe in
// that case
if delete_stats.all_removed() && !*OLD_LOCKING {
@ -276,132 +246,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);
@ -413,11 +257,9 @@ impl BackupGroup {
let owner_path = self.store.owner_path(&self.ns, &self.group);
if let Err(err) = std::fs::remove_file(&owner_path) {
if err.kind() != std::io::ErrorKind::NotFound {
bail!("removing the owner file '{owner_path:?}' failed - {err}");
}
}
std::fs::remove_file(&owner_path).map_err(|err| {
format_err!("removing the owner file '{owner_path:?}' failed - {err}")
})?;
let path = self.full_group_path();
@ -741,149 +583,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) -> Result<(), Error> {
let (_guard, _manifest_guard);
if !force {
_guard = self
@ -896,21 +599,6 @@ impl BackupDir {
bail!("cannot remove protected snapshot"); // use special error type?
}
if let DatastoreBackend::S3(s3_client) = backend {
let path = self.relative_path();
let snapshot_prefix = path
.to_str()
.ok_or_else(|| format_err!("invalid snapshot path"))?;
let prefix = format!("{S3_CONTENT_PREFIX}/{snapshot_prefix}");
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);
bail!("deleting objects failed");
}
}
let full_path = self.full_path();
log::info!("removing backup snapshot {:?}", full_path);
std::fs::remove_dir_all(&full_path).map_err(|err| {
@ -940,12 +628,6 @@ impl BackupDir {
// do to rectify the situation.
if guard.is_ok() && group.list_backups()?.is_empty() && !*OLD_LOCKING {
group.remove_group_dir()?;
if let DatastoreBackend::S3(s3_client) = backend {
let object_key =
super::s3::object_key_from_path(&group.relative_group_path(), "owner")
.context("invalid owner file object key")?;
proxmox_async::runtime::block_on(s3_client.delete_object(object_key))?;
}
} else if let Err(err) = guard {
log::debug!("{err:#}");
}
@ -982,7 +664,6 @@ impl BackupDir {
/// only use this method - anything else may break locking guarantees.
pub fn update_manifest(
&self,
backend: &DatastoreBackend,
update_fn: impl FnOnce(&mut BackupManifest),
) -> Result<(), Error> {
let _guard = self.lock_manifest()?;
@ -995,15 +676,6 @@ impl BackupDir {
let blob = DataBlob::encode(manifest.as_bytes(), None, true)?;
let raw_data = blob.raw_data();
if let DatastoreBackend::S3(s3_client) = backend {
let object_key =
super::s3::object_key_from_path(&self.relative_path(), MANIFEST_BLOB_NAME.as_ref())
.context("invalid manifest object key")?;
let data = hyper::body::Bytes::copy_from_slice(raw_data);
proxmox_async::runtime::block_on(s3_client.upload_replace_with_retry(object_key, data))
.context("failed to update manifest on s3 backend")?;
}
let mut path = self.full_path();
path.push(MANIFEST_BLOB_NAME.as_ref());
@ -1118,13 +790,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 +881,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

@ -81,11 +81,7 @@ impl<I: IndexFile, R: AsyncReadChunk + Send + Sync + 'static> CachedChunkReader<
let info = self.index.chunk_info(chunk.0).unwrap();
// will never be None, see AsyncChunkCacher
let data = self
.cache
.access(info.digest, &self.cacher, |_| Ok(()))
.await?
.unwrap();
let data = self.cache.access(info.digest, &self.cacher).await?.unwrap();
let want_bytes = ((info.range.end - cur_offset) as usize).min(size - read);
let slice = &mut buf[read..(read + want_bytes)];

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 { .. })
}
}
@ -973,8 +973,8 @@ impl ArchiveEntry {
size: Option<u64>,
) -> Self {
Self {
filepath: proxmox_base64::encode(filepath),
text: String::from_utf8_lossy(filepath.split(|x| *x == b'/').next_back().unwrap())
filepath: base64::encode(filepath),
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,27 +5,21 @@ 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};
use proxmox_sys::process_locker::{
ProcessLockExclusiveGuard, ProcessLockSharedGuard, ProcessLocker,
};
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 +134,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 +203,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 +219,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 +253,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 +292,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 +319,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 +339,7 @@ impl ChunkStore {
return Some((
Err(format_err!("unable to read subdir '{subdir}' - {err}")),
percentage,
ChunkExt::None,
false,
));
}
}
@ -423,17 +353,12 @@ impl ChunkStore {
ProcessLocker::oldest_shared_lock(self.locker.clone().unwrap())
}
pub fn mutex(&self) -> &std::sync::Mutex<()> {
&self.mutex
}
pub fn sweep_unused_chunks(
&self,
oldest_writer: i64,
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 +369,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)");
@ -466,63 +390,45 @@ impl ChunkStore {
let lock = self.mutex.lock();
if let Ok(stat) = fstatat(
Some(dirfd),
filename,
nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
) {
if let Ok(stat) = fstatat(dirfd, filename, nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW) {
let file_type = file_type_from_file_stat(&stat);
if file_type != Some(nix::dir::Type::File) {
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 +437,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
@ -587,28 +446,10 @@ impl ChunkStore {
/// Uses a 4 MiB fixed size, compressed but unencrypted chunk to test. The chunk is inserted in
/// the chunk store if not yet present.
/// Returns with error if the check could not be performed.
pub fn check_fs_atime_updates(
&self,
retry_on_file_changed: bool,
s3_client: Option<Arc<S3Client>>,
) -> Result<(), Error> {
pub fn check_fs_atime_updates(&self, retry_on_file_changed: bool) -> Result<(), Error> {
let (zero_chunk, digest) = DataChunkBuilder::build_zero_chunk(None, 4096 * 1024, true)?;
let (path, _digest) = self.chunk_path(&digest);
if let Some(ref s3_client) = s3_client {
if let Err(err) = std::fs::metadata(&path) {
if err.kind() == std::io::ErrorKind::NotFound {
let object_key = crate::s3::object_key_from_digest(&digest)?;
proxmox_async::runtime::block_on(s3_client.upload_no_replace_with_retry(
object_key,
zero_chunk.raw_data().to_vec().into(),
))
.context("failed to upload chunk to s3 backend")?;
}
}
}
let (pre_existing, _) = self.insert_chunk(&zero_chunk, &digest)?;
let (path, _digest) = self.chunk_path(&digest);
// Take into account timestamp update granularity in the kernel
// Blocking the thread is fine here since this runs in a worker.
@ -629,7 +470,7 @@ impl ChunkStore {
// two metadata calls, try to check once again on changed file
if metadata_before.ino() != metadata_now.ino() {
if retry_on_file_changed {
return self.check_fs_atime_updates(false, s3_client);
return self.check_fs_atime_updates(false);
}
bail!("chunk {path:?} changed twice during access time safety check, cannot proceed.");
}
@ -665,24 +506,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 +521,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 +546,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 +584,8 @@ impl ChunkStore {
.map_err(|err| format_err!("fsync failed: {err}"))?;
}
drop(lock);
Ok((false, encoded_size))
}
@ -774,82 +601,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 +668,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 +677,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 +706,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 {
@ -127,7 +134,7 @@ impl DynamicIndexReader {
let index = unsafe {
Mmap::map_fd(
&file,
rawfd,
header_size as u64,
index_count,
nix::sys::mman::ProtFlags::PROT_READ,
@ -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>,
@ -449,7 +599,6 @@ impl<S: ReadChunk> BufferedDynamicReader<S> {
store: &mut self.store,
index: &self.index,
},
|_| Ok(()),
)?
.ok_or_else(|| format_err!("chunk not found by cacher"))?;
@ -503,11 +652,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 +681,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,19 @@
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 +90,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 +102,6 @@ impl FixedIndexReader {
);
}
let chunk_size = usize::try_from(chunk_size)?;
let data = unsafe {
nix::sys::mman::mmap(
None,
@ -110,16 +109,14 @@ impl FixedIndexReader {
.ok_or_else(|| format_err!("invalid index size"))?,
nix::sys::mman::ProtFlags::PROT_READ,
nix::sys::mman::MapFlags::MAP_PRIVATE,
&file,
file.as_raw_fd(),
header_size as i64,
)
}?
.as_ptr()
.cast::<u8>();
}? as *mut u8;
Ok(Self {
_file: file,
chunk_size,
chunk_size: chunk_size as usize,
size,
index_length,
index: data,
@ -130,13 +127,15 @@ impl FixedIndexReader {
}
fn unmap(&mut self) -> Result<(), Error> {
let Some(index) = NonNull::new(self.index as *mut std::ffi::c_void) else {
if self.index.is_null() {
return Ok(());
};
}
let index_size = self.index_length * 32;
if let Err(err) = unsafe { nix::sys::mman::munmap(index, index_size) } {
if let Err(err) =
unsafe { nix::sys::mman::munmap(self.index as *mut std::ffi::c_void, index_size) }
{
bail!("unmap file failed - {}", err);
}
@ -215,32 +214,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 +241,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 +268,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)?;
let memory = MmapPtr(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"))?,
nix::sys::mman::ProtFlags::PROT_READ | nix::sys::mman::ProtFlags::PROT_WRITE,
nix::sys::mman::MapFlags::MAP_SHARED,
&file,
0,
)
}?);
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.size = u64::to_le(size as u64);
header.chunk_size = u64::to_le(chunk_size as u64);
header.uuid = *uuid.as_bytes();
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.as_raw_fd(), (header_size + index_size) as i64)?;
let data = unsafe {
nix::sys::mman::mmap(
None,
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.as_raw_fd(),
header_size as i64,
)
}? as *mut 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);
}
if self.index.is_null() {
return Ok(());
}
let index_size = self.index_length * 32;
if let Err(err) =
unsafe { nix::sys::mman::munmap(self.index as *mut std::ffi::c_void, 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 +361,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 +388,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 +433,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);
}
}

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