From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: <pmg-devel-bounces@lists.proxmox.com> Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 42BF91FF165 for <inbox@lore.proxmox.com>; Wed, 26 Feb 2025 15:40:22 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7E6171710C; Wed, 26 Feb 2025 15:40:21 +0100 (CET) Date: Wed, 26 Feb 2025 15:40:15 +0100 From: Stoiko Ivanov <s.ivanov@proxmox.com> To: Markus Frank <m.frank@proxmox.com> Message-ID: <20250226154015.1004f27c@rosa.proxmox.com> In-Reply-To: <20250226140740.55612-4-m.frank@proxmox.com> References: <20250226140740.55612-1-m.frank@proxmox.com> <20250226140740.55612-4-m.frank@proxmox.com> X-Mailer: Claws Mail 4.1.1 (GTK 3.24.38; x86_64-pc-linux-gnu) MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.016 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [pmg-devel] [PATCH pmg-api v8 3/13] config: add plugin system for authentication realms X-BeenThere: pmg-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Mail Gateway development discussion <pmg-devel.lists.proxmox.com> List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pmg-devel>, <mailto:pmg-devel-request@lists.proxmox.com?subject=unsubscribe> List-Archive: <http://lists.proxmox.com/pipermail/pmg-devel/> List-Post: <mailto:pmg-devel@lists.proxmox.com> List-Help: <mailto:pmg-devel-request@lists.proxmox.com?subject=help> List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pmg-devel>, <mailto:pmg-devel-request@lists.proxmox.com?subject=subscribe> Cc: pmg-devel@lists.proxmox.com Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pmg-devel-bounces@lists.proxmox.com Sender: "pmg-devel" <pmg-devel-bounces@lists.proxmox.com> While trying to add the realm config to the cluster-sync I noticed a small mismatch (cfg vs. conf): * user.conf, ldap.conf vs. realms.cfg While its mostly cosmetic - now would be an easy time to change this if we want to have it aligned (w/o version-guarded copying in maintscripts) probably can be fixed up on applying if nothing else comes up (occurences should be here and in your 6/13 (will reply there as well)) the places where a rename has to happen as comments inline: On Wed, 26 Feb 2025 15:07:30 +0100 Markus Frank <m.frank@proxmox.com> wrote: > To differentiate between usernames, the realm is also stored in the > user.conf file. Old config file syntax can be read, but will be > overwritten after a change. > > This is a carryover from PVE. Previously there was no realm for a > username. Now the realm is also stored after an @ sign in user.conf. > > Utils generates a list of valid realm names, including any newly added > realms, to ensure proper validation of a specified realm name. > > Signed-off-by: Markus Frank <m.frank@proxmox.com> > --- > src/Makefile | 3 + > src/PMG/API2/Users.pm | 1 + > src/PMG/AccessControl.pm | 38 +++++++ > src/PMG/Auth/PAM.pm | 21 ++++ > src/PMG/Auth/PMG.pm | 37 +++++++ > src/PMG/Auth/Plugin.pm | 202 +++++++++++++++++++++++++++++++++++++ > src/PMG/RESTEnvironment.pm | 14 +++ > src/PMG/UserConfig.pm | 25 +++-- > src/PMG/Utils.pm | 29 ++++-- > 9 files changed, 354 insertions(+), 16 deletions(-) > create mode 100755 src/PMG/Auth/PAM.pm > create mode 100755 src/PMG/Auth/PMG.pm > create mode 100755 src/PMG/Auth/Plugin.pm > > diff --git a/src/Makefile b/src/Makefile > index 1232880..659a666 100644 > --- a/src/Makefile > +++ b/src/Makefile > @@ -166,6 +166,9 @@ LIBSOURCES = \ > PMG/API2/ACMEPlugin.pm \ > PMG/API2/NodeConfig.pm \ > PMG/API2.pm \ > + PMG/Auth/Plugin.pm \ > + PMG/Auth/PAM.pm \ > + PMG/Auth/PMG.pm \ > > SOURCES = ${LIBSOURCES} ${CLI_BINARIES} ${TEMPLATES_FILES} ${CONF_MANS} ${CLI_MANS} ${SERVICE_MANS} ${SERVICE_UNITS} ${TIMER_UNITS} pmg-sources.list pmg-initramfs.conf > > diff --git a/src/PMG/API2/Users.pm b/src/PMG/API2/Users.pm > index 99af38d..42c82b7 100644 > --- a/src/PMG/API2/Users.pm > +++ b/src/PMG/API2/Users.pm > @@ -124,6 +124,7 @@ __PACKAGE__->register_method ({ > $entry->{enable} //= 0; > $entry->{expire} //= 0; > $entry->{role} //= 'audit'; > + $entry->{realm} //= 'pmg'; > > $cfg->{$param->{userid}} = $entry; > > diff --git a/src/PMG/AccessControl.pm b/src/PMG/AccessControl.pm > index e12d7cf..78105c0 100644 > --- a/src/PMG/AccessControl.pm > +++ b/src/PMG/AccessControl.pm > @@ -5,6 +5,7 @@ use warnings; > use Authen::PAM; > > use PVE::Tools; > +use PVE::INotify; > use PVE::JSONSchema qw(get_standard_option); > use PVE::Exception qw(raise raise_perm_exc); > > @@ -13,6 +14,14 @@ use PMG::LDAPConfig; > use PMG::LDAPSet; > use PMG::TFAConfig; > > +use PMG::Auth::Plugin; > +use PMG::Auth::PAM; > +use PMG::Auth::PMG; > + > +PMG::Auth::PAM->register(); > +PMG::Auth::PMG->register(); > +PMG::Auth::Plugin->init(); > + > sub normalize_path { > my $path = shift; > > @@ -38,6 +47,7 @@ sub authenticate_user : prototype($$$) { > > ($username, $ruid, $realm) = PMG::Utils::verify_username($username); > > + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); > if ($realm eq 'pam') { > die "invalid pam user (only root allowed)\n" if $ruid ne 'root'; > authenticate_pam_user($ruid, $password); > @@ -53,6 +63,11 @@ sub authenticate_user : prototype($$$) { > return ($pmail . '@quarantine', undef); > } > die "ldap login failed\n"; > + } elsif ($realm =~ m!(${realm_regex})!) { > + my $realm_cfg = PVE::INotify::read_file(PMG::Auth::Plugin->realm_cfg_id()); > + my $cfg = $realm_cfg->{ids}->{$realm}; > + my $plugin = PMG::Auth::Plugin->lookup($cfg->{type}); > + $plugin->authenticate_user($cfg, $realm, $ruid, $password); > } else { > die "no such realm '$realm'\n"; > } > @@ -79,6 +94,7 @@ sub set_user_password { > > ($username, $ruid, $realm) = PMG::Utils::verify_username($username); > > + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); > if ($realm eq 'pam') { > die "invalid pam user (only root allowed)\n" if $ruid ne 'root'; > > @@ -92,6 +108,11 @@ sub set_user_password { > > } elsif ($realm eq 'pmg') { > PMG::UserConfig->set_user_password($username, $password); > + } elsif ($realm =~ m!(${realm_regex})!) { > + my $realm_cfg = PVE::INotify::read_file(PMG::Auth::Plugin->realm_cfg_id()); > + my $cfg = $realm_cfg->{ids}->{$realm}; > + my $plugin = PMG::Auth::Plugin->lookup($cfg->{type}); > + $plugin->store_password($cfg, $realm, $username, $password); > } else { > die "no such realm '$realm'\n"; > } > @@ -106,6 +127,7 @@ sub check_user_enabled { > > ($username, $ruid, $realm) = PMG::Utils::verify_username($username, 1); > > + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); > if ($realm && $ruid) { > if ($realm eq 'pam') { > return 'root' if $ruid eq 'root'; > @@ -115,6 +137,10 @@ sub check_user_enabled { > return $data->{role} if $data && $data->{enable}; > } elsif ($realm eq 'quarantine') { > return 'quser'; > + } elsif ($realm =~ m!(${realm_regex})!) { > + my $usercfg = PMG::UserConfig->new(); > + my $data = $usercfg->lookup_user_data($username, $noerr); > + return $data->{role} if $data && $data->{enable}; > } > } > > @@ -123,6 +149,18 @@ sub check_user_enabled { > return undef; > } > > +sub check_user_exist { > + my ($usercfg, $username, $noerr) = @_; > + > + $username = PMG::Utils::verify_username($username, $noerr); > + return undef if !$username; > + return $usercfg->{$username} if $usercfg && $usercfg->{$username}; > + > + die "no such user ('$username')\n" if !$noerr; > + > + return undef; > +} > + > sub authenticate_pam_user { > my ($username, $password) = @_; > > diff --git a/src/PMG/Auth/PAM.pm b/src/PMG/Auth/PAM.pm > new file mode 100755 > index 0000000..002c8e1 > --- /dev/null > +++ b/src/PMG/Auth/PAM.pm > @@ -0,0 +1,21 @@ > +package PMG::Auth::PAM; > + > +use strict; > +use warnings; > + > +use PMG::Auth::Plugin; > + > +use base qw(PMG::Auth::Plugin); > + > +sub type { > + return 'pam'; > +} > + > +sub options { > + return { > + default => { optional => 1 }, > + comment => { optional => 1 }, > + }; > +} > + > +1; > diff --git a/src/PMG/Auth/PMG.pm b/src/PMG/Auth/PMG.pm > new file mode 100755 > index 0000000..0706c97 > --- /dev/null > +++ b/src/PMG/Auth/PMG.pm > @@ -0,0 +1,37 @@ > +package PMG::Auth::PMG; > + > +use strict; > +use warnings; > + > +use PMG::Auth::Plugin; > + > +use base qw(PMG::Auth::Plugin); > + > +sub type { > + return 'pmg'; > +} > + > +sub properties { > + return { > + default => { > + description => "Use this as default realm", > + type => 'boolean', > + optional => 1, > + }, > + comment => { > + description => "Description.", > + type => 'string', > + optional => 1, > + maxLength => 4096, > + }, > + }; > +} > + > +sub options { > + return { > + default => { optional => 1 }, > + comment => { optional => 1 }, > + }; > +} > + > +1; > diff --git a/src/PMG/Auth/Plugin.pm b/src/PMG/Auth/Plugin.pm > new file mode 100755 > index 0000000..beb8fc4 > --- /dev/null > +++ b/src/PMG/Auth/Plugin.pm > @@ -0,0 +1,202 @@ > +package PMG::Auth::Plugin; > + > +use strict; > +use warnings; > + > +use Digest::SHA; > +use Encode; > + > +use PMG::Utils; > +use PVE::INotify; > +use PVE::JSONSchema qw(get_standard_option); > +use PVE::SectionConfig; > +use PVE::Tools; > + > +use base qw(PVE::SectionConfig); > + > +my $realm_cfg_id = "realms.cfg"; here (else it's even more confusing) > +my $lockfile = "/var/lock/pmg-realms.lck"; > + > +sub realm_cfg_id { > + return $realm_cfg_id; > +} > + > +sub read_realms_conf { > + my ($filename, $fh) = @_; > + > + my $raw; > + $raw = do { local $/ = undef; <$fh> } if defined($fh); > + > + return PMG::Auth::Plugin->parse_config($filename, $raw); > +} > + > +sub write_realms_conf { > + my ($filename, $fh, $cfg) = @_; > + > + my $raw = PMG::Auth::Plugin->write_config($filename, $cfg); > + > + PVE::Tools::safe_print($filename, $fh, $raw); > +} > + > +PVE::INotify::register_file( > + $realm_cfg_id, > + "/etc/pmg/realms.cfg", here > + \&read_realms_conf, > + \&write_realms_conf, > + undef, > + always_call_parser => 1, > +); > + > +sub lock_realm_config { > + my ($code, $errmsg) = @_; > + > + PVE::Tools::lock_file($lockfile, undef, $code); > + if (my $err = $@) { > + $errmsg ? die "$errmsg: $err" : die $err; > + } > +} > + > +my $realm_regex = qr/[A-Za-z][A-Za-z0-9\.\-_]+/; > + > +sub pmg_verify_realm { > + my ($realm, $noerr) = @_; > + > + if ($realm !~ m/^${realm_regex}$/) { > + return undef if $noerr; > + die "value does not look like a valid realm\n"; > + } > + return $realm; > +} > + > +my $defaultData = { > + propertyList => { > + type => { description => "Realm type." }, > + realm => get_standard_option('realm'), > + }, > +}; > + > +sub private { > + return $defaultData; > +} > + > +sub parse_section_header { > + my ($class, $line) = @_; > + > + if ($line =~ m/^(\S+):\s*(\S+)\s*$/) { > + my ($type, $realm) = (lc($1), $2); > + my $errmsg = undef; # set if you want to skip whole section > + eval { pmg_verify_realm($realm); }; > + $errmsg = $@ if $@; > + my $config = {}; # to return additional attributes > + return ($type, $realm, $errmsg, $config); > + } > + return undef; > +} > + > +sub parse_config { > + my ($class, $filename, $raw) = @_; > + > + my $cfg = $class->SUPER::parse_config($filename, $raw); > + > + my $default; > + foreach my $realm (keys %{$cfg->{ids}}) { > + my $data = $cfg->{ids}->{$realm}; > + # make sure there is only one default marker > + if ($data->{default}) { > + if ($default) { > + delete $data->{default}; > + } else { > + $default = $realm; > + } > + } > + > + if ($data->{comment}) { > + $data->{comment} = PVE::Tools::decode_text($data->{comment}); > + } > + > + } > + > + # add default realms > + $cfg->{ids}->{pmg}->{type} = 'pmg'; # force type > + $cfg->{ids}->{pmg}->{comment} = "Proxmox Mail Gateway authentication server" > + if !$cfg->{ids}->{pmg}->{comment}; > + $cfg->{ids}->{pmg}->{default} = 1 > + if !$cfg->{ids}->{pmg}->{default}; > + > + $cfg->{ids}->{pam}->{type} = 'pam'; # force type > + $cfg->{ids}->{pam}->{comment} = "Linux PAM standard authentication" > + if !$cfg->{ids}->{pam}->{comment}; > + > + return $cfg; > +}; > + > +sub write_config { > + my ($class, $filename, $cfg) = @_; > + > + foreach my $realm (keys %{$cfg->{ids}}) { > + my $data = $cfg->{ids}->{$realm}; > + if ($data->{comment}) { > + $data->{comment} = PVE::Tools::encode_text($data->{comment}); > + } > + } > + > + $class->SUPER::write_config($filename, $cfg); > +} > + > +sub authenticate_user { > + my ($class, $config, $realm, $username, $password) = @_; > + > + die "overwrite me"; > +} > + > +sub store_password { > + my ($class, $config, $realm, $username, $password) = @_; > + > + my $type = $class->type(); > + > + die "can't set password on auth type '$type'\n"; > +} > + > +sub delete_user { > + my ($class, $config, $realm, $username) = @_; > + > + # do nothing by default > +} > + > +# called during addition of realm (before the new realm config got written) > +# `password` is moved to %param to avoid writing it out to the config > +# die to abort addition if there are (grave) problems > +# NOTE: runs in a realm config *locked* context > +sub on_add_hook { > + my ($class, $realm, $config, %param) = @_; > + # do nothing by default > +} > + > +# called during realm configuration update (before the updated realm config got > +# written). `password` is moved to %param to avoid writing it out to the config > +# die to abort the update if there are (grave) problems > +# NOTE: runs in a realm config *locked* context > +sub on_update_hook { > + my ($class, $realm, $config, %param) = @_; > + # do nothing by default > +} > + > +# called during deletion of realms (before the new realm config got written) > +# and if the activate check on addition fails, to cleanup all storage traces > +# which on_add_hook may have created. > +# die to abort deletion if there are (very grave) problems > +# NOTE: runs in a realm config *locked* context > +sub on_delete_hook { > + my ($class, $realm, $config) = @_; > + # do nothing by default > +} > + > +# called during addition and updates of realms (before the new realm config gets written) > +# die to abort addition/update in case the connection/bind fails > +# NOTE: runs in a realm config *locked* context > +sub check_connection { > + my ($class, $realm, $config, %param) = @_; > + # do nothing by default > +} > + > +1; > diff --git a/src/PMG/RESTEnvironment.pm b/src/PMG/RESTEnvironment.pm > index 3875720..f6ff449 100644 > --- a/src/PMG/RESTEnvironment.pm > +++ b/src/PMG/RESTEnvironment.pm > @@ -88,6 +88,20 @@ sub get_role { > return $self->{role}; > } > > +sub check_user_enabled { > + my ($self, $user, $noerr) = @_; > + > + my $cfg = $self->{usercfg}; > + return PMG::AccessControl::check_user_enabled($cfg, $user, $noerr); > +} > + > +sub check_user_exist { > + my ($self, $user, $noerr) = @_; > + > + my $cfg = $self->{usercfg}; > + return PMG::AccessControl::check_user_exist($cfg, $user, $noerr); > +} > + > sub check_node_is_master { > my ($self, $noerr); > > diff --git a/src/PMG/UserConfig.pm b/src/PMG/UserConfig.pm > index b9a83a7..fe6d2c8 100644 > --- a/src/PMG/UserConfig.pm > +++ b/src/PMG/UserConfig.pm > @@ -80,7 +80,7 @@ my $schema = { > realm => { > description => "Authentication realm.", > type => 'string', > - enum => ['pam', 'pmg'], > + format => 'pmg-realm', > default => 'pmg', > optional => 1, > }, > @@ -219,10 +219,13 @@ sub read_user_conf { > (?<keys>(?:[^:]*)) : > $/x > ) { > + my @username_parts = split('@', $+{userid}); > + my $username = $username_parts[0]; > + my $realm = defined($username_parts[1]) ? $username_parts[1] : "pmg"; > my $d = { > - username => $+{userid}, > - userid => $+{userid} . '@pmg', > - realm => 'pmg', > + username => $username, > + userid => $username . '@' . $realm, > + realm => $realm, > enable => $+{enable} || 0, > expire => $+{expire} || 0, > role => $+{role}, > @@ -235,8 +238,9 @@ sub read_user_conf { > eval { > $verify_entry->($d); > $cfg->{$d->{userid}} = $d; > - die "role 'root' is reserved\n" > - if $d->{role} eq 'root' && $d->{userid} ne 'root@pmg'; > + if ($d->{role} eq 'root' && $d->{userid} !~ /^root@(pmg|pam)$/) { > + die "role 'root' is reserved\n"; > + } > }; > if (my $err = $@) { > warn "$filename: $err"; > @@ -274,22 +278,23 @@ sub write_user_conf { > $verify_entry->($d); > $cfg->{$d->{userid}} = $d; > > + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); > if ($d->{userid} ne 'root@pam') { > die "role 'root' is reserved\n" if $d->{role} eq 'root'; > die "unable to add users for realm '$d->{realm}'\n" > - if $d->{realm} && $d->{realm} ne 'pmg'; > + if $d->{realm} && $d->{realm} !~ m!(${realm_regex})!; > } > > my $line; > > if ($userid eq 'root@pam') { > - $line = 'root:'; > + $line = 'root@pam:'; > $d->{crypt_pass} = '', > $d->{expire} = '0', > $d->{role} = 'root'; > } else { > - next if $userid !~ m/^(?<username>.+)\@pmg$/; > - $line = "$+{username}:"; > + next if $userid !~ m/^(?<username>.+)\@(${realm_regex})$/; > + $line = "$d->{userid}:"; > } > > for my $k (qw(enable expire crypt_pass role email firstname lastname keys)) { > diff --git a/src/PMG/Utils.pm b/src/PMG/Utils.pm > index 0b8945f..04a3a2e 100644 > --- a/src/PMG/Utils.pm > +++ b/src/PMG/Utils.pm > @@ -49,12 +49,30 @@ postgres_admin_cmd > try_decode_utf8 > ); > > -my $valid_pmg_realms = ['pam', 'pmg', 'quarantine']; > +my $user_regex = qr![^\s:/]+!; > + > +sub valid_pmg_realm_regex { > + my $cfg = PVE::INotify::read_file('realms.cfg'); here > + my $ids = $cfg->{ids}; > + my $realms = ['pam', 'quarantine', sort keys $cfg->{ids}->%* ]; > + return join('|', @$realms); > +} > + > +sub is_valid_realm { > + my ($realm) = @_; > + return 0 if !$realm; > + return 1 if $realm eq 'pam' || $realm eq 'quarantine'; # built-in ones > + > + my $cfg = PVE::INotify::read_file('realms.cfg'); here > + return exists($cfg->{ids}->{$realm}) ? 1 : 0; > +} > + > +PVE::JSONSchema::register_format('pmg-realm', \&is_valid_realm); > > PVE::JSONSchema::register_standard_option('realm', { > description => "Authentication domain ID", > type => 'string', > - enum => $valid_pmg_realms, > + format => 'pmg-realm', > maxLength => 32, > }); > > @@ -82,16 +100,15 @@ sub verify_username { > die "user name '$username' is too short\n" if !$noerr; > return undef; > } > - if ($len > 64) { > - die "user name '$username' is too long ($len > 64)\n" if !$noerr; > + if ($len > 128) { > + die "user name '$username' is too long ($len > 128)\n" if !$noerr; > return undef; > } > > # we only allow a limited set of characters. Colons aren't allowed, because we store usernames > # with colon separated lists! slashes aren't allowed because it is used as pve API delimiter > # also see "man useradd" > - my $realm_list = join('|', @$valid_pmg_realms); > - if ($username =~ m!^([^\s:/]+)\@(${realm_list})$!) { > + if ($username =~ m!^(${user_regex})\@([A-Za-z][A-Za-z0-9\.\-_]+)$!) { > return wantarray ? ($username, $1, $2) : $username; > } > _______________________________________________ pmg-devel mailing list pmg-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pmg-devel