diff --git a/.gitignore b/.gitignore index c6d4e8ea..ed493673 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,11 @@ /.idea /vendor /composer.lock +/coverage +/composer.phar +/composer_2.phar +/.php-cs-fixer.cache +/.phpunit.result.cache +/build +/tests/postfixadmin.sqlite.*.test +/phpstorm-docker-dev/ diff --git a/DOCUMENTS/OPENDKIM.txt b/DOCUMENTS/OPENDKIM.txt new file mode 100644 index 00000000..0d0fa6d3 --- /dev/null +++ b/DOCUMENTS/OPENDKIM.txt @@ -0,0 +1,37 @@ +# +# OpenDKIM configuration for Postfix Admin +# Originally written by: Fredrik Falk +# + +More complete OpenDKIM documentation: + +http://www.opendkim.org/opendkim.8.html +http://www.opendkim.org/opendkim.conf.5.html +http://www.opendkim.org/opendkim-genkey.8.html +https://github.com/trusteddomainproject/OpenDKIM/blob/master/opendkim/README.SQL + +Here are the relevant parts of OpenDKIM v2.11.x configuration for Postfixadmin setup. + +Please refer to OpenDKIM documentation for complete information. + +The setup gets KeyTable and SigningTable info from MySQL, allowing domain admins to edit and add domain keys as well as +assign which authors will use them. + + +1. PostfixAdmin Setup +----------------- + +Add `$CONF['dkim'] = 'YES';` to your `config.local.php`, and optionally ``$CONF['dkim_all_admins'] = 'YES';` to allow +non-super-admins to add domain keys and signtable entries. + + +2. OpenDKIM setup +----------------- + +Ensure that the version of OpenDKIM supports databases/OpenDBX. +After that, simply add the following to your /etc/opendkim.conf: +``` +SigningTable dsn:mysql://{USER}:{PASSWORD}@{HOST}/{DATABASE}/table=dkim_signing?keycol=author?datacol=dkim_id +KeyTable dsn:mysql://{USER}:{PASSWORD}@{HOST}/{DATABASE}/table=dkim?keycol=id?datacol=domain_name,selector,private_key +``` +Replace {USER}, {PASSWORD}, {HOST}, and {DATABASE} with your values. diff --git a/DOCUMENTS/screenshots/dkim-add-domain-key.png b/DOCUMENTS/screenshots/dkim-add-domain-key.png new file mode 100644 index 00000000..5e749e1c Binary files /dev/null and b/DOCUMENTS/screenshots/dkim-add-domain-key.png differ diff --git a/DOCUMENTS/screenshots/dkim-add-sign-table-entry.png b/DOCUMENTS/screenshots/dkim-add-sign-table-entry.png new file mode 100644 index 00000000..b01add82 Binary files /dev/null and b/DOCUMENTS/screenshots/dkim-add-sign-table-entry.png differ diff --git a/config.inc.php b/config.inc.php index 34b2e291..0ae3942f 100644 --- a/config.inc.php +++ b/config.inc.php @@ -142,6 +142,8 @@ $CONF['database_tables'] = array ( 'vacation_notification' => 'vacation_notification', 'quota' => 'quota', 'quota2' => 'quota2', + 'dkim' => 'dkim', + 'dkim_signing' => 'dkim_signing', ); // Site Admin @@ -357,6 +359,8 @@ $CONF['alias_struct_hook'] = ''; $CONF['mailbox_struct_hook'] = ''; $CONF['alias_domain_struct_hook'] = ''; $CONF['fetchmail_struct_hook'] = ''; +$CONF['dkim_struct_hook'] = ''; +$CONF['dkim_signing_struct_hook'] = ''; // Default Domain Values @@ -524,6 +528,21 @@ EOM; // address is legal by performing a name server look-up. $CONF['emailcheck_resolve_domain']='YES'; +// +// +// OpenDKIM stuff +// +// + +// Enable the dkim database component +$CONF['dkim'] = 'NO'; + +// Allow regular admins to add/edit/remove dkim entries +$CONF['dkim_all_admins'] = 'NO'; + +// +// End OpenDKIM stuff +// // Optional: // Analyze alias gotos and display a colored block in the first column diff --git a/configs/menu.conf b/configs/menu.conf index 55ae6b91..a5b9453f 100644 --- a/configs/menu.conf +++ b/configs/menu.conf @@ -17,6 +17,11 @@ url_fetchmail_new_entry = edit.php?table=fetchmail # sendmail url_sendmail = sendmail.php url_broadcast_message = broadcast-message.php +# dkim +url_dkim = list.php?table=dkim +url_dkim_signing = list.php?table=dkimsigning +url_dkim_newkey = edit.php?table=dkim +url_dkim_newsign = edit.php?table=dkimsigning # password url_password = edit.php?table=adminpassword # backup diff --git a/languages/en.lang b/languages/en.lang index 6b4b206e..7b6a20e7 100644 --- a/languages/en.lang +++ b/languages/en.lang @@ -18,6 +18,7 @@ $PALANG['confirm_delete_admin'] = 'Do you really want to delete the admin %s?'; $PALANG['confirm_delete_alias'] = 'Do you really want to delete the alias %s?'; $PALANG['confirm_delete_aliasdomain'] = 'Do you really want to delete the alias domain %s?'; $PALANG['confirm_delete_domain'] = 'Do you really want to delete all records for the domain %s? This can not be undone!'; +$PALANG['confirm_delete_dkim'] = 'Do you really want to delete the domain key entry %s?'; $PALANG['confirm_delete_fetchmail'] = 'Do you really want to delete the fetchmail job %s?'; $PALANG['confirm_delete_mailbox'] = 'Do you really want to delete the mailbox %s?'; $PALANG['confirm_delete_vacation'] = 'Do you really want to delete the vacation message for %s?'; @@ -54,6 +55,8 @@ $PALANG['add_alias_domain'] = 'Add Alias Domain'; $PALANG['add_mailbox'] = 'Add Mailbox'; $PALANG['pMenu_fetchmail'] = 'Fetch Email'; $PALANG['pMenu_sendmail'] = 'Send Email'; +$PALANG['pMenu_dkim'] = 'Domain Keys'; +$PALANG['pMenu_dkim_signing'] = 'Signing Table'; $PALANG['pMenu_password'] = 'Password'; $PALANG['pMenu_viewlog'] = 'View Log'; $PALANG['pMenu_logout'] = 'Logout'; @@ -249,6 +252,33 @@ $PALANG['pSendmail_button'] = 'Send Message'; $PALANG['pSendmail_result_error'] = 'Unable to send email to %s!'; $PALANG['pSendmail_result_success'] = 'Email sent to %s.'; +$PALANG['pDkim_new_key'] = 'Add Domain Key'; +$PALANG['pDkim_new_sign'] = 'Add Sign Table Entry'; +$PALANG['pDkim_edit_key'] = 'Edit Domain Key'; +$PALANG['pDkim_edit_sign'] = 'Edit Sign Table Entry'; +$PALANG['pDkim_field_selector'] = 'Selector'; +$PALANG['pDkim_field_pkey'] = 'Private Key'; +$PALANG['pDkim_field_pub'] = 'Public Key'; +$PALANG['pDkim_field_author'] = 'Author'; +$PALANG['pDkim_field_dkim_id'] = 'Domain Key'; +$PALANG['pDkim_field_selector_desc'] = 'Defines the name of the selector to be used when signing messages'; +$PALANG['pDkim_field_domain_desc'] = 'Defines the domain which will use this key for signing'; +$PALANG['pDkim_field_pkey_desc'] = 'PEM-formatted private key to be used for signing all messages'; +$PALANG['pDkim_field_pub_desc'] = 'PEM-formatted public key'; +$PALANG['pDkim_field_author_desc'] = 'Author that will use this key for signing. Can be either mailbox or domain, with mailbox taking precedence.'; +$PALANG['pDkim_field_dkim_id_desc'] = 'Domain Key that this author will use'; +$PALANG['dkim_already_exists'] = 'This Domain Key has already been added!'; +$PALANG['dkim_does_not_exist'] = 'This Domain Key does not exist!'; +$PALANG['dkim_signing_already_exists'] = 'This Domain Signing Entry already exsits!'; +$PALANG['dkim_signing_does_not_exist'] = 'This Domain Signing Entry does not exist!'; +$PALANG['pViewlog_action_create_dkim_entry'] = 'create domain key entry'; +$PALANG['pViewlog_action_edit_dkim_entry'] = 'edit domain key entry'; +$PALANG['pViewlog_action_delete_dkim_entry'] = 'delete domain key entry'; +$PALANG['pViewlog_action_create_dkim_signing_entry'] = 'create domain signing entry'; +$PALANG['pViewlog_action_edit_dkim_signing_entry'] = 'edit domain signing entry'; +$PALANG['pViewlog_action_delete_dkim_signing_entry'] = 'delete domain signing entry'; +$PALANG['pMain_dkim'] = 'Add a Domain Key for use with OpenDKIM.'; + $PALANG['pAdminMenu_list_admin'] = 'Admin List'; $PALANG['pAdminMenu_list_domain'] = 'Domain List'; $PALANG['pAdminMenu_list_virtual'] = 'Virtual List'; diff --git a/model/DkimHandler.php b/model/DkimHandler.php new file mode 100644 index 00000000..182ccded --- /dev/null +++ b/model/DkimHandler.php @@ -0,0 +1,102 @@ +struct=array( + # field name allow display in... type $PALANG label $PALANG description default / options / ... + # editing? form list + 'id' => pacol(0, 0, 1, 'num' , 'pFetchmail_field_id' , '' , + '', array(), array('dont_write_to_db' => 1) ), + 'description' => pacol(1, 1, 1, 'text' , 'description' , '' ), + 'selector' => pacol(1, 1, 1, 'text' , 'pDkim_field_selector','pDkim_field_selector_desc'), + 'domain_name' => pacol(1, 1, 1, 'enum' , 'domain' , 'pDkim_field_domain_desc' , + '', $this->allowed_domains), + 'private_key' => pacol(1, 1, 0, 'txtlarge', 'pDkim_field_pkey' , 'pDkim_field_pkey_desc' ), + 'public_key' => pacol(1, 1, 0, 'txtlarge', 'pDkim_field_pub' , 'pDkim_field_pub_desc' ), + ); + } + + protected function initMsg() + { + $this->msg['error_already_exists'] = 'dkim_already_exists'; + $this->msg['error_does_not_exist'] = 'dkim_does_not_exist'; + $this->msg['confirm_delete'] = 'confirm_delete_dkim'; + + if ($this->new) { + $this->msg['logname'] = 'create_dkim_entry'; + $this->msg['store_error'] = 'pFetchmail_database_save_error'; + $this->msg['successmessage'] = 'pFetchmail_database_save_success'; + } else { + $this->msg['logname'] = 'edit_dkim_entry'; + $this->msg['store_error'] = 'pFetchmail_database_save_error'; + $this->msg['successmessage'] = 'pFetchmail_database_save_success'; + } + } + + public function webformConfig() + { + $required_role = 'global-admin'; + if (Config::bool('dkim_all_admins')) + $required_role = 'admin'; + + return array( + # $PALANG labels + 'formtitle_create' => 'pDkim_new_key', + 'formtitle_edit' => 'pDkim_edit_key', + 'create_button' => 'pFetchmail_new_entry', + + # various settings + 'required_role' => $required_role, + 'listview' => 'list.php?table=dkim', + 'early_init' => 0, + ); + } + protected function validate_new_id() + { + # auto_increment - any non-empty ID is an error + if ($this->id != '') { + $this->errormsg[$this->id_field] = 'auto_increment value, you must pass an empty string!'; + return false; + } + + return true; + } + + /** + * @return boolean + */ + public function delete() + { + if (! $this->view()) { + $this->errormsg[] = Config::Lang($this->msg['error_does_not_exist']); + return false; + } + + db_delete('dkim', $this->id_field, $this->id); + db_delete($this->db_table, $this->id_field, $this->id); + + db_log($this->result['domain_name'], 'delete_dkim_entry', $this->result['label']); + $this->infomsg[] = Config::Lang_f('pDelete_delete_success', $this->result['label']); + return true; + } + + public function domain_from_id() + { + return ''; + } +} + +/* vim: set expandtab softtabstop=4 tabstop=4 shiftwidth=4: */ diff --git a/model/DkimsigningHandler.php b/model/DkimsigningHandler.php new file mode 100644 index 00000000..39f3bbe6 --- /dev/null +++ b/model/DkimsigningHandler.php @@ -0,0 +1,140 @@ +admin_username); + $domain_handler->getList('1=1'); + + // Get Mailboxes as options for authors + $mail_handler = new MailboxHandler(0, $this->admin_username); + $mail_handler->getList('1=1'); + + // Get Domain Keys + $dkim_handler = new DkimHandler(0, $this->admin_username); + $dkim_handler->getList('1=1'); + + $authors = array_merge( + array_keys($domain_handler->result()), + array_keys($mail_handler->result()) + ); + + $this->struct=array( + # field name allow display in... type $PALANG label $PALANG description default / options / ... + # editing? form list + 'id' => pacol(0, 0, 1, 'num' , 'pFetchmail_field_id' , '' , + '', array(), array('dont_write_to_db' => 1) ), + 'dkim_id' => pacol(1, 1, 1, 'enum' , 'pDkim_field_dkim_id' , 'pDkim_field_dkim_id_desc' , + '', array_keys($dkim_handler->result)), + 'author' => pacol(1, 1, 1, 'enum' , 'pDkim_field_author' , 'pDkim_field_author_desc' , + '', $authors), + ); + } + + protected function initMsg() + { + $this->msg['error_already_exists'] = 'dkim_signing_already_exists'; + $this->msg['error_does_not_exist'] = 'dkim_signing_does_not_exist'; + $this->msg['confirm_delete'] = 'confirm_delete_dkim'; + + if ($this->new) { + $this->msg['logname'] = 'create_dkim_signing_entry'; + $this->msg['store_error'] = 'pFetchmail_database_save_error'; + $this->msg['successmessage'] = 'pFetchmail_database_save_success'; + } else { + $this->msg['logname'] = 'edit_dkim_entry'; + $this->msg['store_error'] = 'pFetchmail_database_save_error'; + $this->msg['successmessage'] = 'pFetchmail_database_save_success'; + } + } + + public function webformConfig() + { + $required_role = 'global-admin'; + if (Config::bool('dkim_all_admins')) + $required_role = 'admin'; + + return array( + # $PALANG labels + 'formtitle_create' => 'pDkim_new_sign', + 'formtitle_edit' => 'pDkim_edit_sign', + 'create_button' => 'pFetchmail_new_entry', + + # various settings + 'required_role' => $required_role, + 'listview' => 'list.php?table=dkimsigning', + 'early_init' => 0, + ); + } + protected function validate_new_id() + { + # auto_increment - any non-empty ID is an error + if ($this->id != '') { + $this->errormsg[$this->id_field] = 'auto_increment value, you must pass an empty string!'; + return false; + } + + return true; + } + + /** + * @return boolean + */ + public function delete() + { + if (! $this->view()) { + $this->errormsg[] = Config::Lang($this->msg['error_does_not_exist']); + return false; + } + + db_delete('dkim_signing', $this->id_field, $this->id); + db_delete($this->db_table, $this->id_field, $this->id); + + db_log($this->result['author'], 'delete_dkim_signing_entry', $this->result['id']); + $this->infomsg[] = Config::Lang_f('pDelete_delete_success', $this->result['author']); + return true; + } + + public function domain_from_id() + { + return ''; + } + + protected function no_domain_field() { + $domain_handler = new DomainHandler(0, $this->admin_username); + $domain_handler->getList('1=1'); + + $this->allowed_domains = array_keys($domain_handler->result()); + } + + /** + * Filters to only allowed domains, as author can be either a mailbox or a domain + * @param $db_result + * @return array + */ + protected function read_from_db_postprocess($db_result) + { + return array_filter($db_result, function($row) { + $domain = $row['author']; + $at_pos = strpos($domain, '@'); + + if ($at_pos) + $domain = preg_split('/@/', $domain)[1]; + + return in_array($domain, $this->allowed_domains); + }); + } +} + +/* vim: set expandtab softtabstop=4 tabstop=4 shiftwidth=4: */ diff --git a/model/PFAHandler.php b/model/PFAHandler.php index 1322b8fd..dedc016e 100644 --- a/model/PFAHandler.php +++ b/model/PFAHandler.php @@ -286,6 +286,7 @@ abstract class PFAHandler * pass password (will be encrypted with pacrypt()) * b64p password (will be stored with base64_encode() - but will NOT be decoded automatically) * num number + * txtlarge Large text input field * txtl text "list" - array of one line texts * *vnum "virtual" number, coming from JOINs etc. * bool boolean (converted to 0/1, additional column _$field with yes/no) diff --git a/model/PFASmarty.php b/model/PFASmarty.php index 1a29fe40..7c5d9cb5 100644 --- a/model/PFASmarty.php +++ b/model/PFASmarty.php @@ -113,6 +113,8 @@ class PFASmarty $this->assign('url_domain', ''); $this->assign('version', $CONF['version'] ?? 'unknown'); $this->assign('boolconf_alias_domain', Config::bool('alias_domain')); + $this->assign('boolconf_dkim', Config::bool('dkim')); + $this->assign('boolconf_dkim_all_admins', Config::bool('dkim_all_admins')); $this->assign('authentication_has_role', array('global_admin' => authentication_has_role('global-admin'), 'admin' => authentication_has_role('admin'), 'user' => authentication_has_role('user'))); header("Expires: Sun, 16 Mar 2003 05:00:00 GMT"); diff --git a/public/backup.php b/public/backup.php index 9ec622dc..92e30b9b 100644 --- a/public/backup.php +++ b/public/backup.php @@ -86,20 +86,22 @@ if ($_SERVER['REQUEST_METHOD'] == "GET") { fwrite($fh, $header); $tables = array( - 'admin', - 'alias', - 'alias_domain', - 'config', - 'domain', - 'domain_admins', - 'fetchmail', - 'log', - 'mailbox', - 'quota', - 'quota2', - 'vacation', - 'vacation_notification' - ); + 'admin', + 'alias', + 'alias_domain', + 'config', + 'domain', + 'domain_admins', + 'fetchmail', + 'log', + 'mailbox', + 'quota', + 'quota2', + 'vacation', + 'vacation_notification', + 'dkim', + 'dkim_signing' + ); for ($i = 0 ; $i < sizeof($tables) ; ++$i) { $result = db_query_all("SHOW CREATE TABLE " . table_by_key($tables[$i])); diff --git a/public/upgrade.php b/public/upgrade.php index 8232314e..16ce33af 100644 --- a/public/upgrade.php +++ b/public/upgrade.php @@ -2130,3 +2130,81 @@ function upgrade_1846_mysql() db_query("ALTER TABLE $domain_admins MODIFY `domain` varchar(255) COLLATE latin1_general_ci NOT NULL"); db_query("ALTER TABLE $domain_admins MODIFY username varchar(255) COLLATE latin1_general_ci NOT NULL"); } + +/** + * Add DKIM tables + * @return void + */ +function upgrade_1847_mysql_pgsql() +{ + $dkim_key_table = table_by_key('dkim'); + $dkim_signing_table = table_by_key('dkim_signing'); + $domain_table = table_by_key('domain'); + + db_query_parsed(" + CREATE TABLE {IF_NOT_EXISTS} $dkim_key_table ( + `id` {AUTOINCREMENT} {PRIMARY}, + `domain_name` varchar(255) NOT NULL, + `description` varchar(255) DEFAULT '', + `selector` varchar(63) NOT NULL DEFAULT 'default', + `private_key` text, + `public_key` text, + `created` {DATETIME}, + `modified` {DATETIME}, + INDEX(domain_name, description), + FOREIGN KEY (`domain_name`) + REFERENCES $domain_table(`domain`) + ON DELETE CASCADE) {COLLATE} COMMENT='Postfix Admin - OpenDKIM Key Table'; + "); + + db_query_parsed(" + CREATE TABLE {IF_NOT_EXISTS} $dkim_signing_table ( + `id` {AUTOINCREMENT} {PRIMARY}, + `author` varchar(255) NOT NULL DEFAULT '', + `dkim_id` integer NOT NULL, + `created` {DATETIME}, + `modified` {DATETIME}, + INDEX(author), + FOREIGN KEY (`dkim_id`) + REFERENCES $dkim_key_table(`id`) + ON DELETE CASCADE) {COLLATE} COMMENT='Postfix Admin - OpenDKIM Signing Table'; + "); +} + +/** + * Add DKIM tables + * @return void + */ +function upgrade_1847_sqlite() +{ + $dkim_key_table = table_by_key('dkim'); + $dkim_signing_table = table_by_key('dkim_signing'); + $domain_table = table_by_key('domain'); + + db_query_parsed(" + CREATE TABLE {IF_NOT_EXISTS} $dkim_key_table ( + `id` {AUTOINCREMENT}, + `domain_name` varchar(255) NOT NULL, + `description` varchar(255) DEFAULT '', + `selector` varchar(63) NOT NULL DEFAULT 'default', + `private_key` text, + `public_key` text, + `created` {DATETIME}, + `modified` {DATETIME}, + FOREIGN KEY (`domain_name`) + REFERENCES $domain_table(`domain`) + ON DELETE CASCADE) {COLLATE}; + "); + + db_query_parsed(" + CREATE TABLE {IF_NOT_EXISTS} $dkim_signing_table ( + `id` {AUTOINCREMENT}, + `author` varchar(255) NOT NULL DEFAULT '', + `dkim_id` integer NOT NULL, + `created` {DATETIME}, + `modified` {DATETIME}, + FOREIGN KEY (`dkim_id`) + REFERENCES $dkim_key_table(`id`) + ON DELETE CASCADE) {COLLATE}; + "); +} \ No newline at end of file diff --git a/templates/editform.tpl b/templates/editform.tpl index 7050cf40..017b42a9 100644 --- a/templates/editform.tpl +++ b/templates/editform.tpl @@ -62,6 +62,8 @@ {elseif $field.type == 'txtl'} + {elseif $field.type == 'txtlarge'} + {else} diff --git a/templates/main.tpl b/templates/main.tpl index 71241b2b..bce91389 100644 --- a/templates/main.tpl +++ b/templates/main.tpl @@ -24,19 +24,29 @@ {$PALANG.pMain_sendmail} {/if} + {if $CONF.dkim==='YES' && ( + $authentication_has_role.global_admin || + (isset($CONF.dkim_all_admins) && $CONF.dkim_all_admins === 'YES') ) + } + + {$PALANG.pMenu_dkim} + {$PALANG.pMain_dkim} + + {/if} {$PALANG.pMenu_password} {$PALANG.pMain_password} - {* viewlog *} + {* viewlog *} {if $CONF.logging==='YES'} {$PALANG.pMenu_viewlog} {$PALANG.pMain_viewlog} - {/if} + {/if} {$PALANG.pMenu_logout} diff --git a/templates/menu.tpl b/templates/menu.tpl index 54b916fc..ad33740c 100644 --- a/templates/menu.tpl +++ b/templates/menu.tpl @@ -106,6 +106,25 @@ {/strip} {/if} + {* dkim *} + {if $CONF.dkim==='YES' && ( + $authentication_has_role.global_admin || + (isset($CONF.dkim_all_admins) && $CONF.dkim_all_admins === 'YES') ) + } + {strip} + + {/strip} + {/if}