]> git.proxmox.com Git - pve-storage.git/blob - PVE/Storage/PBSPlugin.pm
bump version to 8.2.1
[pve-storage.git] / PVE / Storage / PBSPlugin.pm
1 package PVE::Storage::PBSPlugin;
2
3 # Plugin to access Proxmox Backup Server
4
5 use strict;
6 use warnings;
7
8 use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC);
9 use IO::File;
10 use JSON;
11 use POSIX qw(strftime ENOENT);
12
13 use PVE::APIClient::LWP;
14 use PVE::JSONSchema qw(get_standard_option);
15 use PVE::Network;
16 use PVE::PBSClient;
17 use PVE::Storage::Plugin;
18 use PVE::Tools qw(run_command file_read_firstline trim dir_glob_regex dir_glob_foreach $IPV6RE);
19
20 use base qw(PVE::Storage::Plugin);
21
22 # Configuration
23
24 sub type {
25 return 'pbs';
26 }
27
28 sub plugindata {
29 return {
30 content => [ {backup => 1, none => 1}, { backup => 1 }],
31 };
32 }
33
34 sub properties {
35 return {
36 datastore => {
37 description => "Proxmox Backup Server datastore name.",
38 type => 'string',
39 },
40 # openssl s_client -connect <host>:8007 2>&1 |openssl x509 -fingerprint -sha256
41 fingerprint => get_standard_option('fingerprint-sha256'),
42 'encryption-key' => {
43 description => "Encryption key. Use 'autogen' to generate one automatically without passphrase.",
44 type => 'string',
45 },
46 port => {
47 description => "For non default port.",
48 type => 'integer',
49 minimum => 1,
50 maximum => 65535,
51 default => 8007,
52 },
53 };
54 }
55
56 sub options {
57 return {
58 server => { fixed => 1 },
59 datastore => { fixed => 1 },
60 port => { optional => 1 },
61 nodes => { optional => 1},
62 disable => { optional => 1},
63 content => { optional => 1},
64 username => { optional => 1 },
65 password => { optional => 1 },
66 'encryption-key' => { optional => 1 },
67 maxfiles => { optional => 1 },
68 'prune-backups' => { optional => 1 },
69 fingerprint => { optional => 1 },
70 };
71 }
72
73 # Helpers
74
75 sub pbs_password_file_name {
76 my ($scfg, $storeid) = @_;
77
78 return "/etc/pve/priv/storage/${storeid}.pw";
79 }
80
81 sub pbs_set_password {
82 my ($scfg, $storeid, $password) = @_;
83
84 my $pwfile = pbs_password_file_name($scfg, $storeid);
85 mkdir "/etc/pve/priv/storage";
86
87 PVE::Tools::file_set_contents($pwfile, "$password\n");
88 }
89
90 sub pbs_delete_password {
91 my ($scfg, $storeid) = @_;
92
93 my $pwfile = pbs_password_file_name($scfg, $storeid);
94
95 unlink $pwfile;
96 }
97
98 sub pbs_get_password {
99 my ($scfg, $storeid) = @_;
100
101 my $pwfile = pbs_password_file_name($scfg, $storeid);
102
103 return PVE::Tools::file_read_firstline($pwfile);
104 }
105
106 sub pbs_encryption_key_file_name {
107 my ($scfg, $storeid) = @_;
108
109 return "/etc/pve/priv/storage/${storeid}.enc";
110 }
111
112 sub pbs_set_encryption_key {
113 my ($scfg, $storeid, $key) = @_;
114
115 my $pwfile = pbs_encryption_key_file_name($scfg, $storeid);
116 mkdir "/etc/pve/priv/storage";
117
118 PVE::Tools::file_set_contents($pwfile, "$key\n");
119 }
120
121 sub pbs_delete_encryption_key {
122 my ($scfg, $storeid) = @_;
123
124 my $pwfile = pbs_encryption_key_file_name($scfg, $storeid);
125
126 if (!unlink $pwfile) {
127 return if $! == ENOENT;
128 die "failed to delete encryption key! $!\n";
129 }
130 delete $scfg->{'encryption-key'};
131 }
132
133 sub pbs_get_encryption_key {
134 my ($scfg, $storeid) = @_;
135
136 my $pwfile = pbs_encryption_key_file_name($scfg, $storeid);
137
138 return PVE::Tools::file_get_contents($pwfile);
139 }
140
141 # Returns a file handle if there is an encryption key, or `undef` if there is not. Dies on error.
142 sub pbs_open_encryption_key {
143 my ($scfg, $storeid) = @_;
144
145 my $encryption_key_file = pbs_encryption_key_file_name($scfg, $storeid);
146
147 my $keyfd;
148 if (!open($keyfd, '<', $encryption_key_file)) {
149 return undef if $! == ENOENT;
150 die "failed to open encryption key: $encryption_key_file: $!\n";
151 }
152
153 return $keyfd;
154 }
155
156 sub print_volid {
157 my ($storeid, $btype, $bid, $btime) = @_;
158
159 my $time_str = strftime("%FT%TZ", gmtime($btime));
160 my $volname = "backup/${btype}/${bid}/${time_str}";
161
162 return "${storeid}:${volname}";
163 }
164
165 my $USE_CRYPT_PARAMS = {
166 backup => 1,
167 restore => 1,
168 'upload-log' => 1,
169 };
170
171 my sub do_raw_client_cmd {
172 my ($scfg, $storeid, $client_cmd, $param, %opts) = @_;
173
174 my $use_crypto = $USE_CRYPT_PARAMS->{$client_cmd};
175
176 my $client_exe = '/usr/bin/proxmox-backup-client';
177 die "executable not found '$client_exe'! Proxmox backup client not installed?\n"
178 if ! -x $client_exe;
179
180 my $repo = PVE::PBSClient::get_repository($scfg);
181
182 my $userns_cmd = delete $opts{userns_cmd};
183
184 my $cmd = [];
185
186 push @$cmd, @$userns_cmd if defined($userns_cmd);
187
188 push @$cmd, $client_exe, $client_cmd;
189
190 # This must live in the top scope to not get closed before the `run_command`
191 my $keyfd;
192 if ($use_crypto) {
193 if (defined($keyfd = pbs_open_encryption_key($scfg, $storeid))) {
194 my $flags = fcntl($keyfd, F_GETFD, 0)
195 // die "failed to get file descriptor flags: $!\n";
196 fcntl($keyfd, F_SETFD, $flags & ~FD_CLOEXEC)
197 or die "failed to remove FD_CLOEXEC from encryption key file descriptor\n";
198 push @$cmd, '--crypt-mode=encrypt', '--keyfd='.fileno($keyfd);
199 } else {
200 push @$cmd, '--crypt-mode=none';
201 }
202 }
203
204 push @$cmd, @$param if defined($param);
205
206 push @$cmd, "--repository", $repo;
207
208 local $ENV{PBS_PASSWORD} = pbs_get_password($scfg, $storeid);
209
210 local $ENV{PBS_FINGERPRINT} = $scfg->{fingerprint};
211
212 # no ascii-art on task logs
213 local $ENV{PROXMOX_OUTPUT_NO_BORDER} = 1;
214 local $ENV{PROXMOX_OUTPUT_NO_HEADER} = 1;
215
216 if (my $logfunc = $opts{logfunc}) {
217 $logfunc->("run: " . join(' ', @$cmd));
218 }
219
220 run_command($cmd, %opts);
221 }
222
223 # FIXME: External perl code should NOT have access to this.
224 #
225 # There should be separate functions to
226 # - make backups
227 # - restore backups
228 # - restore files
229 # with a sane API
230 sub run_raw_client_cmd {
231 my ($scfg, $storeid, $client_cmd, $param, %opts) = @_;
232 return do_raw_client_cmd($scfg, $storeid, $client_cmd, $param, %opts);
233 }
234
235 sub run_client_cmd {
236 my ($scfg, $storeid, $client_cmd, $param, $no_output) = @_;
237
238 my $json_str = '';
239 my $outfunc = sub { $json_str .= "$_[0]\n" };
240
241 $param = [] if !defined($param);
242 $param = [ $param ] if !ref($param);
243
244 $param = [@$param, '--output-format=json'] if !$no_output;
245
246 do_raw_client_cmd($scfg, $storeid, $client_cmd, $param,
247 outfunc => $outfunc, errmsg => 'proxmox-backup-client failed');
248
249 return undef if $no_output;
250
251 my $res = decode_json($json_str);
252
253 return $res;
254 }
255
256 # Storage implementation
257
258 sub extract_vzdump_config {
259 my ($class, $scfg, $volname, $storeid) = @_;
260
261 my ($vtype, $name, $vmid, undef, undef, undef, $format) = $class->parse_volname($volname);
262
263 my $config = '';
264 my $outfunc = sub { $config .= "$_[0]\n" };
265
266 my $config_name;
267 if ($format eq 'pbs-vm') {
268 $config_name = 'qemu-server.conf';
269 } elsif ($format eq 'pbs-ct') {
270 $config_name = 'pct.conf';
271 } else {
272 die "unable to extract configuration for backup format '$format'\n";
273 }
274
275 do_raw_client_cmd($scfg, $storeid, 'restore', [ $name, $config_name, '-' ],
276 outfunc => $outfunc, errmsg => 'proxmox-backup-client failed');
277
278 return $config;
279 }
280
281 sub prune_backups {
282 my ($class, $scfg, $storeid, $keep, $vmid, $type, $dryrun, $logfunc) = @_;
283
284 $logfunc //= sub { print "$_[1]\n" };
285
286 my $backups = $class->list_volumes($storeid, $scfg, $vmid, ['backup']);
287
288 $type = 'vm' if defined($type) && $type eq 'qemu';
289 $type = 'ct' if defined($type) && $type eq 'lxc';
290
291 my $backup_groups = {};
292 foreach my $backup (@{$backups}) {
293 (my $backup_type = $backup->{format}) =~ s/^pbs-//;
294
295 next if defined($type) && $backup_type ne $type;
296
297 my $backup_group = "$backup_type/$backup->{vmid}";
298 $backup_groups->{$backup_group} = 1;
299 }
300
301 my @param;
302
303 my $keep_all = delete $keep->{'keep-all'};
304
305 if (!$keep_all) {
306 foreach my $opt (keys %{$keep}) {
307 next if $keep->{$opt} == 0;
308 push @param, "--$opt";
309 push @param, "$keep->{$opt}";
310 }
311 } else { # no need to pass anything to PBS
312 $keep = { 'keep-all' => 1 };
313 }
314
315 push @param, '--dry-run' if $dryrun;
316
317 my $prune_list = [];
318 my $failed;
319
320 foreach my $backup_group (keys %{$backup_groups}) {
321 $logfunc->('info', "running 'proxmox-backup-client prune' for '$backup_group'")
322 if !$dryrun;
323 eval {
324 my $res = run_client_cmd($scfg, $storeid, 'prune', [ $backup_group, @param ]);
325
326 foreach my $backup (@{$res}) {
327 die "result from proxmox-backup-client is not as expected\n"
328 if !defined($backup->{'backup-time'})
329 || !defined($backup->{'backup-type'})
330 || !defined($backup->{'backup-id'})
331 || !defined($backup->{'keep'});
332
333 my $ctime = $backup->{'backup-time'};
334 my $type = $backup->{'backup-type'};
335 my $vmid = $backup->{'backup-id'};
336 my $volid = print_volid($storeid, $type, $vmid, $ctime);
337
338 push @{$prune_list}, {
339 ctime => $ctime,
340 mark => $backup->{keep} ? 'keep' : 'remove',
341 type => $type eq 'vm' ? 'qemu' : 'lxc',
342 vmid => $vmid,
343 volid => $volid,
344 };
345 }
346 };
347 if (my $err = $@) {
348 $logfunc->('err', "prune '$backup_group': $err\n");
349 $failed = 1;
350 }
351 }
352 die "error pruning backups - check log\n" if $failed;
353
354 return $prune_list;
355 }
356
357 my $autogen_encryption_key = sub {
358 my ($scfg, $storeid) = @_;
359 my $encfile = pbs_encryption_key_file_name($scfg, $storeid);
360 if (-f $encfile) {
361 rename $encfile, "$encfile.old";
362 }
363 my $cmd = ['proxmox-backup-client', 'key', 'create', '--kdf', 'none', $encfile];
364 run_command($cmd, errmsg => 'failed to create encryption key');
365 return PVE::Tools::file_get_contents($encfile);
366 };
367
368 sub on_add_hook {
369 my ($class, $storeid, $scfg, %param) = @_;
370
371 my $res = {};
372
373 if (defined(my $password = $param{password})) {
374 pbs_set_password($scfg, $storeid, $password);
375 } else {
376 pbs_delete_password($scfg, $storeid);
377 }
378
379 if (defined(my $encryption_key = $param{'encryption-key'})) {
380 my $decoded_key;
381 if ($encryption_key eq 'autogen') {
382 $res->{'encryption-key'} = $autogen_encryption_key->($scfg, $storeid);
383 $decoded_key = decode_json($res->{'encryption-key'});
384 } else {
385 $decoded_key = eval { decode_json($encryption_key) };
386 if ($@ || !exists($decoded_key->{data})) {
387 die "Value does not seems like a valid, JSON formatted encryption key!\n";
388 }
389 pbs_set_encryption_key($scfg, $storeid, $encryption_key);
390 $res->{'encryption-key'} = $encryption_key;
391 }
392 $scfg->{'encryption-key'} = $decoded_key->{fingerprint} || 1;
393 } else {
394 pbs_delete_encryption_key($scfg, $storeid);
395 }
396
397 return $res;
398 }
399
400 sub on_update_hook {
401 my ($class, $storeid, $scfg, %param) = @_;
402
403 my $res = {};
404
405 if (exists($param{password})) {
406 if (defined($param{password})) {
407 pbs_set_password($scfg, $storeid, $param{password});
408 } else {
409 pbs_delete_password($scfg, $storeid);
410 }
411 }
412
413 if (exists($param{'encryption-key'})) {
414 if (defined(my $encryption_key = delete($param{'encryption-key'}))) {
415 my $decoded_key;
416 if ($encryption_key eq 'autogen') {
417 $res->{'encryption-key'} = $autogen_encryption_key->($scfg, $storeid);
418 $decoded_key = decode_json($res->{'encryption-key'});
419 } else {
420 $decoded_key = eval { decode_json($encryption_key) };
421 if ($@ || !exists($decoded_key->{data})) {
422 die "Value does not seems like a valid, JSON formatted encryption key!\n";
423 }
424 pbs_set_encryption_key($scfg, $storeid, $encryption_key);
425 $res->{'encryption-key'} = $encryption_key;
426 }
427 $scfg->{'encryption-key'} = $decoded_key->{fingerprint} || 1;
428 } else {
429 pbs_delete_encryption_key($scfg, $storeid);
430 }
431 }
432
433 return $res;
434 }
435
436 sub on_delete_hook {
437 my ($class, $storeid, $scfg) = @_;
438
439 pbs_delete_password($scfg, $storeid);
440 pbs_delete_encryption_key($scfg, $storeid);
441
442 return;
443 }
444
445 sub parse_volname {
446 my ($class, $volname) = @_;
447
448 if ($volname =~ m!^backup/([^\s_]+)/([^\s_]+)/([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)$!) {
449 my $btype = $1;
450 my $bid = $2;
451 my $btime = $3;
452 my $format = "pbs-$btype";
453
454 my $name = "$btype/$bid/$btime";
455
456 if ($bid =~ m/^\d+$/) {
457 return ('backup', $name, $bid, undef, undef, undef, $format);
458 } else {
459 return ('backup', $name, undef, undef, undef, undef, $format);
460 }
461 }
462
463 die "unable to parse PBS volume name '$volname'\n";
464 }
465
466 sub path {
467 my ($class, $scfg, $volname, $storeid, $snapname) = @_;
468
469 die "volume snapshot is not possible on pbs storage"
470 if defined($snapname);
471
472 my ($vtype, $name, $vmid) = $class->parse_volname($volname);
473
474 my $repo = PVE::PBSClient::get_repository($scfg);
475
476 # artifical url - we currently do not use that anywhere
477 my $path = "pbs://$repo/$name";
478
479 return ($path, $vmid, $vtype);
480 }
481
482 sub create_base {
483 my ($class, $storeid, $scfg, $volname) = @_;
484
485 die "can't create base images in pbs storage\n";
486 }
487
488 sub clone_image {
489 my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_;
490
491 die "can't clone images in pbs storage\n";
492 }
493
494 sub alloc_image {
495 my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size) = @_;
496
497 die "can't allocate space in pbs storage\n";
498 }
499
500 sub free_image {
501 my ($class, $storeid, $scfg, $volname, $isBase) = @_;
502
503 my ($vtype, $name, $vmid) = $class->parse_volname($volname);
504
505 run_client_cmd($scfg, $storeid, "forget", [ $name ], 1);
506 }
507
508
509 sub list_images {
510 my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_;
511
512 my $res = [];
513
514 return $res;
515 }
516
517 my sub snapshot_files_encrypted {
518 my ($files) = @_;
519 return 0 if !$files;
520
521 my $any;
522 my $all = 1;
523 for my $file (@$files) {
524 my $fn = $file->{filename};
525 next if $fn eq 'client.log.blob' || $fn eq 'index.json.blob';
526
527 my $crypt = $file->{'crypt-mode'};
528
529 $all = 0 if !$crypt || $crypt ne 'encrypt';
530 $any ||= defined($crypt) && $crypt eq 'encrypt';
531 }
532 return $any && $all;
533 }
534
535 sub list_volumes {
536 my ($class, $storeid, $scfg, $vmid, $content_types) = @_;
537
538 my $res = [];
539
540 return $res if !grep { $_ eq 'backup' } @$content_types;
541
542 my $data = run_client_cmd($scfg, $storeid, "snapshots");
543
544 foreach my $item (@$data) {
545 my $btype = $item->{"backup-type"};
546 my $bid = $item->{"backup-id"};
547 my $epoch = $item->{"backup-time"};
548 my $size = $item->{size} // 1;
549
550 next if !($btype eq 'vm' || $btype eq 'ct');
551 next if $bid !~ m/^\d+$/;
552 next if defined($vmid) && $bid ne $vmid;
553
554 my $volid = print_volid($storeid, $btype, $bid, $epoch);
555
556 my $info = {
557 volid => $volid,
558 format => "pbs-$btype",
559 size => $size,
560 content => 'backup',
561 vmid => int($bid),
562 ctime => $epoch,
563 };
564
565 $info->{verification} = $item->{verification} if defined($item->{verification});
566 $info->{notes} = $item->{comment} if defined($item->{comment});
567 if (defined($item->{fingerprint})) {
568 $info->{encrypted} = $item->{fingerprint};
569 } elsif (snapshot_files_encrypted($item->{files})) {
570 $info->{encrypted} = '1';
571 }
572
573 push @$res, $info;
574 }
575
576 return $res;
577 }
578
579 sub status {
580 my ($class, $storeid, $scfg, $cache) = @_;
581
582 my $total = 0;
583 my $free = 0;
584 my $used = 0;
585 my $active = 0;
586
587 eval {
588 my $res = run_client_cmd($scfg, $storeid, "status");
589
590 $active = 1;
591 $total = $res->{total};
592 $used = $res->{used};
593 $free = $res->{avail};
594 };
595 if (my $err = $@) {
596 warn $err;
597 }
598
599 return ($total, $free, $used, $active);
600 }
601
602 # TODO: use a client with native rust/proxmox-backup bindings to profit from
603 # API schema checks and types
604 my sub pbs_api_connect {
605 my ($scfg, $password) = @_;
606
607 my $params = {};
608
609 my $user = $scfg->{username} // 'root@pam';
610
611 if (my $tokenid = PVE::AccessControl::pve_verify_tokenid($user, 1)) {
612 $params->{apitoken} = "PBSAPIToken=${tokenid}:${password}";
613 } else {
614 $params->{password} = $password;
615 $params->{username} = $user;
616 }
617
618 if (my $fp = $scfg->{fingerprint}) {
619 $params->{cached_fingerprints}->{uc($fp)} = 1;
620 }
621
622 my $conn = PVE::APIClient::LWP->new(
623 %$params,
624 host => $scfg->{server},
625 port => $scfg->{port} // 8007,
626 timeout => 7, # cope with a 401 (3s api delay) and high latency
627 cookie_name => 'PBSAuthCookie',
628 );
629
630 return $conn;
631 }
632
633 # can also be used for not (yet) added storages, pass $scfg with
634 # {
635 # server
636 # user
637 # port (optional default to 8007)
638 # fingerprint (optional for trusted certs)
639 # }
640 sub scan_datastores {
641 my ($scfg, $password) = @_;
642
643 my $conn = pbs_api_connect($scfg, $password);
644
645 my $response = eval { $conn->get('/api2/json/admin/datastore', {}) };
646 die "error fetching datastores - $@" if $@;
647
648 return $response;
649 }
650
651 sub activate_storage {
652 my ($class, $storeid, $scfg, $cache) = @_;
653
654 my $password = pbs_get_password($scfg, $storeid);
655
656 my $datastores = eval { scan_datastores($scfg, $password) };
657 die "$storeid: $@" if $@;
658
659 my $datastore = $scfg->{datastore};
660
661 for my $ds (@$datastores) {
662 if ($ds->{store} eq $datastore) {
663 return 1;
664 }
665 }
666
667 die "$storeid: Cannot find datastore '$datastore', check permissions and existance!\n";
668 }
669
670 sub deactivate_storage {
671 my ($class, $storeid, $scfg, $cache) = @_;
672 return 1;
673 }
674
675 sub activate_volume {
676 my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
677
678 die "volume snapshot is not possible on pbs device" if $snapname;
679
680 return 1;
681 }
682
683 sub deactivate_volume {
684 my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
685
686 die "volume snapshot is not possible on pbs device" if $snapname;
687
688 return 1;
689 }
690
691 sub get_volume_notes {
692 my ($class, $scfg, $storeid, $volname, $timeout) = @_;
693
694 my (undef, $name, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
695
696 my $data = run_client_cmd($scfg, $storeid, "snapshot", [ "notes", "show", $name ]);
697
698 return $data->{notes};
699 }
700
701 sub update_volume_notes {
702 my ($class, $scfg, $storeid, $volname, $notes, $timeout) = @_;
703
704 my (undef, $name, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
705
706 run_client_cmd($scfg, $storeid, "snapshot", [ "notes", "update", $name, $notes ], 1);
707
708 return undef;
709 }
710
711 sub volume_size_info {
712 my ($class, $scfg, $storeid, $volname, $timeout) = @_;
713
714 my ($vtype, $name, undef, undef, undef, undef, $format) = $class->parse_volname($volname);
715
716 my $data = run_client_cmd($scfg, $storeid, "files", [ $name ]);
717
718 my $size = 0;
719 foreach my $info (@$data) {
720 $size += $info->{size} if $info->{size};
721 }
722
723 my $used = $size;
724
725 return wantarray ? ($size, $format, $used, undef) : $size;
726 }
727
728 sub volume_resize {
729 my ($class, $scfg, $storeid, $volname, $size, $running) = @_;
730 die "volume resize is not possible on pbs device";
731 }
732
733 sub volume_snapshot {
734 my ($class, $scfg, $storeid, $volname, $snap) = @_;
735 die "volume snapshot is not possible on pbs device";
736 }
737
738 sub volume_snapshot_rollback {
739 my ($class, $scfg, $storeid, $volname, $snap) = @_;
740 die "volume snapshot rollback is not possible on pbs device";
741 }
742
743 sub volume_snapshot_delete {
744 my ($class, $scfg, $storeid, $volname, $snap) = @_;
745 die "volume snapshot delete is not possible on pbs device";
746 }
747
748 sub volume_has_feature {
749 my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_;
750
751 return undef;
752 }
753
754 1;