diff --git a/classes/dkim_manager.php b/classes/dkim_manager.php new file mode 100644 index 0000000..a20c34a --- /dev/null +++ b/classes/dkim_manager.php @@ -0,0 +1,225 @@ +. + +/** + * DKIM manager + * + * This loads, verifies and can auto create DKIM pairs of certificates + * Code largely adapted from PHPMailer + * + * @package tool_emailutils + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Brendan Heywood + */ + +namespace tool_emailutils; + +/** + * DKIM manager + */ +class dkim_manager { + + /** @var Domain */ + protected $domain; + + /** @var Selector */ + protected $selector; + + /** @var Private key */ + protected $privatekey; + + /** @var Public key */ + protected $publickey; + + /** @var DNS record */ + protected $dnsrecord; + + /** Digest algorythm */ + const DIGEST_ALG = 'sha256'; + + /** + * Create or load the certificates for a domain and selector + * @param string $domain domain + * @param string $selector + * @param bool $autocreate Should this autocreate cert pairs if they don't exist? + */ + public function __construct($domain, $selector, $autocreate = false) { + $this->domain = $domain; + $this->selector = $selector; + + $privatekeyfile = $this->get_private_key_path(); + $publickeyfile = $this->get_public_key_path(); + + if (!file_exists($privatekeyfile) && $autocreate) { + + $this->get_base_path(true); + // Create a 2048-bit RSA key with an SHA256 digest. + $pk = openssl_pkey_new( + [ + 'digest_alg' => self::DIGEST_ALG, + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ] + ); + + // Save both keys. + openssl_pkey_export_to_file($pk, $privatekeyfile); + $details = openssl_pkey_get_details($pk); + file_put_contents($publickeyfile, $details['key']); + } + + $this->privatekey = file_get_contents($privatekeyfile); + $this->publickey = file_get_contents($publickeyfile); + } + + /** + * Get the domain file path + */ + public function get_domain_path() { + global $CFG; + return $CFG->dataroot . '/dkim/' . $this->domain . '/'; + } + + /** + * Get the domain file path + * @param bool $create auto create the directories + */ + public function get_base_path($create = false) { + $certdir = $this->get_domain_path(); + if ($create) { + mkdir($certdir, 0777, true); + } + return $certdir . '/' . $this->selector; + } + + /** + * Get the private key file path + */ + public function get_private_key_path() { + return $this->get_base_path() . '.private'; + } + + /** + * Get the public key file path + */ + public function get_public_key_path() { + return $this->get_base_path() . '.public'; + } + + /** + * Get the DNS record file path + */ + public function get_dns_record_path() { + return $this->get_base_path() . '.txt'; + } + + /** + * Get the domain the DKIM record should be stored at + */ + public function get_dns_domain() { + return "{$this->selector}._domainkey.{$this->domain}"; + } + + /** + * Get the key of the DKIM txt record + */ + public function get_dns_key() { + return $this->get_dns_domain() . ' IN TXT'; + } + + /** + * Get the value of the DKIM record + * + * This loads the public key and then stores the DNS record in a file. + */ + public function get_dns_value() { + if (!empty($this->dnsrecord)) { + return $this->dnsrecord; + } + + // TODO add support for records added by open dkim + // These do not include the public key in the normal format, only in the DNS value format. + + if (empty($this->publickey)) { + return "ERROR: Can't find public key"; + } + + $dnsvalue = 'v=DKIM1;'; + $dnsvalue .= ' h=' . self::DIGEST_ALG . ';'; // Hash algorythm. + $dnsvalue .= ' t=s;'; // No sub domains allowed. + $dnsvalue .= ' k=rsa;'; // Key type. + $dnsvalue .= ' p='; // Public key. + + $publickey = $this->publickey; + $publickey = preg_replace('/^-+.*?-+$/m', '', $publickey); // Remove PEM wrapper. + $publickey = str_replace(["\r", "\n"], '', $publickey); // Strip line breaks. + $dnsvalue .= $publickey; + + $this->dnsrecord = trim($dnsvalue); + + return $this->dnsrecord; + } + + /** + * Get a chunked version of the DKIM record + * + * Strip and split the key into smaller parts and format for DNS as many systems + * don't like long TXT entries but are OK if it's split into 255-char chunks. + */ + public function get_dns_value_chunked() { + + $rawvalue = $this->get_dns_value(); + + // Split into chunks. + $keyparts = str_split($rawvalue, 253); // Becomes 255 when quotes are included. + // Quote each chunk. + foreach ($keyparts as $keypart) { + $dnsvalue .= '"' . trim($keypart) . '" '; + } + + return $dnsvalue; + + } + /** + * Get the alternate escaped version of the DKIM record + * + * Some DNS servers don't like ;(semi colon) chars unless backslash-escaped + */ + public function get_dns_value_escaped() { + + $value = $this->get_dns_value_chunked(); + $value = str_replace(';', '\;', $value); + return $value; + + } + + /** + * Delete all info about a selector + */ + public function delete_selector() { + $privatekeyfile = $this->get_private_key_path(); + $publickeyfile = $this->get_public_key_path(); + $dnsrecordfile = $this->get_dns_record_path(); + $domaindir = $this->get_domain_path(); + + @unlink($privatekeyfile); + @unlink($publickeyfile); + @unlink($dnsrecordfile); + @rmdir($domaindir); + } + +} diff --git a/classes/form/create_dkim.php b/classes/form/create_dkim.php new file mode 100644 index 0000000..be070cf --- /dev/null +++ b/classes/form/create_dkim.php @@ -0,0 +1,76 @@ +. + +/** + * Create dkim selector form + * + * @package tool_emailutils + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Brendan Heywood + */ + +namespace tool_emailutils\form; + +defined('MOODLE_INTERNAL') || die; + +require_once("$CFG->libdir/formslib.php"); + +/** + * Selector form + */ +class create_dkim extends \moodleform { + + /** + * Selector + * @see moodleform::definition() + */ + public function definition() { + + global $CFG; + + $mform = $this->_form; + $noreplydomain = substr($CFG->noreplyaddress, strpos($CFG->noreplyaddress, '@') + 1); + + $group = []; + + $group[] =& $mform->createElement('text', 'domain', array("size" => 20)); + $mform->setDefault("domain", $noreplydomain); + $mform->setType('domain', PARAM_HOST); + + $group[] =& $mform->createElement('text', 'selector', array("size" => 20)); + + $selector = \userdate(time(), get_string('selectordefault', 'tool_emailutils')); + $mform->setDefault("selector", $selector); + $mform->setType('selector', PARAM_HOST); + + $mform->addGroup($group, 'selector', get_string('selectorcreate', 'tool_emailutils'), '', false); + + $this->add_action_buttons(true, get_string('selectorcreatesubmit', 'tool_emailutils')); + } + + /** + * Validate + * + * @param mixed $data date + * @param mixed $files files + * @return mixed errors + */ + public function validation($data, $files) { + $errors = parent::validation($data, $files); + return $errors; + } +} diff --git a/dkim.php b/dkim.php new file mode 100644 index 0000000..a4c3b91 --- /dev/null +++ b/dkim.php @@ -0,0 +1,186 @@ +. + +/** + * DKIM manager admin page + * + * @package tool_emailutils + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Brendan Heywood + */ + +use tool_emailutils\dkim_manager; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); + +$baseurl = new moodle_url('/admin/tool/emailutils/dkim.php'); +$PAGE->set_url($baseurl); +admin_externalpage_setup('tool_emailutils_dkim'); + +$action = optional_param('action', '', PARAM_ALPHA); + +if ($action == 'delete') { + require_sesskey(); + $domain = required_param('domain', PARAM_TEXT); + $selector = required_param('selector', PARAM_TEXT); + $manager = new dkim_manager($domain, $selector); + $manager->delete_selector(); + redirect($baseurl, get_string('selectordeleted', 'tool_emailutils'), null, \core\output\notification::NOTIFY_SUCCESS); +} + +if ($action == 'activate') { + require_sesskey(); + $selector = required_param('selector', PARAM_TEXT); + add_to_config_log('emaildkimselector', $CFG->emaildkimselector, $selector, ''); + set_config('emaildkimselector', $selector); + redirect($baseurl, get_string('selectoractivated', 'tool_emailutils'), null, \core\output\notification::NOTIFY_SUCCESS); +} + +$form = new \tool_emailutils\form\create_dkim(); +if ($form->is_cancelled()) { + redirect($prevurl); +} else if ($fromform = $form->get_data()) { + + $domain = $fromform->domain; + $selector = $fromform->selector; + $manager = new dkim_manager($domain, $selector, true); + redirect($baseurl, get_string('selectorcreated', 'tool_emailutils'), null, \core\output\notification::NOTIFY_SUCCESS); +} + +$dkimdir = $CFG->dataroot . '/dkim/'; +$domains = scandir($dkimdir); +$domaincount = 0; +$noreplydomain = substr($CFG->noreplyaddress, strpos($CFG->noreplyaddress, '@') + 1); + +print $OUTPUT->header(); +print $OUTPUT->heading(get_string('dkimmanager', 'tool_emailutils')); + +print ""; +print ''; +foreach ($domains as $domain) { + + if (substr($domain, 0, 1) == '.') { + continue; + } + if (!is_dir($dkimdir . $domain)) { + continue; + } + + $domaincount ++; + + print ''; + print ''; + + + $selectors = scandir($dkimdir . $domain); + $selectorcount = 0; + + foreach ($selectors as $file) { + + if (substr($file, -8, 8) !== '.private') { + continue; + } + + $selector = substr($file, 0, -8); + $manager = new dkim_manager($domain, $selector); + + $context = [ + 'domain' => $domain, + 'selector' => $selector, + 'dkimurl' => new moodle_url('https://mxtoolbox.com/SuperTool.aspx', + ['action' => "dkim:$domain:$selector", 'run' => 'toolpage']), + 'dkimrawurl' => new moodle_url('https://mxtoolbox.com/SuperTool.aspx', + ['action' => "txt:$selector._domainkey.$domain"]), + 'dnskey' => $manager->get_dns_key(), + 'dnsvalue' => $manager->get_dns_value(), + 'dnsvaluechunked' => $manager->get_dns_value_chunked(), + 'dnsvalueescaped' => $manager->get_dns_value_escaped(), + 'id' => uniqid(), + ]; + + if ($CFG->emaildkimselector == $selector) { + $context['selectoractive'] = true; + } + + if ($CFG->emaildkimselector !== $selector) { + // Only give the option to delete if it is not being used. + $confirmation = new \confirm_action( + get_string('selectordeleteconfirm', 'tool_emailutils'), + null, + get_string('selectordelete', 'tool_emailutils') + ); + $context['selectordelete'] = $OUTPUT->action_link( + new moodle_url('/admin/tool/emailutils/dkim.php', [ + 'domain' => $domain, + 'selector' => $selector, + 'action' => 'delete', + 'sesskey' => sesskey()]), + get_string('selectordelete', 'tool_emailutils'), + $confirmation, + ['class' => 'btn btn-secondary btn-sm'], + new pix_icon('i/delete', '')); + + // Only give the option to make it the active select if it is not being used. + $confirmation = new \confirm_action( + get_string('selectoractivateconfirm', 'tool_emailutils'), + null, + get_string('selectoractivate', 'tool_emailutils') + ); + $context['selectoractivate'] = $OUTPUT->action_link( + new moodle_url('/admin/tool/emailutils/dkim.php', [ + 'selector' => $selector, + 'action' => 'activate', + 'sesskey' => sesskey()]), + get_string('selectoractivate', 'tool_emailutils'), + $confirmation, + ['class' => 'btn btn-secondary btn-sm'], + new pix_icon('i/star', '')); + } + + print $OUTPUT->render_from_template('tool_emailutils/dkimselector', $context); + } +} +print "
Domains / selectorsActions
'; + print '

'; + print html_writer::tag('span', "@$domain "); + if ($domain == $noreplydomain) { + print ' ' . html_writer::tag('span', get_string('domaindefaultnoreply', 'tool_emailutils'), + ['class' => 'badge badge-secondary']); + } + print '

'; + print '
'; + + $url = new moodle_url('https://mxtoolbox.com/SuperTool.aspx', ['action' => "spf:$domain", 'run' => 'toolpage']); + print get_string('mxtoolbox', 'tool_emailutils'); + print '
    '; + print "
  • SPF"; + + $url = new moodle_url('https://mxtoolbox.com/SuperTool.aspx', ['action' => "txt:$domain"]); + print "
  • Raw TXT"; + + print '
"; + +if ($domaincount == 0) { + echo $OUTPUT->notification(get_string('selectormissing', 'tool_emailutils'), \core\notification::ERROR); +} + +print html_writer::tag('div', get_string('dkimmanagerhelp', 'tool_emailutils'), ['class' => 'crap', 'style' => 'max-width: 40em']); + +$form->display(); + +echo $OUTPUT->footer(); diff --git a/lang/en/tool_emailutils.php b/lang/en/tool_emailutils.php index ae12eef..21c72bc 100644 --- a/lang/en/tool_emailutils.php +++ b/lang/en/tool_emailutils.php @@ -15,6 +15,8 @@ // along with Moodle. If not, see . /** + * Lang pack + * * @package tool_emailutils * @copyright 2018 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -46,6 +48,35 @@ $string['event:notificationreceived'] = 'AWS SNS notification received'; +$string['dkimmanager'] = 'DKIM manager'; +$string['dkimmanagerhelp'] = '

This shows all DKIM key pairs / selectors available for email signing, including those made by this admin tool or put in place by external tools such as open-dkim. For most systems this is the end to end setup:

+
    +
  1. First decide and set the $CFG->noreply email as the domain of this is email is tied to the signing. +
  2. Create a new private and public key pair using a selector of your choice. The selector is arbitrary but a rough date format is a good convention. +
  3. Save the DNS record shown in this tool into your DNS server +
  4. Confirm that the DNS is in the correct shape using the MXtoolbox links +
  5. Now activate the selector you have chosen +
  6. Use the test email tool to send a real email and confirm the DKIM headers have been sent +
  7. Also confirm the DKIM headers validate using a 3rd party tools built into gmail, and most email clients +
+'; + +$string['domaindefaultnoreply'] = 'Default noreply'; + +$string['mxtoolbox'] = 'MXtoolbox'; +$string['selectoractive'] = 'Active selector'; +$string['selectoractivate'] = 'Activate selector'; +$string['selectoractivated'] = 'Selector was activated'; +$string['selectoractivateconfirm'] = 'This will set $CFG->emaildkimselector to this selector and it will be used for signing outgoing emails.'; +$string['selectorcreate'] = 'Create a new domain:selector certificate pair'; +$string['selectorcreatesubmit'] = 'Create new selector'; +$string['selectorcreated'] = 'A new certificate pair has been created'; +$string['selectordefault'] = '%Y-%m'; +$string['selectormissing'] = 'No DKIM selector certificates found'; +$string['selectordelete'] = 'Delete inactive selector'; +$string['selectordeleted'] = 'Inactive selector has been deleted'; +$string['selectordeleteconfirm'] = 'This will permanently delete this selector\'s private and public keys and is irreversable.'; + // Complaints list strings. $string['not_implemented'] = 'Not implemented yet. Search the user report for emails ending with ".b.invalid" and ".c.invalid".'; $string['bounces'] = 'For a list of bounces, visit {$a} and search for emails ending with ".b.invalid."'; diff --git a/settings.php b/settings.php index e9028d1..66d2b0d 100644 --- a/settings.php +++ b/settings.php @@ -15,6 +15,8 @@ // along with Moodle. If not, see . /** + * Add admin settings + * * @package tool_emailutils * @copyright 2018 onwards Catalyst IT {@link http://www.catalyst-eu.net/} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -29,6 +31,12 @@ new lang_string('pluginname', 'tool_emailutils') )); + $ADMIN->add('email', new admin_externalpage( + 'tool_emailutils_dkim', + new lang_string('dkimmanager', 'tool_emailutils'), + new moodle_url('/admin/tool/emailutils/dkim.php') + )); + $ADMIN->add('tool_emailutils', new admin_externalpage( 'tool_emailutils_list', new lang_string('list', 'tool_emailutils'), diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..da66606 --- /dev/null +++ b/styles.css @@ -0,0 +1,11 @@ + +.path-admin-tool-emailutils .dnsrecord { + background: #eee; + font: 75% monospace; + max-width: 50em; + overflow-wrap: anywhere; + padding: 0.5em; + text-wrap: wrap; + user-select: all; +} + diff --git a/templates/dkimselector.mustache b/templates/dkimselector.mustache new file mode 100644 index 0000000..2bfac39 --- /dev/null +++ b/templates/dkimselector.mustache @@ -0,0 +1,83 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_emailutils/dkimselector + + This template renders a DKIM selector table row + + Context variables required for this template: + * selectoractive If this selector is the one in use + + Example context (json): + { + "welcomemessage": "welcomemessage", + "selectoractive": true + } +}} + + + {{selector}} + {{#selectoractive}} +
{{#str}} selectoractive, tool_emailutils{{/str}} + {{/selectoractive}} + + +
+
+
+ +
+
+ {{dnskey}} +

This is the full raw txt of the DNS value:

+

{{dnsvalue}}

+
+
+ {{dnskey}} +

This is the DNS record value broken into quoted chunks of max 256 chars:

+

{{dnsvaluechunked}}

+
+
+ {{dnskey}} +

This is an escaped record value which is needed for some DNS systems:

+

{{dnsvalueescaped}}

+
+
+
+
+
+ + + {{#str}} mxtoolbox, tool_emailutils{{/str}} + +

{{{selectordelete}}}

+

{{{selectoractivate}}}

+ +