From m.frank at proxmox.com Tue Jan 14 10:30:03 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:03 +0100 Subject: [pmg-devel] [PATCH proxmox-perl-rs v4 3/10] remove empty PMG::RS::OpenId package to avoid confusion In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-4-m.frank@proxmox.com> The common Proxmox::RS:OpenId package can be used instead. Signed-off-by: Markus Frank --- pmg-rs/Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/pmg-rs/Makefile b/pmg-rs/Makefile index 073a284..0b48a37 100644 --- a/pmg-rs/Makefile +++ b/pmg-rs/Makefile @@ -29,7 +29,6 @@ PERLMOD_PACKAGES := \ PMG::RS::APT::Repositories \ PMG::RS::Acme \ PMG::RS::CSR \ - PMG::RS::OpenId \ PMG::RS::TFA PERLMOD_PACKAGE_FILES := $(addsuffix .pm,$(subst ::,/,$(PERLMOD_PACKAGES))) -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:01 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:01 +0100 Subject: [pmg-devel] [PATCH pve-common v4 1/10] add Schema package with auth module that contains realm sync options In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-2-m.frank@proxmox.com> This is because these standard options & formats are used by both PVE and PMG. Signed-off-by: Markus Frank --- src/Makefile | 2 ++ src/PVE/Schema/Auth.pm | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/PVE/Schema/Auth.pm diff --git a/src/Makefile b/src/Makefile index 2d8bdc4..833bbc1 100644 --- a/src/Makefile +++ b/src/Makefile @@ -29,6 +29,7 @@ LIB_SOURCES = \ RESTEnvironment.pm \ RESTHandler.pm \ SafeSyslog.pm \ + Schema/Auth.pm \ SectionConfig.pm \ SysFSTools.pm \ Syscall.pm \ @@ -41,6 +42,7 @@ all: install: $(addprefix PVE/,${LIB_SOURCES}) install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/Job + install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/Schema for i in ${LIB_SOURCES}; do install -D -m 0644 PVE/$$i ${DESTDIR}${PERLDIR}/PVE/$$i; done diff --git a/src/PVE/Schema/Auth.pm b/src/PVE/Schema/Auth.pm new file mode 100644 index 0000000..1f7f586 --- /dev/null +++ b/src/PVE/Schema/Auth.pm @@ -0,0 +1,82 @@ +package PVE::Schema::Auth; + +use strict; +use warnings; + +use PVE::JSONSchema qw(get_standard_option parse_property_string); + +my $remove_options = "(?:acl|properties|entry)"; + +PVE::JSONSchema::register_standard_option('sync-scope', { + description => "Select what to sync.", + type => 'string', + enum => [qw(users groups both)], + optional => '1', +}); + +PVE::JSONSchema::register_standard_option('sync-remove-vanished', { + description => "A semicolon-seperated list of things to remove when they or the user" + ." vanishes during a sync. The following values are possible: 'entry' removes the" + ." user/group when not returned from the sync. 'properties' removes the set" + ." properties on existing user/group that do not appear in the source (even custom ones)." + ." 'acl' removes acls when the user/group is not returned from the sync." + ." Instead of a list it also can be 'none' (the default).", + type => 'string', + default => 'none', + typetext => "([acl];[properties];[entry])|none", + pattern => "(?:(?:$remove_options\;)*$remove_options)|none", + optional => '1', +}); + +my $realm_sync_options_desc = { + scope => get_standard_option('sync-scope'), + 'remove-vanished' => get_standard_option('sync-remove-vanished'), + 'enable-new' => { + description => "Enable newly synced users immediately.", + type => 'boolean', + default => '1', + optional => '1', + }, +}; +PVE::JSONSchema::register_standard_option('realm-sync-options', $realm_sync_options_desc); +PVE::JSONSchema::register_format('realm-sync-options', $realm_sync_options_desc); + +my $tfa_format = { + type => { + description => "The type of 2nd factor authentication.", + format_description => 'TFATYPE', + type => 'string', + enum => [qw(oath)], + }, + digits => { + description => "TOTP digits.", + format_description => 'COUNT', + type => 'integer', + minimum => 6, maximum => 8, + default => 6, + optional => 1, + }, + step => { + description => "TOTP time period.", + format_description => 'SECONDS', + type => 'integer', + minimum => 10, + default => 30, + optional => 1, + }, +}; + +PVE::JSONSchema::register_format('pve-tfa-config', $tfa_format); + +PVE::JSONSchema::register_standard_option('tfa', { + description => "Use Two-factor authentication.", + type => 'string', format => 'pve-tfa-config', + optional => 1, + maxLength => 128, +}); + +sub parse_tfa_config { + my ($data) = @_; + + return parse_property_string($tfa_format, $data); +} -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:02 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:02 +0100 Subject: [pmg-devel] [PATCH proxmox-perl-rs v4 2/10] move openid code from pve-rs to common In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-3-m.frank@proxmox.com> Change pve-rs functions to be wrapper functions for common. Signed-off-by: Markus Frank --- common/pkg/Makefile | 1 + common/src/mod.rs | 1 + common/src/openid/mod.rs | 63 ++++++++++++++++++++++++++++++++++++++++ pmg-rs/Cargo.toml | 1 + pmg-rs/debian/control | 1 + pve-rs/src/openid/mod.rs | 32 +++++--------------- 6 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 common/src/openid/mod.rs diff --git a/common/pkg/Makefile b/common/pkg/Makefile index cbefdf7..eb5828a 100644 --- a/common/pkg/Makefile +++ b/common/pkg/Makefile @@ -24,6 +24,7 @@ PERLMOD_PACKAGES := \ Proxmox::RS::APT::Repositories \ Proxmox::RS::CalendarEvent \ Proxmox::RS::Notify \ + Proxmox::RS::OpenId \ Proxmox::RS::SharedCache \ Proxmox::RS::Subscription diff --git a/common/src/mod.rs b/common/src/mod.rs index badfc98..5b8445f 100644 --- a/common/src/mod.rs +++ b/common/src/mod.rs @@ -2,5 +2,6 @@ pub mod apt; mod calendar_event; pub mod logger; pub mod notify; +pub mod openid; pub mod shared_cache; mod subscription; diff --git a/common/src/openid/mod.rs b/common/src/openid/mod.rs new file mode 100644 index 0000000..13bbaab --- /dev/null +++ b/common/src/openid/mod.rs @@ -0,0 +1,63 @@ +#[perlmod::package(name = "Proxmox::RS::OpenId")] +pub mod export { + use std::sync::Mutex; + + use anyhow::Error; + + use perlmod::{to_value, Value}; + + use proxmox_openid::{OpenIdAuthenticator, OpenIdConfig, PrivateAuthState}; + + perlmod::declare_magic!(Box : &OpenId as "Proxmox::RS::OpenId"); + + /// An OpenIdAuthenticator client instance. + pub struct OpenId { + inner: Mutex, + } + + /// Create a new OpenId client instance + #[export(raw_return)] + pub fn discover( + #[raw] class: Value, + config: OpenIdConfig, + redirect_url: &str, + ) -> Result { + let open_id = OpenIdAuthenticator::discover(&config, redirect_url)?; + Ok(perlmod::instantiate_magic!( + &class, + MAGIC => Box::new(OpenId { + inner: Mutex::new(open_id), + }) + )) + } + + #[export] + pub fn authorize_url( + #[try_from_ref] this: &OpenId, + state_dir: &str, + realm: &str, + ) -> Result { + let open_id = this.inner.lock().unwrap(); + open_id.authorize_url(state_dir, realm) + } + + #[export] + pub fn verify_public_auth_state( + state_dir: &str, + state: &str, + ) -> Result<(String, PrivateAuthState), Error> { + OpenIdAuthenticator::verify_public_auth_state(state_dir, state) + } + + #[export(raw_return)] + pub fn verify_authorization_code( + #[try_from_ref] this: &OpenId, + code: &str, + private_auth_state: PrivateAuthState, + ) -> Result { + let open_id = this.inner.lock().unwrap(); + let claims = open_id.verify_authorization_code_simple(code, &private_auth_state)?; + + Ok(to_value(&claims)?) + } +} diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml index 1252671..ce715bf 100644 --- a/pmg-rs/Cargo.toml +++ b/pmg-rs/Cargo.toml @@ -42,3 +42,4 @@ proxmox-subscription = "0.5" proxmox-sys = "0.6" proxmox-tfa = { version = "5", features = ["api"] } proxmox-time = "2" +proxmox-openid = "0.10.0" diff --git a/pmg-rs/debian/control b/pmg-rs/debian/control index 295dcb3..afd8cbb 100644 --- a/pmg-rs/debian/control +++ b/pmg-rs/debian/control @@ -27,6 +27,7 @@ Build-Depends: cargo:native , librust-proxmox-http-error-0.1+default-dev, librust-proxmox-log-0.2+default-dev, librust-proxmox-notify-0.5+default-dev, + librust-proxmox-openid-0.10+default-dev, librust-proxmox-shared-cache-0.1+default-dev, librust-proxmox-subscription-0.5+default-dev, librust-proxmox-sys-0.6+default-dev, diff --git a/pve-rs/src/openid/mod.rs b/pve-rs/src/openid/mod.rs index 1fa7572..d3ad5a5 100644 --- a/pve-rs/src/openid/mod.rs +++ b/pve-rs/src/openid/mod.rs @@ -1,19 +1,13 @@ #[perlmod::package(name = "PVE::RS::OpenId", lib = "pve_rs")] mod export { - use std::sync::Mutex; - use anyhow::Error; - use perlmod::{to_value, Value}; - - use proxmox_openid::{OpenIdAuthenticator, OpenIdConfig, PrivateAuthState}; + use perlmod::Value; - perlmod::declare_magic!(Box : &OpenId as "PVE::RS::OpenId"); + use proxmox_openid::{OpenIdConfig, PrivateAuthState}; - /// An OpenIdAuthenticator client instance. - pub struct OpenId { - inner: Mutex, - } + use crate::common::openid::export as common; + use crate::common::openid::export::OpenId as OpenId; /// Create a new OpenId client instance #[export(raw_return)] @@ -22,13 +16,7 @@ mod export { config: OpenIdConfig, redirect_url: &str, ) -> Result { - let open_id = OpenIdAuthenticator::discover(&config, redirect_url)?; - Ok(perlmod::instantiate_magic!( - &class, - MAGIC => Box::new(OpenId { - inner: Mutex::new(open_id), - }) - )) + common::discover(class, config, redirect_url) } #[export] @@ -37,8 +25,7 @@ mod export { state_dir: &str, realm: &str, ) -> Result { - let open_id = this.inner.lock().unwrap(); - open_id.authorize_url(state_dir, realm) + common::authorize_url(this, state_dir, realm) } #[export] @@ -46,7 +33,7 @@ mod export { state_dir: &str, state: &str, ) -> Result<(String, PrivateAuthState), Error> { - OpenIdAuthenticator::verify_public_auth_state(state_dir, state) + common::verify_public_auth_state(state_dir, state) } #[export(raw_return)] @@ -55,9 +42,6 @@ mod export { code: &str, private_auth_state: PrivateAuthState, ) -> Result { - let open_id = this.inner.lock().unwrap(); - let claims = open_id.verify_authorization_code_simple(code, &private_auth_state)?; - - Ok(to_value(&claims)?) + common::verify_authorization_code(this, code, private_auth_state) } } -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:09 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:09 +0100 Subject: [pmg-devel] [PATCH pmg-gui v4 09/10] login: add OpenID realms In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-10-m.frank@proxmox.com> By adding a viewModel with an oidc variable, the username & password fields are disabled/hidden when an OIDC realm is selected. Signed-off-by: Markus Frank --- js/LoginView.js | 208 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 157 insertions(+), 51 deletions(-) diff --git a/js/LoginView.js b/js/LoginView.js index b5da19a..8366fe2 100644 --- a/js/LoginView.js +++ b/js/LoginView.js @@ -2,6 +2,21 @@ Ext.define('PMG.LoginView', { extend: 'Ext.container.Container', xtype: 'loginview', + viewModel: { + data: { + oidc: false, + }, + formulas: { + button_text: function(get) { + if (get("oidc") === true) { + return gettext("Login (OpenID Connect redirect)"); + } else { + return gettext("Login"); + } + }, + }, + }, + controller: { xclass: 'Ext.app.ViewController', @@ -46,50 +61,77 @@ Ext.define('PMG.LoginView', { submitForm: async function() { let me = this; - let view = me.getView(); - let loginForm = me.lookupReference('loginForm'); - var unField = me.lookupReference('usernameField'); - var saveunField = me.lookupReference('saveunField'); - if (loginForm.isValid()) { - if (loginForm.isVisible()) { - loginForm.mask(gettext('Please wait...'), 'x-mask-loading'); - } + let loginForm = this.lookupReference('loginForm'); + let unField = this.lookupReference('usernameField'); + let saveunField = this.lookupReference('saveunField'); + let view = this.getView(); - // set or clear username for admin view - if (view.targetview !== 'quarantineview') { - var sp = Ext.state.Manager.getProvider(); - if (saveunField.getValue() === true) { - sp.set(unField.getStateId(), unField.getValue()); - } else { - sp.clear(unField.getStateId()); - } - sp.set(saveunField.getStateId(), saveunField.getValue()); + if (!loginForm.isValid()) { + return; + } + + if (loginForm.isVisible()) { + loginForm.mask(gettext('Please wait...'), 'x-mask-loading'); + } + + // set or clear username for admin view + if (view.targetview !== 'quarantineview') { + let sp = Ext.state.Manager.getProvider(); + if (saveunField.getValue() === true) { + sp.set(unField.getStateId(), unField.getValue()); + } else { + sp.clear(unField.getStateId()); } + sp.set(saveunField.getStateId(), saveunField.getValue()); + } + + let creds = loginForm.getValues(); - let creds = loginForm.getValues(); + if (this.getViewModel().data.oidc === true) { + const redirectURL = location.origin; + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/openid/auth-url', + params: { + realm: creds.realm, + "redirect-url": redirectURL, + }, + method: 'POST', + success: function(resp, opts) { + window.location = resp.result.data; + }, + failure: function(resp, opts) { + Proxmox.Utils.authClear(); + loginForm.unmask(); + Ext.MessageBox.alert( + gettext('Error'), + gettext('OpenID Connect redirect failed.') + `
${resp.htmlStatus}`, + ); + }, + }); + return; + } - try { - let resp = await Proxmox.Async.api2({ - url: '/api2/extjs/access/ticket', - params: creds, - method: 'POST', - }); + try { + let resp = await Proxmox.Async.api2({ + url: '/api2/extjs/access/ticket', + params: creds, + method: 'POST', + }); - let data = resp.result.data; - if (data.ticket.startsWith('PMG:!tfa!')) { - data = await me.performTFAChallenge(data); - } - PMG.Utils.updateLoginData(data); - PMG.app.changeView(view.targetview); - } catch (error) { - Proxmox.Utils.authClear(); - loginForm.unmask(); - Ext.MessageBox.alert( - gettext('Error'), - gettext('Login failed. Please try again'), - ); + let data = resp.result.data; + if (data.ticket.startsWith('PMG:!tfa!')) { + data = await me.performTFAChallenge(data); } + PMG.Utils.updateLoginData(data); + PMG.app.changeView(view.targetview); + } catch (error) { + Proxmox.Utils.authClear(); + loginForm.unmask(); + Ext.MessageBox.alert( + gettext('Error'), + gettext('Login failed. Please try again'), + ); } }, @@ -115,6 +157,15 @@ Ext.define('PMG.LoginView', { return resp.result.data; }, + success: function(data) { + let me = this; + let view = me.getView(); + let handler = view.handler || Ext.emptyFn; + handler.call(me, data); + PMG.Utils.updateLoginData(data); + PMG.app.changeView(view.targetview); + }, + openQuarantineLinkWindow: function() { let me = this; me.lookup('loginwindow').setVisible(false); @@ -150,6 +201,14 @@ Ext.define('PMG.LoginView', { window.location.reload(); }, }, + 'field[name=realm]': { + change: function(f, value) { + let record = f.store.getById(value); + if (record === undefined) return; + let data = record.data; + this.getViewModel().set("oidc", data.type === "oidc"); + }, + }, 'button[reference=quarantineButton]': { click: 'openQuarantineLinkWindow', }, @@ -161,19 +220,54 @@ Ext.define('PMG.LoginView', { let me = this; let view = me.getView(); if (view.targetview !== 'quarantineview') { - var sp = Ext.state.Manager.getProvider(); - var checkboxField = this.lookupReference('saveunField'); - var unField = this.lookupReference('usernameField'); + let sp = Ext.state.Manager.getProvider(); + let checkboxField = this.lookupReference('saveunField'); + let unField = this.lookupReference('usernameField'); - var checked = sp.get(checkboxField.getStateId()); + let checked = sp.get(checkboxField.getStateId()); checkboxField.setValue(checked); if (checked === true) { - var username = sp.get(unField.getStateId()); + let username = sp.get(unField.getStateId()); unField.setValue(username); - var pwField = this.lookupReference('passwordField'); + let pwField = this.lookupReference('passwordField'); pwField.focus(); } + + let auth = Proxmox.Utils.getOpenIDRedirectionAuthorization(); + if (auth !== undefined) { + Proxmox.Utils.authClear(); + + let loginForm = this.lookupReference('loginForm'); + loginForm.mask(gettext('OpenID Connect login - please wait...'), 'x-mask-loading'); + + const redirectURL = location.origin; + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/openid/login', + params: { + state: auth.state, + code: auth.code, + "redirect-url": redirectURL, + }, + method: 'POST', + failure: function(response) { + loginForm.unmask(); + let error = response.htmlStatus; + Ext.MessageBox.alert( + gettext('Error'), + gettext('OpenID Connect login failed, please try again') + `
${error}`, + () => { window.location = redirectURL; }, + ); + }, + success: function(response, options) { + loginForm.unmask(); + let data = response.result.data; + history.replaceState(null, '', redirectURL); + me.success(data); + }, + }); + } } }, }, @@ -250,6 +344,10 @@ Ext.define('PMG.LoginView', { reference: 'usernameField', stateId: 'login-username', inputAttrTpl: 'autocomplete=username', + bind: { + visible: "{!oidc}", + disabled: "{oidc}", + }, }, { xtype: 'textfield', @@ -258,6 +356,16 @@ Ext.define('PMG.LoginView', { name: 'password', reference: 'passwordField', inputAttrTpl: 'autocomplete=current-password', + bind: { + visible: "{!oidc}", + disabled: "{oidc}", + }, + }, + { + xtype: 'pmxRealmComboBox', + reference: 'realmfield', + name: 'realm', + value: 'pam', }, { xtype: 'proxmoxLanguageSelector', @@ -266,12 +374,6 @@ Ext.define('PMG.LoginView', { name: 'lang', submitValue: false, }, - { - xtype: 'hiddenfield', - reference: 'realmfield', - name: 'realm', - value: 'pmg', - }, ], buttons: [ { @@ -283,15 +385,19 @@ Ext.define('PMG.LoginView', { labelAlign: 'right', labelWidth: 150, submitValue: false, + bind: { + visible: "{!oidc}", + }, }, { text: gettext('Request Quarantine Link'), reference: 'quarantineButton', }, { - text: gettext('Login'), + bind: { + text: "{button_text}", + }, reference: 'loginButton', - formBind: true, }, ], }, -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:06 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:06 +0100 Subject: [pmg-devel] [PATCH pmg-api v4 6/10] api: add/update/remove realms like in PVE In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-7-m.frank@proxmox.com> The name Authdomains.pm was chosen because a Domain.pm already exists. Signed-off-by: Markus Frank --- src/Makefile | 1 + src/PMG/API2/AccessControl.pm | 10 +- src/PMG/API2/Authdomains.pm | 274 ++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/PMG/API2/Authdomains.pm diff --git a/src/Makefile b/src/Makefile index 3ed0ea1..1dfe469 100644 --- a/src/Makefile +++ b/src/Makefile @@ -151,6 +151,7 @@ LIBSOURCES = \ PMG/API2/Postfix.pm \ PMG/API2/Quarantine.pm \ PMG/API2/AccessControl.pm \ + PMG/API2/Authdomains.pm \ PMG/API2/TFA.pm \ PMG/API2/TFAConfig.pm \ PMG/API2/ObjectGroupHelpers.pm \ diff --git a/src/PMG/API2/AccessControl.pm b/src/PMG/API2/AccessControl.pm index e26ae70..dad679c 100644 --- a/src/PMG/API2/AccessControl.pm +++ b/src/PMG/API2/AccessControl.pm @@ -12,6 +12,7 @@ use PVE::JSONSchema qw(get_standard_option); use PMG::Utils; use PMG::UserConfig; use PMG::AccessControl; +use PMG::API2::Authdomains; use PMG::API2::Users; use PMG::API2::TFA; use PMG::TFAConfig; @@ -30,6 +31,11 @@ __PACKAGE__->register_method ({ path => 'tfa', }); +__PACKAGE__->register_method ({ + subclass => "PMG::API2::Authdomains", + path => 'domains', +}); + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -57,6 +63,7 @@ __PACKAGE__->register_method ({ my $res = [ { subdir => 'ticket' }, + { subdir => 'domains' }, { subdir => 'password' }, { subdir => 'users' }, ]; @@ -248,7 +255,8 @@ __PACKAGE__->register_method ({ my $username = $param->{username}; - if ($username !~ m/\@(pam|pmg|quarantine)$/) { + my $realm_regex = PMG::Utils::valid_pmg_realm_regex(); + if ($username !~ m/\@(${realm_regex})$/) { my $realm = $param->{realm} // 'quarantine'; $username .= "\@$realm"; } diff --git a/src/PMG/API2/Authdomains.pm b/src/PMG/API2/Authdomains.pm new file mode 100644 index 0000000..a93ca13 --- /dev/null +++ b/src/PMG/API2/Authdomains.pm @@ -0,0 +1,274 @@ +package PMG::API2::Authdomains; + +use strict; +use warnings; + +use PVE::Exception qw(raise_param_exc); +use PVE::INotify; +use PVE::JSONSchema qw(get_standard_option); +use PVE::RESTHandler; +use PVE::SafeSyslog; +use PVE::Tools qw(extract_param); + +use PMG::AccessControl; +use PMG::Auth::Plugin; +use PVE::Schema::Auth; + +my $domainconfigfile = "realms.cfg"; + +use base qw(PVE::RESTHandler); + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + description => "Authentication domain index.", + permissions => { + description => "Anyone can access that, because we need that list for the login box (before the user is authenticated).", + user => 'world', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + realm => { type => 'string' }, + type => { type => 'string' }, + tfa => { + description => "Two-factor authentication provider.", + type => 'string', + enum => [ 'yubico', 'oath' ], + optional => 1, + }, + comment => { + description => "A comment. The GUI use this text when you select a domain (Realm) on the login window.", + type => 'string', + optional => 1, + }, + }, + }, + links => [ { rel => 'child', href => "{realm}" } ], + }, + code => sub { + my ($param) = @_; + + my $res = []; + + my $cfg = PVE::INotify::read_file($domainconfigfile); + my $ids = $cfg->{ids}; + + for my $realm (keys %$ids) { + my $d = $ids->{$realm}; + my $entry = { realm => $realm, type => $d->{type} }; + $entry->{comment} = $d->{comment} if $d->{comment}; + $entry->{default} = 1 if $d->{default}; + if ($d->{tfa} && (my $tfa_cfg = PVE::Schema::Auth::parse_tfa_config($d->{tfa}))) { + $entry->{tfa} = $tfa_cfg->{type}; + } + push @$res, $entry; + } + + return $res; + }}); + +__PACKAGE__->register_method ({ + name => 'create', + protected => 1, + path => '', + method => 'POST', + permissions => { check => [ 'admin' ] }, + description => "Add an authentication server.", + parameters => PMG::Auth::Plugin->createSchema(0), + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + # always extract, add it with hook + my $password = extract_param($param, 'password'); + + PMG::Auth::Plugin::lock_domain_config( + sub { + my $cfg = PVE::INotify::read_file($domainconfigfile); + my $ids = $cfg->{ids}; + + my $realm = extract_param($param, 'realm'); + PMG::Auth::Plugin::pmg_verify_realm($realm); + my $type = $param->{type}; + my $check_connection = extract_param($param, 'check-connection'); + + die "domain '$realm' already exists\n" + if $ids->{$realm}; + + die "unable to use reserved name '$realm'\n" + if ($realm eq 'pam' || $realm eq 'pmg'); + + die "unable to create builtin type '$type'\n" + if ($type eq 'pam' || $type eq 'pmg'); + + my $plugin = PMG::Auth::Plugin->lookup($type); + my $config = $plugin->check_config($realm, $param, 1, 1); + + if ($config->{default}) { + for my $r (keys %$ids) { + delete $ids->{$r}->{default}; + } + } + + $ids->{$realm} = $config; + + my $opts = $plugin->options(); + if (defined($password) && !defined($opts->{password})) { + $password = undef; + warn "ignoring password parameter"; + } + $plugin->on_add_hook($realm, $config, password => $password); + + PVE::INotify::write_file($domainconfigfile, $cfg); + }, + "add auth server failed", + ); + return undef; + }}); + +__PACKAGE__->register_method ({ + name => 'update', + path => '{realm}', + method => 'PUT', + permissions => { check => [ 'admin' ] }, + description => "Update authentication server settings.", + protected => 1, + parameters => PMG::Auth::Plugin->updateSchema(0), + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + # always extract, update in hook + my $password = extract_param($param, 'password'); + + PMG::Auth::Plugin::lock_domain_config( + sub { + my $cfg = PVE::INotify::read_file($domainconfigfile); + my $ids = $cfg->{ids}; + + my $digest = extract_param($param, 'digest'); + PVE::SectionConfig::assert_if_modified($cfg, $digest); + + my $realm = extract_param($param, 'realm'); + my $type = $ids->{$realm}->{type}; + my $check_connection = extract_param($param, 'check-connection'); + + die "domain '$realm' does not exist\n" + if !$ids->{$realm}; + + my $delete_str = extract_param($param, 'delete'); + die "no options specified\n" + if !$delete_str && !scalar(keys %$param) && !defined($password); + + my $delete_pw = 0; + for my $opt (PVE::Tools::split_list($delete_str)) { + delete $ids->{$realm}->{$opt}; + $delete_pw = 1 if $opt eq 'password'; + } + + my $plugin = PMG::Auth::Plugin->lookup($type); + my $config = $plugin->check_config($realm, $param, 0, 1); + + if ($config->{default}) { + for my $r (keys %$ids) { + delete $ids->{$r}->{default}; + } + } + + for my $p (keys %$config) { + $ids->{$realm}->{$p} = $config->{$p}; + } + + my $opts = $plugin->options(); + if ($delete_pw || defined($password)) { + $plugin->on_update_hook($realm, $config, password => $password); + } else { + $plugin->on_update_hook($realm, $config); + } + + PVE::INotify::write_file($domainconfigfile, $cfg); + }, + "update auth server failed" + ); + return undef; + }}); + +# fixme: return format! +__PACKAGE__->register_method ({ + name => 'read', + path => '{realm}', + method => 'GET', + description => "Get auth server configuration.", + permissions => { check => [ 'admin', 'qmanager', 'audit' ] }, + parameters => { + additionalProperties => 0, + properties => { + realm => get_standard_option('realm'), + }, + }, + returns => {}, + code => sub { + my ($param) = @_; + + my $cfg = PVE::INotify::read_file($domainconfigfile); + + my $realm = $param->{realm}; + + my $data = $cfg->{ids}->{$realm}; + die "domain '$realm' does not exist\n" if !$data; + + my $type = $data->{type}; + + $data->{digest} = $cfg->{digest}; + + return $data; + }}); + + +__PACKAGE__->register_method ({ + name => 'delete', + path => '{realm}', + method => 'DELETE', + permissions => { check => [ 'admin' ] }, + description => "Delete an authentication server.", + protected => 1, + parameters => { + additionalProperties => 0, + properties => { + realm => get_standard_option('realm'), + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + PMG::Auth::Plugin::lock_domain_config( + sub { + my $cfg = PVE::INotify::read_file($domainconfigfile); + my $ids = $cfg->{ids}; + my $realm = $param->{realm}; + + die "authentication domain '$realm' does not exist\n" if !$ids->{$realm}; + + my $plugin = PMG::Auth::Plugin->lookup($ids->{$realm}->{type}); + + $plugin->on_delete_hook($realm, $ids->{$realm}); + + delete $ids->{$realm}; + + PVE::INotify::write_file($domainconfigfile, $cfg); + }, + "delete auth server failed", + ); + return undef; + }}); + +1; -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:07 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:07 +0100 Subject: [pmg-devel] [PATCH pmg-api v4 7/10] api: openid login similar to PVE In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-8-m.frank@proxmox.com> Allow OpenID Connect login using the Rust OpenID module. Signed-off-by: Markus Frank --- src/Makefile | 1 + src/PMG/API2/AccessControl.pm | 7 + src/PMG/API2/OpenId.pm | 243 ++++++++++++++++++++++++++++++++++ src/PMG/HTTPServer.pm | 2 + 4 files changed, 253 insertions(+) create mode 100644 src/PMG/API2/OpenId.pm diff --git a/src/Makefile b/src/Makefile index 1dfe469..8268978 100644 --- a/src/Makefile +++ b/src/Makefile @@ -152,6 +152,7 @@ LIBSOURCES = \ PMG/API2/Quarantine.pm \ PMG/API2/AccessControl.pm \ PMG/API2/Authdomains.pm \ + PMG/API2/OpenId.pm \ PMG/API2/TFA.pm \ PMG/API2/TFAConfig.pm \ PMG/API2/ObjectGroupHelpers.pm \ diff --git a/src/PMG/API2/AccessControl.pm b/src/PMG/API2/AccessControl.pm index dad679c..214fd78 100644 --- a/src/PMG/API2/AccessControl.pm +++ b/src/PMG/API2/AccessControl.pm @@ -13,6 +13,7 @@ use PMG::Utils; use PMG::UserConfig; use PMG::AccessControl; use PMG::API2::Authdomains; +use PMG::API2::OpenId; use PMG::API2::Users; use PMG::API2::TFA; use PMG::TFAConfig; @@ -36,6 +37,11 @@ __PACKAGE__->register_method ({ path => 'domains', }); +__PACKAGE__->register_method ({ + subclass => "PMG::API2::OpenId", + path => 'openid', +}); + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -64,6 +70,7 @@ __PACKAGE__->register_method ({ my $res = [ { subdir => 'ticket' }, { subdir => 'domains' }, + { subdir => 'openid' }, { subdir => 'password' }, { subdir => 'users' }, ]; diff --git a/src/PMG/API2/OpenId.pm b/src/PMG/API2/OpenId.pm new file mode 100644 index 0000000..76394e5 --- /dev/null +++ b/src/PMG/API2/OpenId.pm @@ -0,0 +1,243 @@ +package PMG::API2::OpenId; + +use strict; +use warnings; + +use PVE::Tools qw(extract_param lock_file); +use Proxmox::RS::OpenId; + +use PVE::Exception qw(raise raise_perm_exc raise_param_exc); +use PVE::SafeSyslog; +use PVE::INotify; +use PVE::JSONSchema qw(get_standard_option); + +use PMG::AccessControl; +use PMG::RESTEnvironment; +use PVE::RESTHandler; + +use base qw(PVE::RESTHandler); + +my $openid_state_path = "/var/lib/pmg"; + +my $lookup_openid_auth = sub { + my ($realm, $redirect_url) = @_; + + my $cfg = PVE::INotify::read_file('realms.cfg'); + my $ids = $cfg->{ids}; + + die "authentication domain '$realm' does not exist\n" if !$ids->{$realm}; + + my $config = $ids->{$realm}; + die "wrong realm type ($config->{type} != oidc)\n" if $config->{type} ne "oidc"; + + my $openid_config = { + issuer_url => $config->{'issuer-url'}, + client_id => $config->{'client-id'}, + client_key => $config->{'client-key'}, + }; + $openid_config->{prompt} = $config->{'prompt'} if defined($config->{'prompt'}); + + my $scopes = $config->{'scopes'} // 'email profile'; + $openid_config->{scopes} = [ PVE::Tools::split_list($scopes) ]; + + if (defined(my $acr = $config->{'acr-values'})) { + $openid_config->{acr_values} = [ PVE::Tools::split_list($acr) ]; + } + + my $openid = Proxmox::RS::OpenId->discover($openid_config, $redirect_url); + return ($config, $openid); +}; + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + description => "Directory index.", + permissions => { + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + subdir => { type => 'string' }, + }, + }, + links => [ { rel => 'child', href => "{subdir}" } ], + }, + code => sub { + my ($param) = @_; + + return [ + { subdir => 'auth-url' }, + { subdir => 'login' }, + ]; + }}); + +__PACKAGE__->register_method ({ + name => 'auth_url', + path => 'auth-url', + method => 'POST', + protected => 1, + description => "Get the OpenId Connect Authorization Url for the specified realm.", + parameters => { + additionalProperties => 0, + properties => { + realm => { + description => "Authentication domain ID", + type => 'string', + pattern => qr/[A-Za-z][A-Za-z0-9\.\-_]+/, + maxLength => 32, + }, + 'redirect-url' => { + description => "Redirection Url. The client should set this to the used server url (location.origin).", + type => 'string', + maxLength => 255, + }, + }, + }, + returns => { + type => "string", + description => "Redirection URL.", + }, + permissions => { user => 'world' }, + code => sub { + my ($param) = @_; + + my $realm = extract_param($param, 'realm'); + my $redirect_url = extract_param($param, 'redirect-url'); + + my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); + my $url = $openid->authorize_url($openid_state_path , $realm); + + return $url; + }}); + +__PACKAGE__->register_method ({ + name => 'login', + path => 'login', + method => 'POST', + protected => 1, + description => " Verify OpenID Connect authorization code and create a ticket.", + parameters => { + additionalProperties => 0, + properties => { + 'state' => { + description => "OpenId Connect state.", + type => 'string', + maxLength => 1024, + }, + code => { + description => "OpenId Connect authorization code.", + type => 'string', + maxLength => 4096, + }, + 'redirect-url' => { + description => "Redirection Url. The client should set this to the used server url (location.origin).", + type => 'string', + maxLength => 255, + }, + }, + }, + returns => { + properties => { + role => { type => 'string', optional => 1}, + username => { type => 'string' }, + ticket => { type => 'string' }, + CSRFPreventionToken => { type => 'string' }, + }, + }, + permissions => { user => 'world' }, + code => sub { + my ($param) = @_; + + my $rpcenv = PMG::RESTEnvironment->get(); + + my $res; + eval { + my ($realm, $private_auth_state) = Proxmox::RS::OpenId::verify_public_auth_state( + $openid_state_path, $param->{'state'}); + + my $redirect_url = extract_param($param, 'redirect-url'); + + my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); + + my $info = $openid->verify_authorization_code($param->{code}, $private_auth_state); + my $subject = $info->{'sub'}; + + my $unique_name; + + my $user_attr = $config->{'username-claim'} // 'sub'; + if (defined($info->{$user_attr})) { + $unique_name = $info->{$user_attr}; + } elsif ($user_attr eq 'subject') { # stay compat with old versions + $unique_name = $subject; + } elsif ($user_attr eq 'username') { # stay compat with old versions + my $username = $info->{'preferred_username'}; + die "missing claim 'preferred_username'\n" if !defined($username); + $unique_name = $username; + } else { + # neither the attr nor fallback are defined in info.. + die "missing configured claim '$user_attr' in returned info object\n"; + } + + my $username = "${unique_name}\@${realm}"; + # first, check if $username respects our naming conventions + PMG::Utils::verify_username($username); + if ($config->{'autocreate'} && !$rpcenv->check_user_exist($username, 1)) { + my $code = sub { + my $usercfg = PMG::UserConfig->new(); + + my $entry = { enable => 1 }; + if (defined(my $email = $info->{'email'})) { + $entry->{email} = $email; + } + if (defined(my $given_name = $info->{'given_name'})) { + $entry->{firstname} = $given_name; + } + if (defined(my $family_name = $info->{'family_name'})) { + $entry->{lastname} = $family_name; + } + $entry->{role} = $config->{'autocreate-role'} // 'audit'; + $entry->{userid} = $username; + $entry->{username} = $unique_name; + $entry->{realm} = $realm; + + die "User '$username' already exists\n" + if $usercfg->{$username}; + + $usercfg->{$username} = $entry; + + $usercfg->write(); + }; + PMG::UserConfig::lock_config($code, "autocreate openid connect user failed"); + } + my $role = $rpcenv->check_user_enabled($username); + + my $ticket = PMG::Ticket::assemble_ticket($username); + my $csrftoken = PMG::Ticket::assemble_csrf_prevention_token($username); + + $res = { + ticket => $ticket, + username => $username, + CSRFPreventionToken => $csrftoken, + role => $role, + }; + + }; + if (my $err = $@) { + my $clientip = $rpcenv->get_client_ip() || ''; + syslog('err', "openid connect authentication failure; rhost=$clientip msg=$err"); + # do not return any info to prevent user enumeration attacks + die PVE::Exception->new("authentication failure $err\n", code => 401); + } + + syslog('info', 'root at pam', "successful openid connect auth for user '$res->{username}'"); + + return $res; + }}); diff --git a/src/PMG/HTTPServer.pm b/src/PMG/HTTPServer.pm index 49724fe..c3e5b65 100644 --- a/src/PMG/HTTPServer.pm +++ b/src/PMG/HTTPServer.pm @@ -58,6 +58,8 @@ sub auth_handler { # explicitly allow some calls without auth if (($rel_uri eq '/access/domains' && $method eq 'GET') || + ($rel_uri eq '/access/openid/login' && $method eq 'POST') || + ($rel_uri eq '/access/openid/auth-url' && $method eq 'POST') || ($rel_uri eq '/quarantine/sendlink' && ($method eq 'GET' || $method eq 'POST')) || ($rel_uri eq '/access/ticket' && ($method eq 'GET' || $method eq 'POST'))) { $require_auth = 0; -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:00 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:00 +0100 Subject: [pmg-devel] [PATCH pve-common/perl-rs/pmg-api/widget-toolkit/pmg-gui v4 0/10] fix #3892: OpenID Connect Message-ID: <20250114093010.4560-1-m.frank@proxmox.com> Patch-series to enable OpenID Connect Login for PMG apply/compile order: pve-common: 1 add Schema package with auth module that contains realm sync options proxmox-perl-rs: 2 move openid code from pve-rs to common 3 remove empty PMG::RS::OpenId package to avoid confusion pmg-api: 4 config: add plugin system for realms 5 config: add openid type realm 6 api: add/update/remove realms like in PVE 7 api: openid login similar to PVE proxmox-widget-toolkit: 8 fix: window: AuthEditBase: rename variable 'realm' to 'type' pmg-gui: 9 login: add option to login with OpenID realm 10 add panel for realms to User Management v4: * split "config: add plugin system for realms & add openid type realms" patch into two patches * use the name 'OpenId' for filenames, but use 'OIDC' as realm type name * added autocreate-role option to set the role for automatically created users in a realm, but currently not exposed in GUI (needs a lot of changes in pmg-gui and proxmox-widget-toolkit) pve-common: Markus Frank (1): add Schema package with auth module that contains realm sync options src/Makefile | 2 ++ src/PVE/Schema/Auth.pm | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/PVE/Schema/Auth.pm proxmox-perl-rs: Markus Frank (2): move openid code from pve-rs to common remove empty PMG::RS::OpenId package to avoid confusion common/pkg/Makefile | 1 + common/src/mod.rs | 1 + common/src/openid/mod.rs | 63 ++++++++++++++++++++++++++++++++++++++++ pmg-rs/Cargo.toml | 1 + pmg-rs/Makefile | 1 - pmg-rs/debian/control | 1 + pve-rs/src/openid/mod.rs | 32 +++++--------------- 7 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 common/src/openid/mod.rs pmg-api: Markus Frank (4): config: add plugin system for realms config: add openid type realm api: add/update/remove realms like in PVE api: openid login similar to PVE src/Makefile | 6 + src/PMG/API2/AccessControl.pm | 17 ++- src/PMG/API2/Authdomains.pm | 274 ++++++++++++++++++++++++++++++++++ src/PMG/API2/OpenId.pm | 243 ++++++++++++++++++++++++++++++ src/PMG/AccessControl.pm | 33 ++++ src/PMG/Auth/OpenId.pm | 95 ++++++++++++ src/PMG/Auth/PAM.pm | 22 +++ src/PMG/Auth/PMG.pm | 39 +++++ src/PMG/Auth/Plugin.pm | 199 ++++++++++++++++++++++++ src/PMG/HTTPServer.pm | 2 + src/PMG/RESTEnvironment.pm | 14 ++ src/PMG/UserConfig.pm | 25 ++-- src/PMG/Utils.pm | 29 +++- 13 files changed, 981 insertions(+), 17 deletions(-) create mode 100644 src/PMG/API2/Authdomains.pm create mode 100644 src/PMG/API2/OpenId.pm create mode 100755 src/PMG/Auth/OpenId.pm create mode 100755 src/PMG/Auth/PAM.pm create mode 100755 src/PMG/Auth/PMG.pm create mode 100755 src/PMG/Auth/Plugin.pm widget-toolkit: Markus Frank (1): fix: window: AuthEditBase: rename variable 'realm' to 'type' src/window/AuthEditBase.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) pmg-gui: Markus Frank (2): login: add OpenID realms add panel for realms to User Management js/LoginView.js | 208 ++++++++++++++++++++++++++++++++----------- js/UserManagement.js | 6 ++ js/Utils.js | 23 +++++ 3 files changed, 186 insertions(+), 51 deletions(-) -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:10 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:10 +0100 Subject: [pmg-devel] [PATCH pmg-gui v4 10/10] add panel for realms to User Management In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-11-m.frank@proxmox.com> Make the realm configuration available in PMG and disable LDAP/AD realms for now and use the name oidc instead of openid. Signed-off-by: Markus Frank --- js/UserManagement.js | 6 ++++++ js/Utils.js | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/js/UserManagement.js b/js/UserManagement.js index 65fabbf..54493b1 100644 --- a/js/UserManagement.js +++ b/js/UserManagement.js @@ -34,5 +34,11 @@ Ext.define('PMG.UserManagement', { itemId: 'pop', iconCls: 'fa fa-reply-all', }, + { + xtype: 'pmxAuthView', + title: gettext('Realms'), + itemId: 'domains', + iconCls: 'fa fa-address-book-o', + }, ], }); diff --git a/js/Utils.js b/js/Utils.js index 9b5f054..055b938 100644 --- a/js/Utils.js +++ b/js/Utils.js @@ -851,6 +851,29 @@ Ext.define('PMG.Utils', { constructor: function() { var me = this; + // use oidc instead of openid + Proxmox.Schema.authDomains.oidc = Proxmox.Schema.authDomains.openid; + Proxmox.Schema.authDomains.oidc.useTypeInUrl = false; + delete Proxmox.Schema.authDomains.openid; + + // Disable LDAP/AD as a realm until LDAP/AD login is implemented + Proxmox.Schema.authDomains.ldap.add = false; + Proxmox.Schema.authDomains.ad.add = false; + + Proxmox.Schema.authDomains.pmg = { + add: false, + edit: false, + pwchange: false, + sync: false, + }; + + Proxmox.Schema.authDomains.pam = { + add: false, + edit: false, + pwchange: false, + sync: false, + }; + // do whatever you want here Proxmox.Utils.override_task_descriptions({ applycustomscores: ['', gettext('Apply custom SpamAssassin scores')], -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:08 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:08 +0100 Subject: [pmg-devel] [PATCH widget-toolkit v4 8/10] fix: window: AuthEditBase: rename variable 'realm' to 'type' In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-9-m.frank@proxmox.com> PVE/PMG API returns a variable called 'type' instead of 'realm' Fixes: 3822a031ddbe4136aa847476f2e3785934a41547 Signed-off-by: Markus Frank --- src/window/AuthEditBase.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/window/AuthEditBase.js b/src/window/AuthEditBase.js index 73c1fee..e044235 100644 --- a/src/window/AuthEditBase.js +++ b/src/window/AuthEditBase.js @@ -91,9 +91,9 @@ Ext.define('Proxmox.window.AuthEditBase', { var data = response.result.data || {}; // just to be sure (should not happen) // only check this when the type is not in the api path - if (!me.useTypeInUrl && data.realm !== me.authType) { + if (!me.useTypeInUrl && data.type !== me.authType) { me.close(); - throw `got wrong auth type '${me.authType}' for realm '${data.realm}'`; + throw `got wrong auth type '${me.authType}' for realm '${data.type}'`; } me.setValues(data); }, -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:05 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:05 +0100 Subject: [pmg-devel] [PATCH pmg-api v4 5/10] config: add openid type realm In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-6-m.frank@proxmox.com> If the autocreate option is enabled, the user is automatically created with the Audit role. To change the role for automatically created users, set the autocreate-role option to the preferred role. Signed-off-by: Markus Frank --- src/Makefile | 1 + src/PMG/AccessControl.pm | 2 + src/PMG/Auth/OpenId.pm | 95 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100755 src/PMG/Auth/OpenId.pm diff --git a/src/Makefile b/src/Makefile index 659a666..3ed0ea1 100644 --- a/src/Makefile +++ b/src/Makefile @@ -169,6 +169,7 @@ LIBSOURCES = \ PMG/Auth/Plugin.pm \ PMG/Auth/PAM.pm \ PMG/Auth/PMG.pm \ + PMG/Auth/OpenId.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/AccessControl.pm b/src/PMG/AccessControl.pm index ced5f9a..534385e 100644 --- a/src/PMG/AccessControl.pm +++ b/src/PMG/AccessControl.pm @@ -14,9 +14,11 @@ use PMG::LDAPSet; use PMG::TFAConfig; use PMG::Auth::Plugin; +use PMG::Auth::OpenId; use PMG::Auth::PAM; use PMG::Auth::PMG; +PMG::Auth::OpenId->register(); PMG::Auth::PAM->register(); PMG::Auth::PMG->register(); PMG::Auth::Plugin->init(); diff --git a/src/PMG/Auth/OpenId.pm b/src/PMG/Auth/OpenId.pm new file mode 100755 index 0000000..bbc4e38 --- /dev/null +++ b/src/PMG/Auth/OpenId.pm @@ -0,0 +1,95 @@ +package PMG::Auth::OpenId; + +use strict; +use warnings; + +use PVE::Tools; +use PMG::Auth::Plugin; + +use base qw(PMG::Auth::Plugin); + +sub type { + return 'oidc'; +} + +sub properties { + return { + 'issuer-url' => { + description => "OpenID Connect Issuer Url", + type => 'string', + maxLength => 256, + }, + 'client-id' => { + description => "OpenID Connect Client ID", + type => 'string', + maxLength => 256, + }, + 'client-key' => { + description => "OpenID Connect Client Key", + type => 'string', + optional => 1, + maxLength => 256, + }, + autocreate => { + description => "Automatically create users if they do not exist.", + optional => 1, + type => 'boolean', + default => 0, + }, + 'autocreate-role' => { + description => "Automatically create users with a specific role.", + type => 'string', + enum => ['admin', 'qmanager', 'audit', 'helpdesk'], + optional => 1, + }, + 'username-claim' => { + description => "OpenID Connect claim used to generate the unique username.", + type => 'string', + optional => 1, + }, + prompt => { + description => "Specifies whether the Authorization Server prompts the End-User for" + ." reauthentication and consent.", + type => 'string', + pattern => '(?:none|login|consent|select_account|\S+)', # \S+ is the extension variant + optional => 1, + }, + scopes => { + description => "Specifies the scopes (user details) that should be authorized and" + ." returned, for example 'email' or 'profile'.", + type => 'string', # format => 'some-safe-id-list', # FIXME: TODO + default => "email profile", + optional => 1, + }, + 'acr-values' => { + description => "Specifies the Authentication Context Class Reference values that the" + ."Authorization Server is being requested to use for the Auth Request.", + type => 'string', # format => 'some-safe-id-list', # FIXME: TODO + optional => 1, + }, + }; +} + +sub options { + return { + 'issuer-url' => {}, + 'client-id' => {}, + 'client-key' => { optional => 1 }, + autocreate => { optional => 1 }, + 'autocreate-role' => { optional => 1 }, + 'username-claim' => { optional => 1, fixed => 1 }, + prompt => { optional => 1 }, + scopes => { optional => 1 }, + 'acr-values' => { optional => 1 }, + default => { optional => 1 }, + comment => { optional => 1 }, + }; +} + +sub authenticate_user { + my ($class, $config, $realm, $username, $password) = @_; + + die "OpenID Connect realm does not allow password verification.\n"; +} + +1; -- 2.39.5 From m.frank at proxmox.com Tue Jan 14 10:30:04 2025 From: m.frank at proxmox.com (Markus Frank) Date: Tue, 14 Jan 2025 10:30:04 +0100 Subject: [pmg-devel] [PATCH pmg-api v4 4/10] config: add plugin system for realms In-Reply-To: <20250114093010.4560-1-m.frank@proxmox.com> References: <20250114093010.4560-1-m.frank@proxmox.com> Message-ID: <20250114093010.4560-5-m.frank@proxmox.com> 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 --- src/Makefile | 3 + src/PMG/AccessControl.pm | 31 ++++++ src/PMG/Auth/PAM.pm | 22 ++++ src/PMG/Auth/PMG.pm | 39 ++++++++ src/PMG/Auth/Plugin.pm | 199 +++++++++++++++++++++++++++++++++++++ src/PMG/RESTEnvironment.pm | 14 +++ src/PMG/UserConfig.pm | 25 +++-- src/PMG/Utils.pm | 29 ++++-- 8 files changed, 346 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/AccessControl.pm b/src/PMG/AccessControl.pm index e12d7cf..ced5f9a 100644 --- a/src/PMG/AccessControl.pm +++ b/src/PMG/AccessControl.pm @@ -13,6 +13,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 +46,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 +62,8 @@ sub authenticate_user : prototype($$$) { return ($pmail . '@quarantine', undef); } die "ldap login failed\n"; + } elsif ($realm =~ m!(${realm_regex})!) { + # nothing to do for self-defined realms } else { die "no such realm '$realm'\n"; } @@ -79,6 +90,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 +104,8 @@ sub set_user_password { } elsif ($realm eq 'pmg') { PMG::UserConfig->set_user_password($username, $password); + } elsif ($realm =~ m!(${realm_regex})!) { + # nothing to do for self-defined realms } else { die "no such realm '$realm'\n"; } @@ -106,6 +120,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 +130,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 +142,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..3ffd8a6 --- /dev/null +++ b/src/PMG/Auth/PAM.pm @@ -0,0 +1,22 @@ +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 }, + tfa => { optional => 1 }, + }; +} + +1; diff --git a/src/PMG/Auth/PMG.pm b/src/PMG/Auth/PMG.pm new file mode 100755 index 0000000..b083692 --- /dev/null +++ b/src/PMG/Auth/PMG.pm @@ -0,0 +1,39 @@ +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, + }, + tfa => PVE::JSONSchema::get_standard_option('tfa'), + }; +} + +sub options { + return { + default => { optional => 1 }, + comment => { optional => 1 }, + tfa => { optional => 1 }, + }; +} + +1; diff --git a/src/PMG/Auth/Plugin.pm b/src/PMG/Auth/Plugin.pm new file mode 100755 index 0000000..b336d0c --- /dev/null +++ b/src/PMG/Auth/Plugin.pm @@ -0,0 +1,199 @@ +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::Schema::Auth; +use PVE::SectionConfig; +use PVE::Tools; + +use base qw(PVE::SectionConfig); + +my $domainconfigfile = "realms.cfg"; +my $lockfile = "/var/lock/pmg-realms.lck"; + +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( + $domainconfigfile, + "/etc/pmg/realms.cfg", + \&read_realms_conf, + \&write_realms_conf, + undef, + always_call_parser => 1, +); + +sub lock_domain_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 domains + $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 domain 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 domain config *locked* context +sub on_add_hook { + my ($class, $realm, $config, %param) = @_; + # do nothing by default +} + +# called during domain configuration update (before the updated domain 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 domain config *locked* context +sub on_update_hook { + my ($class, $realm, $config, %param) = @_; + # do nothing by default +} + +# called during deletion of realms (before the new domain 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 domain 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 domain config gets written) +# die to abort addition/update in case the connection/bind fails +# NOTE: runs in a domain 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 { (?(?:[^:]*)) : $/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 at 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 at 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 at pam') { - $line = 'root:'; + $line = 'root at pam:'; $d->{crypt_pass} = '', $d->{expire} = '0', $d->{role} = 'root'; } else { - next if $userid !~ m/^(?.+)\@pmg$/; - $line = "$+{username}:"; + next if $userid !~ m/^(?.+)\@(${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'); + 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'); + 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; } -- 2.39.5 From m.sandoval at proxmox.com Wed Jan 15 16:26:10 2025 From: m.sandoval at proxmox.com (Maximiliano Sandoval) Date: Wed, 15 Jan 2025 16:26:10 +0100 Subject: [pmg-devel] [PATCH pmg-docs v2] fix some typos In-Reply-To: <20241114100336.101411-1-m.sandoval@proxmox.com> References: <20241114100336.101411-1-m.sandoval@proxmox.com> Message-ID: Maximiliano Sandoval writes: > Signed-off-by: Maximiliano Sandoval > --- > Differences from v1: > - Remove fixes on automatically generated documentation (pmg.conf.5-opts.adoc). > > pmgconfig.adoc | 2 +- > system-booting.adoc | 2 +- > 2 files changed, 2 insertions(+), 2 deletions(-) ping From m.sandoval at proxmox.com Mon Jan 27 15:44:36 2025 From: m.sandoval at proxmox.com (Maximiliano Sandoval) Date: Mon, 27 Jan 2025 15:44:36 +0100 Subject: [pmg-devel] [PATCH pmg-docs v2] fix some typos In-Reply-To: References: <20241114100336.101411-1-m.sandoval@proxmox.com> Message-ID: Maximiliano Sandoval writes: > Maximiliano Sandoval writes: > >> Signed-off-by: Maximiliano Sandoval >> --- >> Differences from v1: >> - Remove fixes on automatically generated documentation (pmg.conf.5-opts.adoc). >> >> pmgconfig.adoc | 2 +- >> system-booting.adoc | 2 +- >> 2 files changed, 2 insertions(+), 2 deletions(-) > > ping ping From s.ivanov at proxmox.com Thu Jan 30 12:21:40 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 12:21:40 +0100 Subject: [pmg-devel] [PATCH pmg-api 2/2] fix #6126: do not deliver/delete quarantined mails multiple times In-Reply-To: <20250130112140.42219-1-s.ivanov@proxmox.com> References: <20250130112140.42219-1-s.ivanov@proxmox.com> Message-ID: <20250130112140.42219-3-s.ivanov@proxmox.com> This was reported through our enterprise support and was easy to reproduce. When clicking on the action-links in the spamreport multiple times the action was equally done multiple times, which was surprising especially when you deliver a quarantined mail more than once. As quarantined mail-files are not deleted upon deliver/delete (mails can have multiple recipients), but asynchronously after the quarantine lifetime they are only marked with status 'D'. Use this information to prevent multiple deliveries and superfluous database updates. Signed-off-by: Stoiko Ivanov --- src/PMG/Quarantine.pm | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/PMG/Quarantine.pm b/src/PMG/Quarantine.pm index 28138ba..77f64a0 100644 --- a/src/PMG/Quarantine.pm +++ b/src/PMG/Quarantine.pm @@ -95,6 +95,12 @@ sub deliver_quarantined_mail { my $id = 'C' . $ref->{cid} . 'R' . $ref->{rid} . 'T' . $ref->{ticketid};; + if ($ref->{status} eq 'D') { + syslog('info', "quarantined mail '$id' ($path) has already been delivered or marked as " . + "deleted for $receiver!"); + return 1; + } + my $parser = PMG::MIMEUtils::new_mime_parser({ nested => 1, decode_bodies => 0, @@ -148,6 +154,12 @@ sub delete_quarantined_mail { my $id = 'C' . $ref->{cid} . 'R' . $ref->{rid} . 'T' . $ref->{ticketid};; + if ($ref->{status} eq 'D') { + syslog('info', "quarantined mail '$id' ($path) has already been delivered or marked as " . + "deleted for $pmail"); + return 1; + } + eval { my $sth = $dbh->prepare( "UPDATE CMSReceivers SET Status='D', MTime = ? WHERE " . -- 2.39.5 From s.ivanov at proxmox.com Thu Jan 30 12:21:38 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 12:21:38 +0100 Subject: [pmg-devel] [PATCH pmg-api 0/2] fix #6126 and improve logging for quarantine actions Message-ID: <20250130112140.42219-1-s.ivanov@proxmox.com> while reproducing #6126 (thanks to Hannes D. for pointing me there) I remembered a few other cases where users were asking how to find out what happened to a particular mail that was quarantined. Changes were minimally tested on my setup. Stoiko Ivanov (2): quarantine: add receiver to delivery/delete log-messages fix #6126: do not deliver/delete quarantined mails multiple times src/PMG/Quarantine.pm | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) -- 2.39.5 From s.ivanov at proxmox.com Thu Jan 30 12:21:39 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 12:21:39 +0100 Subject: [pmg-devel] [PATCH pmg-api 1/2] quarantine: add receiver to delivery/delete log-messages In-Reply-To: <20250130112140.42219-1-s.ivanov@proxmox.com> References: <20250130112140.42219-1-s.ivanov@proxmox.com> Message-ID: <20250130112140.42219-2-s.ivanov@proxmox.com> The question: "What happened to the quarantined e-mail" comes up every now and then in our support-channels, and it always feels a bit clumsy to explain how to get to the fitting logline, by referring to the general timeframe when a user did the request and to look for the queue-ids and quarantine-file-ids for identifying a particular mail. Adding the receiver of the mail to the log lines should make this a bit more straight-forward. Signed-off-by: Stoiko Ivanov --- src/PMG/Quarantine.pm | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/PMG/Quarantine.pm b/src/PMG/Quarantine.pm index d06a88d..28138ba 100644 --- a/src/PMG/Quarantine.pm +++ b/src/PMG/Quarantine.pm @@ -128,12 +128,12 @@ sub deliver_quarantined_mail { }; my $err = $@; if ($err) { - my $msg = "deliver quarantined mail '$id' ($path) failed: $err"; + my $msg = "deliver quarantined mail '$id' ($path) to $receiver failed: $err"; syslog('err', $msg); die "$msg\n"; } - syslog('info', "delivered quarantined mail '$id' ($path)"); + syslog('info', "delivered quarantined mail '$id' ($path) to $receiver"); return 1; } @@ -141,6 +141,7 @@ sub deliver_quarantined_mail { sub delete_quarantined_mail { my ($dbh, $ref) = @_; + my $pmail = $ref->{pmail}; my $filename = $ref->{file}; my $spooldir = $PMG::MailQueue::spooldir; my $path = "$spooldir/$filename"; @@ -155,12 +156,12 @@ sub delete_quarantined_mail { $sth->finish; }; if (my $err = $@) { - my $msg = "delete quarantined mail '$id' ($path) failed: $err"; + my $msg = "delete quarantined mail '$id' ($path) for $pmail failed: $err"; syslog ('err', $msg); die "$msg\n"; } - syslog ('info', "marked quarantined mail '$id' as deleted ($path)"); + syslog ('info', "marked quarantined mail '$id' as deleted ($path) for $pmail"); return 1; } -- 2.39.5 From s.ivanov at proxmox.com Thu Jan 30 13:33:50 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 13:33:50 +0100 Subject: [pmg-devel] [PATCH pmg-api 2/2] pmg7to8: add check for deprecated default entries in ruledb In-Reply-To: <20250130123350.50043-1-s.ivanov@proxmox.com> References: <20250130123350.50043-1-s.ivanov@proxmox.com> Message-ID: <20250130123350.50043-3-s.ivanov@proxmox.com> with a new section for future checks of the rule database. Signed-off-by: Stoiko Ivanov --- src/PMG/CLI/pmg7to8.pm | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/PMG/CLI/pmg7to8.pm b/src/PMG/CLI/pmg7to8.pm index d0a6cbe..4e11b6b 100644 --- a/src/PMG/CLI/pmg7to8.pm +++ b/src/PMG/CLI/pmg7to8.pm @@ -13,6 +13,7 @@ use PMG::API2::APT; use PMG::API2::Certificates; use PMG::API2::Cluster; use PMG::RESTEnvironment; +use PMG::RuleDB; use PMG::Utils; use Term::ANSIColor; @@ -526,6 +527,23 @@ sub check_dkms_modules { } } +sub check_ruledb { + log_info("Check the rulesystem..."); + + my $rdb = PMG::RuleDB->new(); + my $ogroups = $rdb->load_objectgroups("who"); + for my $who ($ogroups->@*) { + my $group_name = $who->{name}; + next if ($group_name ne 'Blacklist' && $group_name ne 'Whitelist'); + my $objects = $rdb->load_group_objects($who->{id}); + for my $obj ($objects->@*) { + log_warn("deprecated default entry in '$group_name' present: $obj->{address}") + if ($obj->{address} =~ m/(?:no)?mail\@fromthisdomain.com/); + } + } + return; +} + sub check_misc { print_header("MISCELLANEOUS CHECKS"); my $ssh_config = eval { PVE::Tools::file_get_contents('/root/.ssh/config') }; @@ -637,6 +655,7 @@ __PACKAGE__->register_method ({ code => sub { my ($param) = @_; + check_ruledb(); check_pmg_packages(); check_cluster_status(); my $upgraded_db = check_running_postgres(); -- 2.39.5 From s.ivanov at proxmox.com Thu Jan 30 13:33:48 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 13:33:48 +0100 Subject: [pmg-devel] [PATCH pmg-api 0/2] change sample-entries in default Who-Objects and check in pmg7to8 Message-ID: <20250130123350.50043-1-s.ivanov@proxmox.com> The issue was originally reported in our community-forum: https://forum.proxmox.com/threads/.158455/ Stoiko Ivanov (2): fix #5972: ruledb: default ruleset: use .example as TLD pmg7to8: add check for deprecated default entries in ruledb src/PMG/CLI/pmg7to8.pm | 19 +++++++++++++++++++ src/PMG/DBTools.pm | 4 ++-- src/tests/testdb.txt | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) -- 2.39.5 From s.ivanov at proxmox.com Thu Jan 30 13:33:49 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 13:33:49 +0100 Subject: [pmg-devel] [PATCH pmg-api 1/2] fix #5972: ruledb: default ruleset: use .example as TLD In-Reply-To: <20250130123350.50043-1-s.ivanov@proxmox.com> References: <20250130123350.50043-1-s.ivanov@proxmox.com> Message-ID: <20250130123350.50043-2-s.ivanov@proxmox.com> following RFC 2606 https://www.rfc-editor.org/rfc/rfc2606.html reported in our community forum: https://forum.proxmox.com/threads/.158455/ Signed-off-by: Stoiko Ivanov --- src/PMG/DBTools.pm | 4 ++-- src/tests/testdb.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PMG/DBTools.pm b/src/PMG/DBTools.pm index 8770d06..1acc0cb 100644 --- a/src/PMG/DBTools.pm +++ b/src/PMG/DBTools.pm @@ -644,12 +644,12 @@ sub init_ruledb { # WHO Objects # Blacklist - my $obj = PMG::RuleDB::EMail->new ('nomail at fromthisdomain.com'); + my $obj = PMG::RuleDB::EMail->new ('nomail at fromthisdomain.example'); my $blacklist = $ruledb->create_group_with_obj( $obj, 'Blacklist', 'Global blacklist'); # Whitelist - $obj = PMG::RuleDB::EMail->new('mail at fromthisdomain.com'); + $obj = PMG::RuleDB::EMail->new('mail at fromthisdomain.example'); my $whitelist = $ruledb->create_group_with_obj($obj, 'Whitelist', 'Global whitelist'); # WHEN Objects diff --git a/src/tests/testdb.txt b/src/tests/testdb.txt index 794aa15..2c4f062 100644 --- a/src/tests/testdb.txt +++ b/src/tests/testdb.txt @@ -1,6 +1,6 @@ Found RULE 4: Blacklist FOUND FROM GROUP 1: Blacklist - OBJECT 1: nomail at fromthisdomain.com + OBJECT 1: nomail at fromthisdomain.example FOUND ACTION GROUP 17: Block OBJECT 30: block message Found RULE 2: Block Viruses @@ -49,7 +49,7 @@ Found RULE 11: Block Multimedia Files OBJECT 27: remove matching attachments Found RULE 5: Whitelist FOUND FROM GROUP 2: Whitelist - OBJECT 2: mail at fromthisdomain.com + OBJECT 2: mail at fromthisdomain.example FOUND ACTION GROUP 16: Accept OBJECT 29: accept message Found RULE 8: Block Spam (Level 10) -- 2.39.5 From s.ivanov at proxmox.com Thu Jan 30 14:00:34 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 14:00:34 +0100 Subject: [pmg-devel] applied: [PATCH pmg-docs v2] fix some typos In-Reply-To: <20241114100336.101411-1-m.sandoval@proxmox.com> References: <20241114100336.101411-1-m.sandoval@proxmox.com> Message-ID: applied the patch - Thanks! On Thu, Nov 14, 2024 at 11:03:36AM +0100, Maximiliano Sandoval wrote: > Signed-off-by: Maximiliano Sandoval > --- > Differences from v1: > - Remove fixes on automatically generated documentation (pmg.conf.5-opts.adoc). > > pmgconfig.adoc | 2 +- > system-booting.adoc | 2 +- > 2 files changed, 2 insertions(+), 2 deletions(-) > > diff --git a/pmgconfig.adoc b/pmgconfig.adoc > index a9377c5..0342b97 100644 > --- a/pmgconfig.adoc > +++ b/pmgconfig.adoc > @@ -1101,7 +1101,7 @@ WebAuthn Configuration > > //[thumbnail="screenshot/gui-datacenter-webauthn-edit.png"]//TODO > > -To allow users to use 'WebAuthn' authentication, it is necessaary to use a valid > +To allow users to use 'WebAuthn' authentication, it is necessary to use a valid > domain with a valid SSL certificate, otherwise some browsers may warn or refuse > to authenticate altogether. > > diff --git a/system-booting.adoc b/system-booting.adoc > index af5b767..e8ac76e 100644 > --- a/system-booting.adoc > +++ b/system-booting.adoc > @@ -356,7 +356,7 @@ To remove any pinned version configuration use the `unpin` subcommand: > > While `unpin` has a `--next-boot` option as well, it is used to clear a pinned > version set with `--next-boot`. As that happens already automatically on boot, > -invonking it manually is of little use. > +invoking it manually is of little use. > > After setting, or clearing pinned versions you also need to synchronize the > content and configuration on the ESPs by running the `refresh` subcommand. > -- > 2.39.5 > > > > _______________________________________________ > pmg-devel mailing list > pmg-devel at lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pmg-devel > > From s.ivanov at proxmox.com Thu Jan 30 14:01:13 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 14:01:13 +0100 Subject: [pmg-devel] applied-series: [PATCH pmg-docs 0/2] improve faq upgrade section In-Reply-To: <20241022112426.709562-1-c.heiss@proxmox.com> References: <20241022112426.709562-1-c.heiss@proxmox.com> Message-ID: applied both patches - thanks! On Tue, Oct 22, 2024 at 01:24:23PM +0200, Christoph Heiss wrote: > First, splits up the upgrade section into two, one for point release > upgrades and one for major release upgrades. This makes the difference > stand out better, and we have the same in pve-docs too. > > Secondly, fixes #5771 by adding the missing link for the 7->8 upgrade > instructions in the wiki. > > Christoph Heiss (2): > faq: split up upgrade section into major and point release upgrades > fix #5771: faq: add 7 to 8 upgrade link > > pmg-faq.adoc | 19 +++++++++++++++---- > 1 file changed, 15 insertions(+), 4 deletions(-) > > -- > 2.46.0 > > > > _______________________________________________ > pmg-devel mailing list > pmg-devel at lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pmg-devel > > From s.ivanov at proxmox.com Thu Jan 30 14:02:28 2025 From: s.ivanov at proxmox.com (Stoiko Ivanov) Date: Thu, 30 Jan 2025 14:02:28 +0100 Subject: [pmg-devel] applied: [PATCH docs] installation: add section about unattended/automatic installation In-Reply-To: <20240418160949.368712-1-a.lauterer@proxmox.com> References: <20240418160949.368712-1-a.lauterer@proxmox.com> Message-ID: applied the patch after aligning it with the version applied in pve-docs, and replacing 'on bare-metal' with 'with the ISO' as this sounded a bit more fitting for PMG Thanks! On Thu, Apr 18, 2024 at 06:09:49PM +0200, Aaron Lauterer wrote: > Mention and briefly explain it. The main part of the documentation will > live in the Wiki for now as it applies to not just Proxmox Mail Gateway. > > Signed-off-by: Aaron Lauterer > --- > pmg-installation.adoc | 20 ++++++++++++++++++++ > 1 file changed, 20 insertions(+) > > diff --git a/pmg-installation.adoc b/pmg-installation.adoc > index 25d16a7..5cc8fbe 100644 > --- a/pmg-installation.adoc > +++ b/pmg-installation.adoc > @@ -305,6 +305,26 @@ parameter. > > Then press `Ctrl-X` or `F10` to boot the configuration. > > + > +[[pmg_install_unattended]] > +Unattended Installation > +----------------------- > + > +It is possible to install {pmg} automatically in an unattended manner. This > +makes it possible to fully automate the setup of {pmg} on bare-metal. Once the > +installation is done and the host has booted up, automation tools like Ansible > +can be used to further configure the installation. > + > +The options for the installer have to be provided in an answer file. This > +answer file allows using filter rules to decide which disks and network card > +should be used. It is also possible to run custom commands in the installation > +environment prior and after the installation. > + > +It is necessary to prepare the installation ISO to be usable for the automated > +installation. https://pve.proxmox.com/wiki/Automated_Installation[Visit our wiki] > +for more details and information on the unattended installation. > + > + > [[pmg_install_on_debian]] > Install {pmg} on Debian > ----------------------- > -- > 2.39.2 > > > > _______________________________________________ > pmg-devel mailing list > pmg-devel at lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pmg-devel > >