From cd3d13d84fcb0d188042bbf9da6f5344573b3fdf Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 25 Sep 2026 11:31:39 +0200 Subject: [PATCH] Upstream proxmox-git/pve-guest-common state of the touched files before 96c6fc4292 --- src/PVE/GuestHelpers.pm | 457 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 src/PVE/GuestHelpers.pm diff --git a/src/PVE/GuestHelpers.pm b/src/PVE/GuestHelpers.pm new file mode 100644 index 0000000..f8d112b --- /dev/null +++ b/src/PVE/GuestHelpers.pm @@ -0,0 +1,457 @@ +package PVE::GuestHelpers; + +use strict; +use warnings; + +use PVE::Exception qw(raise_perm_exc); +use PVE::Tools qw(split_list); +use PVE::Storage; + +use POSIX qw(strftime); +use Scalar::Util qw(weaken); + +my $have_sdn; +eval { + require PVE::Network::SDN; + $have_sdn = 1; +}; + +use base qw(Exporter); + +our @EXPORT_OK = qw( + assert_tag_permissions + check_vnet_access + get_allowed_tags + safe_boolean_ne + safe_num_ne + safe_string_ne + typesafe_ne +); + +# We use a separate lock to block migration while a replication job +# is running. + +our $lockdir = '/var/lock/pve-manager'; + +# safe variable comparison functions + +sub safe_num_ne { + my ($a, $b) = @_; + + return 0 if !defined($a) && !defined($b); + return 1 if !defined($a); + return 1 if !defined($b); + + return $a != $b; +} + +sub safe_string_ne { + my ($a, $b) = @_; + + return 0 if !defined($a) && !defined($b); + return 1 if !defined($a); + return 1 if !defined($b); + + return $a ne $b; +} + +sub safe_boolean_ne { + my ($a, $b) = @_; + + # we don't check if value is defined, since undefined + # is false (so it's a valid boolean) + + # negate both values to normalize and compare + return !$a != !$b; +} + +sub typesafe_ne { + my ($a, $b, $type) = @_; + + return 0 if !defined($a) && !defined($b); + return 1 if !defined($a); + return 1 if !defined($b); + + if ($type eq 'string') { + return safe_string_ne($a, $b); + } elsif ($type eq 'number' || $type eq 'integer') { + return safe_num_ne($a, $b); + } elsif ($type eq 'boolean') { + return safe_boolean_ne($a, $b); + } + + die "internal error: can't compare $a and $b with type $type"; +} + +sub guest_migration_lock { + my ($vmid, $timeout, $func, @param) = @_; + + my $lockid = "pve-migrate-$vmid"; + + mkdir $lockdir; + + my $res = PVE::Tools::lock_file("$lockdir/$lockid", $timeout, $func, @param); + die $@ if $@; + + return $res; +} + +sub check_hookscript { + my ($volid, $storecfg) = @_; + + $storecfg = PVE::Storage::config() if !defined($storecfg); + my ($path, undef, $type) = PVE::Storage::path($storecfg, $volid); + + die "'$volid' is not in the snippets directory\n" + if $type ne 'snippets'; + + die "script '$volid' does not exist\n" + if !-f $path; + + die "script '$volid' is not executable\n" + if !-x $path; + + return $path; +} + +sub exec_hookscript { + my ($conf, $vmid, $phase, $stop_on_error) = @_; + + return if !$conf->{hookscript}; + + eval { + my $hookscript = check_hookscript($conf->{hookscript}); + die $@ if $@; + + PVE::Tools::run_command([$hookscript, $vmid, $phase]); + }; + if (my $err = $@) { + my $errmsg = "hookscript error for $vmid on $phase: $err\n"; + die $errmsg if ($stop_on_error); + warn $errmsg; + } +} + +# takes a snapshot list (e.g., qm/pct snapshot_list API call result) and +# prints it out in a nice tree sorted by age. Can cope with multiple roots +sub print_snapshot_tree { + my ($snapshot_list) = @_; + + my $snapshots = { map { $_->{name} => $_ } @$snapshot_list }; + + my @roots; + foreach my $e (@$snapshot_list) { + my $parent; + if (($parent = $e->{parent}) && defined $snapshots->{$parent}) { + push @{ $snapshots->{$parent}->{children} }, $e->{name}; + } else { + push @roots, $e->{name}; + } + } + + # sort the elements by snaptime - with "current" (no snaptime) highest + my $snaptimesort = sub { + return +1 if !defined $snapshots->{$a}->{snaptime}; + return -1 if !defined $snapshots->{$b}->{snaptime}; + return $snapshots->{$a}->{snaptime} <=> $snapshots->{$b}->{snaptime}; + }; + + # recursion function for displaying the tree + my $snapshottree_weak; + $snapshottree_weak = sub { + my ($prefix, $root, $snapshots) = @_; + my $e = $snapshots->{$root}; + + my $description = $e->{description} || 'no-description'; + ($description) = $description =~ m/(.*)$/m; + + my $timestring = ""; + if (defined $e->{snaptime}) { + $timestring = strftime("%F %H:%M:%S", localtime($e->{snaptime})); + } + + my $len = 30 - length($prefix); # for aligning the description + $len = 0 if $len < 0; + printf("%s %-${len}s %-23s %s\n", $prefix, $root, $timestring, $description); + + if ($e->{children}) { + $prefix = " $prefix"; + foreach my $child (sort $snaptimesort @{ $e->{children} }) { + $snapshottree_weak->($prefix, $child, $snapshots); + } + } + }; + my $snapshottree = $snapshottree_weak; + weaken($snapshottree_weak); + + foreach my $root (sort $snaptimesort @roots) { + $snapshottree->('`->', $root, $snapshots); + } +} + +sub format_pending { + my ($data) = @_; + foreach my $item (sort { $a->{key} cmp $b->{key} } @$data) { + my $k = $item->{key}; + next if $k eq 'digest'; + my $v = $item->{value}; + my $p = $item->{pending}; + if ($k eq 'description') { + $v = PVE::Tools::encode_text($v) if defined($v); + $p = PVE::Tools::encode_text($p) if defined($p); + } + if (defined($v)) { + if ($item->{delete}) { + print "del $k: $v\n"; + } elsif (defined($p)) { + print "cur $k: $v\n"; + print "new $k: $p\n"; + } else { + print "cur $k: $v\n"; + } + } elsif (defined($p)) { + print "new $k: $p\n"; + } + } +} + +# returns the config as an array of hashes, each hash can have the following keys: +# key: the config property name, non-optional +# value: the current value in effect - if any +# pending: a new, still pending, value - if any +# delete: when deletions are pending, this is set to either 2 (force) or 1 (graceful) +sub config_with_pending_array { + my ($conf, $pending_delete_hash) = @_; + + my $pending = delete $conf->{pending}; + # we don't care for snapshots in pending and it makes our loops throw up + delete $conf->{snapshots}; + + my $res = []; + foreach my $opt (keys %$conf) { + next if ref($conf->{$opt}); # e.g., "raw" lxc.* keys are added as array ref + + my $item = { + key => $opt, + value => $conf->{$opt}, + }; + $item->{pending} = delete $pending->{$opt} if defined($pending->{$opt}); + my $delete = delete $pending_delete_hash->{$opt}; + $item->{delete} = $delete->{force} ? 2 : 1 if defined($delete); + + push @$res, $item; + } + + foreach my $opt (keys %$pending) { + next if $opt eq 'delete'; + push @$res, + { + key => $opt, + pending => $pending->{$opt}, + }; + } + + while (my ($opt, $force) = each %$pending_delete_hash) { + push @$res, + { + key => $opt, + delete => $force ? 2 : 1, + }; + } + + return $res; +} + +# returns the allowed tags for the given user +# in scalar context, returns the list of allowed tags that exist +# in list context, returns a tuple of allowed tags, privileged tags, and if freeform is enabled +# +# first parameter is a bool if the user is 'privileged' (normally Sys.Modify on /) +# second parameter is a closure which takes the vmid. should check if the user can see the vm tags +sub get_allowed_tags { + my ($rpcenv, $user, $privileged_user) = @_; + + $privileged_user //= ($rpcenv->check($user, '/', ['Sys.Modify'], 1) // 0); + + my $datacenter_config = PVE::Cluster::cfs_read_file('datacenter.cfg'); + + my $allowed_tags = {}; + my $privileged_tags = {}; + if (my $tags = $datacenter_config->{'registered-tags'}) { + $privileged_tags->{$_} = 1 for $tags->@*; + } + my $user_tag_privs = $datacenter_config->{'user-tag-access'} // {}; + my $user_allow = $user_tag_privs->{'user-allow'} // 'free'; + my $freeform = $user_allow eq 'free'; + + if ($user_allow ne 'none' || $privileged_user) { + $allowed_tags->{$_} = 1 for ($user_tag_privs->{'user-allow-list'} // [])->@*; + } + + if ($user_allow eq 'free' || $user_allow eq 'existing' || $privileged_user) { + my $props = PVE::Cluster::get_guest_config_properties(['tags']); + for my $vmid (keys $props->%*) { + next + if !$privileged_user + && !$rpcenv->check_vm_perm($user, $vmid, undef, ['VM.Audit'], 0, 1); + $allowed_tags->{$_} = 1 for split_list($props->{$vmid}->{tags}); + } + } + + if ($privileged_user) { + $allowed_tags->{$_} = 1 for keys $privileged_tags->%*; + } else { + delete $allowed_tags->{$_} for keys $privileged_tags->%*; + } + + return wantarray ? ($allowed_tags, $privileged_tags, $freeform) : $allowed_tags; +} + +# checks the permissions for setting/updating/removing tags for guests +# tagopt_old and tagopt_new expect the tags as they are in the config +# +# either returns gracefully or raises a permission exception +sub assert_tag_permissions { + my ($vmid, $tagopt_old, $tagopt_new, $rpcenv, $authuser) = @_; + + $rpcenv->check_vm_perm($authuser, $vmid, undef, ['VM.Config.Options']); + + my $privileged_user = $rpcenv->check($authuser, '/', ['Sys.Modify'], 1) // 0; + + my ($allowed_tags, $privileged_tags, $freeform); + my $check_single_tag = sub { + my ($tag) = @_; + return if $privileged_user; + + if (!defined($allowed_tags // $privileged_tags // $freeform)) { # cache + ($allowed_tags, $privileged_tags, $freeform) = + get_allowed_tags($rpcenv, $authuser, $privileged_user); + } + + if ((!$allowed_tags->{$tag} && !$freeform) || $privileged_tags->{$tag}) { + raise_perm_exc("/, Sys.Modify for modifying tag '$tag'"); + } + + return; + }; + + my ($old_tags, $new_tags, $all_tags) = ({}, {}, {}); + + $all_tags->{$_} = $old_tags->{$_} += 1 for split_list($tagopt_old // ''); + $all_tags->{$_} = $new_tags->{$_} += 1 for split_list($tagopt_new // ''); + + for my $tag (keys $all_tags->%*) { + next if ($new_tags->{$tag} // 0) == ($old_tags->{$tag} // 0); + $check_single_tag->($tag); + } +} + +sub get_unique_tags { + my ($tags, $no_join_result) = @_; + + $tags = [split_list($tags // '')] if ref($tags) ne 'ARRAY'; + return !$no_join_result ? '' : [] if !scalar($tags->@*); + + my $datacenter_config = PVE::Cluster::cfs_read_file('datacenter.cfg'); + my $tag_style_config = $datacenter_config->{'tag-style'} // {}; + my $case_sensitive = !!$tag_style_config->{'case-sensitive'}; + + my $seen_tags = {}; + my $res = []; + if (!defined($tag_style_config->{ordering}) || $tag_style_config->{ordering} ne 'config') { + for my $tag (sort { $case_sensitive ? $a cmp $b : lc($a) cmp lc($b) } $tags->@*) { + $tag = lc($tag) if !$case_sensitive; + next if $seen_tags->{$tag}; + $seen_tags->{$tag} = 1; + push @$res, $tag; + } + } else { + for my $tag ($tags->@*) { + $tag = lc($tag) if !$case_sensitive; + next if $seen_tags->{$tag}; + $seen_tags->{$tag} = 1; + push @$res, $tag; + } + } + + return !$no_join_result ? join(';', $res->@*) : $res; +} + +my sub get_tags_from_trunk { + my ($trunks) = @_; + + my $res = {}; + my @trunks_array = split /;/, $trunks; + for my $trunk (@trunks_array) { + my ($tag, $tag_end) = split /-/, $trunk; + if ($tag_end && $tag_end > $tag) { + my @tags = ($tag .. $tag_end); + $res->{$_} = 1 for @tags; + } else { + $res->{$tag} = 1; + } + } + return $res; +} + +sub check_vnet_access { + my ($rpcenv, $authuser, $vnet, $tag, $trunks) = @_; + + my $zone = 'localnetwork'; + + if ($have_sdn) { + my $vnet_cfg = PVE::Network::SDN::Vnets::config(); + if (defined(my $vnet = PVE::Network::SDN::Vnets::sdn_vnets_config($vnet_cfg, $vnet, 1))) { + $zone = $vnet->{zone}; + } + } + + # if a tag is defined, test if user have a specific access to the vlan (or propagated from full bridge acl) + $rpcenv->check($authuser, "/sdn/zones/$zone/$vnet/$tag", ['SDN.Use']) if $tag; + # check each vlan access from trunk + if ($trunks) { + my $tags = get_tags_from_trunk($trunks); + for my $tag (sort keys %$tags) { + $rpcenv->check($authuser, "/sdn/zones/$zone/$vnet/$tag", ['SDN.Use']); + } + } + # if no tag, test if user have access to full bridge. + $rpcenv->check($authuser, "/sdn/zones/$zone/$vnet", ['SDN.Use']) + if !($tag || $trunks); +} + +sub abort_guest_tasks { + my ($rpcenv, $type, $vmid) = @_; + + my $authuser = $rpcenv->get_user(); + my $node = PVE::INotify::nodename(); + my $can_abort_all = $rpcenv->check($authuser, "/nodes/$node", ['Sys.Modify'], 1); + + my $active_tasks = PVE::INotify::read_file('active'); + my $aborted_tasks = []; + for my $task (@$active_tasks) { + next if $task->{saved} || $task->{type} ne $type || $task->{id} ne $vmid; # filter + + my $can_abort_task = $can_abort_all; + if (!$can_abort_task) { + # tasks started by a token can be aborted by the token or token owner, + # tasks started by a user can be aborted by the user + if (PVE::AccessControl::pve_verify_tokenid($task->{user}, 1)) { + my $full_tokenid = $task->{user}; + my ($task_username, undef) = PVE::AccessControl::split_tokenid($full_tokenid); + $can_abort_task = $authuser eq $task_username || $authuser eq $full_tokenid; + } else { + $can_abort_task = $authuser eq $task->{user}; + } + } + + if ($can_abort_task) { + # passing `1` for parameter $killit aborts the task + PVE::RPCEnvironment->check_worker($task->{upid}, 1); + push @$aborted_tasks, $task->{upid}; + } + } + return $aborted_tasks; +} + +1;