From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id DF4E094FDA for ; Tue, 17 Jan 2023 12:47:04 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BE73BAED1 for ; Tue, 17 Jan 2023 12:47:04 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Tue, 17 Jan 2023 12:47:02 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id E7CF544B40 for ; Tue, 17 Jan 2023 12:47:01 +0100 (CET) From: Dominik Csapak To: pve-devel@lists.proxmox.com Date: Tue, 17 Jan 2023 12:46:54 +0100 Message-Id: <20230117114659.2397499-4-d.csapak@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20230117114659.2397499-1-d.csapak@proxmox.com> References: <20230117114659.2397499-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.086 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [tokenconfig.pm, domains.pm, acl.pm, realmsync.pm, accesscontrol.pm, group.pm, openid.pm, tfa.pm, role.pm] Subject: [pve-devel] [PATCH access-control v3 2/2] add realm-sync plugin for jobs and CRUD api for realm-sync-jobs X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 17 Jan 2023 11:47:04 -0000 to be able to define automated jobs that sync ldap/ad The jobs plugin contains special handling when no node is given, since we only want it to run on a single node when that triggers. For that, we save a statefile in /etc/pve/priv/jobs/ which contains the node/time/upid of the node that runs the job. The first node that is able to lock the realm (via cfs_lock_domain) "wins" and may sync from the ldap. in case a specific node was selected, this is omitted and the Jobs handling will not let it run on other nodes anyway the API part is our usual sectionconfig CRUD api, but specialized for the specific type of job. the api will be at /cluster/jobs/realm-sync (this must be done in pve-manager) Signed-off-by: Dominik Csapak --- src/PVE/API2/AccessControl/Makefile | 6 + src/PVE/API2/AccessControl/RealmSync.pm | 292 ++++++++++++++++++++++++ src/PVE/API2/Makefile | 4 + src/PVE/Jobs/Makefile | 6 + src/PVE/Jobs/RealmSync.pm | 201 ++++++++++++++++ src/PVE/Makefile | 1 + 6 files changed, 510 insertions(+) create mode 100644 src/PVE/API2/AccessControl/Makefile create mode 100644 src/PVE/API2/AccessControl/RealmSync.pm create mode 100644 src/PVE/Jobs/Makefile create mode 100644 src/PVE/Jobs/RealmSync.pm diff --git a/src/PVE/API2/AccessControl/Makefile b/src/PVE/API2/AccessControl/Makefile new file mode 100644 index 0000000..8f9d749 --- /dev/null +++ b/src/PVE/API2/AccessControl/Makefile @@ -0,0 +1,6 @@ +API2_SOURCES= \ + RealmSync.pm \ + +.PHONY: install +install: + for i in ${API2_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/API2/AccessControl/$$i; done diff --git a/src/PVE/API2/AccessControl/RealmSync.pm b/src/PVE/API2/AccessControl/RealmSync.pm new file mode 100644 index 0000000..4a689ca --- /dev/null +++ b/src/PVE/API2/AccessControl/RealmSync.pm @@ -0,0 +1,292 @@ +package PVE::API2::AccessControl::RealmSync; + +use strict; +use warnings; + +use JSON; + +use PVE::Cluster qw (cfs_read_file cfs_write_file cfs_lock_file); +use PVE::Exception qw(raise_param_exc); +use PVE::JSONSchema qw(get_standard_option); +use PVE::Job::Registry; +use PVE::RESTHandler; +use PVE::SafeSyslog; +use PVE::SectionConfig; +use PVE::Tools qw(extract_param); + +use PVE::AccessControl; +use PVE::Auth::Plugin; +use PVE::Jobs::RealmSync; + + +use base qw(PVE::RESTHandler); + +my $get_cluster_last_run = sub { + my ($jobid) = @_; + + my $state = eval { PVE::Jobs::RealmSync::get_state($jobid) }; + die "error on getting state for '$jobid': $@\n" if $@; + + if (my $upid = $state->{upid}) { + if (my $decoded = PVE::Tools::upid_decode($upid)) { + return $decoded->{starttime}; + } + } else { + return $state->{time}; + } + + return undef; +}; + +__PACKAGE__->register_method ({ + name => 'syncjob_index', + path => '', + method => 'GET', + description => "List configured realm-sync-jobs.", + permissions => { + check => ['perm', '/', ['Sys.Audit']], + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + id => { + description => "The ID of the entry.", + type => 'string' + }, + enabled => { + description => "If the job is enabled or not.", + type => 'boolean', + }, + comment => { + description => "A comment for the job.", + type => 'string', + optional => 1, + }, + schedule => { + description => "The configured sync schedule.", + type => 'string', + }, + realm => get_standard_option('realm'), + scope => get_standard_option('sync-scope'), + 'remove-vanished' => get_standard_option('sync-remove-vanished'), + 'last-run' => { + description => "Last execution time of the job in seconds since the beginning of the UNIX epoch", + type => 'integer', + optional => 1, + }, + 'next-run' => { + description => "Next planned execution time of the job in seconds since the beginning of the UNIX epoch.", + type => 'integer', + optional => 1, + }, + }, + }, + links => [ { rel => 'child', href => "{id}" } ], + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $user = $rpcenv->get_user(); + + my $jobs_data = cfs_read_file('jobs.cfg'); + my $order = $jobs_data->{order}; + my $jobs = $jobs_data->{ids}; + + my $res = []; + for my $jobid (sort { $order->{$a} <=> $order->{$b} } keys %$jobs) { + my $job = $jobs->{$jobid}; + next if $job->{type} ne 'realm-sync'; + + $job->{id} = $jobid; + if (my $schedule = $job->{schedule}) { + $job->{'last-run'} = eval { $get_cluster_last_run->($jobid) }; + my $last_run = $job->{'last-run'} // time(); # current time as fallback + my $calendar_event = Proxmox::RS::CalendarEvent->new($schedule); + my $next_run = $calendar_event->compute_next_event($last_run); + $job->{'next-run'} = $next_run if defined($next_run); + } + + push @$res, $job; + } + + return $res; + }}); + +__PACKAGE__->register_method({ + name => 'read_job', + path => '{id}', + method => 'GET', + description => "Read realm-sync job definition.", + permissions => { + check => ['perm', '/', ['Sys.Audit']], + }, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + }, + }, + returns => { + type => 'object', + }, + code => sub { + my ($param) = @_; + + my $jobs = cfs_read_file('jobs.cfg'); + my $id = $param->{id}; + my $job = $jobs->{ids}->{$id}; + return $job if $job && $job->{type} eq 'realm-sync'; + + raise_param_exc({ id => "No such job '$id'" }); + + }}); + +__PACKAGE__->register_method({ + name => 'create_job', + path => '{id}', + method => 'POST', + protected => 1, + description => "Create new realm-sync job.", + permissions => { + description => "'Realm.AllocateUser' on '/access/realm/' and " + ."'User.Modify' permissions to '/access/groups/'.", + check => [ 'and', + ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], + ['perm', '/access/groups', ['User.Modify']], + ], + }, + parameters => PVE::Jobs::RealmSync->createSchema(), + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = extract_param($param, 'id'); + + cfs_lock_file('jobs.cfg', undef, sub { + my $data = cfs_read_file('jobs.cfg'); + + die "Job '$id' already exists\n" + if $data->{ids}->{$id}; + + my $plugin = PVE::Job::Registry->lookup('realm-sync'); + my $opts = $plugin->check_config($id, $param, 1, 1); + + my $realm = $opts->{realm}; + my $cfg = cfs_read_file('domains.cfg'); + + raise_param_exc({ realm => "No such realm '$realm'" }) + if !defined($cfg->{ids}->{$realm}); + + my $realm_type = $cfg->{ids}->{$realm}->{type}; + raise_param_exc({ realm => "Only LDAP/AD realms can be synced." }) + if $realm_type ne 'ldap' && $realm_type ne 'ad'; + + $data->{ids}->{$id} = $opts; + + cfs_write_file('jobs.cfg', $data); + }); + die "$@" if ($@); + + return undef; + }}); + +__PACKAGE__->register_method({ + name => 'update_job', + path => '{id}', + method => 'PUT', + protected => 1, + description => "Update realm-sync job definition.", + permissions => { + description => "'Realm.AllocateUser' on '/access/realm/' and " + ." 'User.Modify' permissions to '/access/groups/'.", + check => [ 'and', + ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], + ['perm', '/access/groups', ['User.Modify']], + ], + }, + parameters => PVE::Jobs::RealmSync->updateSchema(), + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = extract_param($param, 'id'); + my $delete = extract_param($param, 'delete'); + if ($delete) { + $delete = [PVE::Tools::split_list($delete)]; + } + + cfs_lock_file('jobs.cfg', undef, sub { + my $jobs = cfs_read_file('jobs.cfg'); + + die "no options specified\n" if !scalar(keys %$param); + + my $plugin = PVE::Job::Registry->lookup('realm-sync'); + my $opts = $plugin->check_config($id, $param, 0, 1); + + my $job = $jobs->{ids}->{$id}; + die "no such realm-sync job\n" if !$job || $job->{type} ne 'realm-sync'; + + my $options = $plugin->options(); + PVE::SectionConfig::delete_from_config($job, $options, $opts, $delete); + + $job->{$_} = $param->{$_} for keys $param->%*; + + cfs_write_file('jobs.cfg', $jobs); + + return; + }); + die "$@" if ($@); + }}); + + +__PACKAGE__->register_method({ + name => 'delete_job', + path => '{id}', + method => 'DELETE', + description => "Delete realm-sync job definition.", + permissions => { + check => ['perm', '/', ['Sys.Modify']], + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = $param->{id}; + + cfs_lock_file('jobs.cfg', undef, sub { + my $jobs = cfs_read_file('jobs.cfg'); + + if (!defined($jobs->{ids}->{$id}) || $jobs->{ids}->{$id}->{type} ne 'realm-sync') { + raise_param_exc({ id => "No such job '$id'" }); + } + delete $jobs->{ids}->{$id}; + + cfs_write_file('jobs.cfg', $jobs); + PVE::Jobs::RealmSync::save_state($id, undef); + }); + die "$@" if $@; + + return undef; + }}); + +1; diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile index 2817f48..7d2be93 100644 --- a/src/PVE/API2/Makefile +++ b/src/PVE/API2/Makefile @@ -2,6 +2,7 @@ API2_SOURCES= \ AccessControl.pm \ Domains.pm \ + RealmSync.pm \ ACL.pm \ Role.pm \ Group.pm \ @@ -9,6 +10,9 @@ API2_SOURCES= \ TFA.pm \ OpenId.pm +SUBDIRS=AccessControl + .PHONY: install install: for i in ${API2_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/API2/$$i; done + set -e && for i in ${SUBDIRS}; do ${MAKE} -C $$i $@; done diff --git a/src/PVE/Jobs/Makefile b/src/PVE/Jobs/Makefile new file mode 100644 index 0000000..9eed1b2 --- /dev/null +++ b/src/PVE/Jobs/Makefile @@ -0,0 +1,6 @@ +SOURCES=RealmSync.pm + +.PHONY: install +install: ${SOURCES} + install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/Jobs + for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Jobs/$$i; done diff --git a/src/PVE/Jobs/RealmSync.pm b/src/PVE/Jobs/RealmSync.pm new file mode 100644 index 0000000..3a518cc --- /dev/null +++ b/src/PVE/Jobs/RealmSync.pm @@ -0,0 +1,201 @@ +package PVE::Jobs::RealmSync; + +use strict; +use warnings; + +use JSON; +use POSIX qw(ENOENT); + +use PVE::JSONSchema qw(get_standard_option); +use PVE::Cluster; +use PVE::CalendarEvent; +use PVE::Tools; + +use PVE::API2::Domains; + +use base qw(PVE::Job::Registry); + +sub type { + return 'realm-sync'; +} + +my $props = get_standard_option('realm-sync-options', { + realm => get_standard_option('realm'), +}); + +sub properties { + return $props; +} + +sub options { + my $options = { + enabled => { optional => 1 }, + schedule => {}, + comment => { optional => 1 }, + scope => {}, + }; + for my $opt (keys %$props) { + next if defined($options->{$opt}); + # ignore legacy props from realm-sync schema + next if $opt eq 'full' || $opt eq 'purge'; + if ($props->{$opt}->{optional}) { + $options->{$opt} = { optional => 1 }; + } else { + $options->{$opt} = {}; + } + } + $options->{realm}->{fixed} = 1; + + return $options; +} + +sub decode_value { + my ($class, $type, $key, $value) = @_; + return $value; +} + +sub encode_value { + my ($class, $type, $key, $value) = @_; + return $value; +} + +sub createSchema { + my ($class, $skip_type) = @_; + + my $schema = $class->SUPER::createSchema($skip_type); + + my $opts = $class->options(); + for my $opt (keys $schema->{properties}->%*) { + next if defined($opts->{$opt}) || $opt eq 'id'; + delete $schema->{properties}->{$opt}; + } + + return $schema; +} + +sub updateSchema { + my ($class, $skip_type) = @_; + my $schema = $class->SUPER::updateSchema($skip_type); + + my $opts = $class->options(); + for my $opt (keys $schema->{properties}->%*) { + next if defined($opts->{$opt}); + next if $opt eq 'id' || $opt eq 'delete'; + delete $schema->{properties}->{$opt}; + } + + return $schema; +} + +my $statedir = "/etc/pve/priv/jobs"; + +sub get_state { + my ($id) = @_; + + mkdir $statedir; + my $statefile = "$statedir/realm-sync-$id.json"; + my $raw = eval { PVE::Tools::file_get_contents($statefile) } // ''; + + my $state = ($raw =~ m/^(\{.*\})$/) ? decode_json($1) : {}; + + return $state; +} + +sub save_state { + my ($id, $state) = @_; + + mkdir $statedir; + my $statefile = "$statedir/realm-sync-$id.json"; + + if (defined($state)) { + PVE::Tools::file_set_contents($statefile, encode_json($state)); + } else { + unlink $statefile or $! == ENOENT or die "could not delete state for $id - $!\n"; + } + + return undef; +} + +sub run { + my ($class, $conf, $id, $schedule) = @_; + + for my $opt (keys %$conf) { + delete $conf->{$opt} if !defined($props->{$opt}); + } + + my $realm = $conf->{realm}; + + # cluster synced + my $now = time(); + my $nodename = PVE::INotify::nodename(); + + # check statefile in pmxcfs if we should start + my $shouldrun = PVE::Cluster::cfs_lock_domain('realm-sync', undef, sub { + my $members = PVE::Cluster::get_members(); + + my $state = get_state($id); + my $last_node = $state->{node} // $nodename; + my $last_upid = $state->{upid}; + my $last_time = $state->{time}; + + my $last_node_online = $last_node eq $nodename || ($members->{$last_node} // {})->{online}; + + if (defined($last_upid)) { + # first check if the next run is scheduled + if (my $parsed = PVE::Tools::upid_decode($last_upid, 1)) { + my $cal_spec = PVE::CalendarEvent::parse_calendar_event($schedule); + my $next_sync = PVE::CalendarEvent::compute_next_event($cal_spec, $parsed->{starttime}); + return 0 if !defined($next_sync) || $now < $next_sync; # not yet its (next) turn + } + # check if still running and node is online + my $tasks = PVE::Cluster::get_tasklist(); + for my $task (@$tasks) { + next if $task->{upid} ne $last_upid; + last if defined($task->{endtime}); # it's already finished + last if !$last_node_online; # it's not finished and the node is offline + return 0; # not finished and online + } + } elsif (defined($last_time) && ($last_time+60) > $now && $last_node_online) { + # another node started this job in the last 60 seconds and is still online + return 0; + } + + # any of the following conditions should be true here: + # * it was started on another node but that node is offline now + # * it was started but either too long ago, or with an error + # * the started task finished + + save_state($id, { + node => $nodename, + time => $now, + }); + return 1; + }); + die $@ if $@; + + if ($shouldrun) { + my $upid = eval { PVE::API2::Domains->sync($conf) }; + my $err = $@; + PVE::Cluster::cfs_lock_domain('realm-sync', undef, sub { + if ($err && !$upid) { + save_state($id, { + node => $nodename, + time => $now, + error => $err, + }); + die "$err\n"; + } + + save_state($id, { + node => $nodename, + upid => $upid, + }); + }); + die $@ if $@; + return $upid; + } + + return "OK"; # all other cases should not run the sync on this node +} + +1; diff --git a/src/PVE/Makefile b/src/PVE/Makefile index c839d8f..dfc5314 100644 --- a/src/PVE/Makefile +++ b/src/PVE/Makefile @@ -8,3 +8,4 @@ install: install -D -m 0644 TokenConfig.pm ${DESTDIR}${PERLDIR}/PVE/TokenConfig.pm make -C API2 install make -C CLI install + make -C Jobs install -- 2.30.2