Skip to content

Commit edadd57

Browse files
Added DKIM manager page #7
1 parent 925e1fc commit edadd57

File tree

5 files changed

+500
-0
lines changed

5 files changed

+500
-0
lines changed

classes/dkim_manager.php

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* @package tool_emailutils
19+
* @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/}
20+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
21+
* @author Brendan Heywood <[email protected]>
22+
*/
23+
24+
namespace tool_emailutils;
25+
26+
/**
27+
* This loads, verifies and can auto create DKIM pairs of certificates
28+
*
29+
* Code largely adapted from PHPMailer
30+
*/
31+
class dkim_manager {
32+
33+
protected $domain;
34+
35+
protected $selector;
36+
37+
protected $privatekey;
38+
39+
protected $publickey;
40+
41+
protected $dnsrecord;
42+
43+
const DIGEST_ALG = 'sha256';
44+
45+
/**
46+
* Create or load the certificates for a domain and selector
47+
*
48+
*/
49+
public function __construct($domain, $selector, $autocreate = false) {
50+
$this->domain = $domain;
51+
$this->selector = $selector;
52+
53+
$privatekeyfile = $this->get_private_key_path();
54+
$publickeyfile = $this->get_public_key_path();
55+
56+
if (!file_exists($privatekeyfile) && $autocreate) {
57+
58+
$this->get_base_path(true);
59+
// Create a 2048-bit RSA key with an SHA256 digest.
60+
$pk = openssl_pkey_new(
61+
[
62+
'digest_alg' => self::DIGEST_ALG,
63+
'private_key_bits' => 2048,
64+
'private_key_type' => OPENSSL_KEYTYPE_RSA,
65+
]
66+
);
67+
68+
// Save both keys.
69+
openssl_pkey_export_to_file($pk, $privatekeyfile);
70+
$details = openssl_pkey_get_details($pk);
71+
file_put_contents($publickeyfile, $details['key']);
72+
}
73+
74+
$this->privatekey = file_get_contents($privatekeyfile);
75+
$this->publickey = file_get_contents($publickeyfile);
76+
}
77+
78+
public function get_domain_path() {
79+
global $CFG;
80+
return $CFG->dataroot . '/dkim/' . $this->domain . '/';
81+
}
82+
83+
public function get_base_path($create = false) {
84+
$certdir = $this->get_domain_path();
85+
if ($create) {
86+
mkdir($certdir, 0777, true);
87+
}
88+
return $certdir . '/' . $this->selector;
89+
}
90+
91+
public function get_private_key_path() {
92+
return $this->get_base_path() . '.private';
93+
}
94+
95+
public function get_public_key_path() {
96+
return $this->get_base_path() . '.public';
97+
}
98+
99+
public function get_dns_record_path() {
100+
return $this->get_base_path() . '.txt';
101+
}
102+
103+
/**
104+
* Get the domain the DKIM record should be stored at
105+
*/
106+
public function get_dns_domain() {
107+
return "{$this->selector}._domainkey.{$this->domain}";
108+
}
109+
110+
/**
111+
* Get the key of the DKIM txt record
112+
*/
113+
public function get_dns_key() {
114+
return $this->get_dns_domain() . ' IN TXT';
115+
}
116+
117+
/**
118+
* Get the value of the DKIM record
119+
*
120+
* This loads the public key and then stores the DNS record in a file.
121+
*/
122+
public function get_dns_value() {
123+
if (!empty($this->dnsrecord)) {
124+
return $this->dnsrecord;
125+
}
126+
127+
// TODO add support for records added by open dkim
128+
// These do not include the public key in the normal format, only in the DNS value format.
129+
130+
if (empty($this->publickey)) {
131+
return "ERROR: Can't find public key";
132+
}
133+
134+
$dnsvalue = 'v=DKIM1;';
135+
$dnsvalue .= ' h=' . self::DIGEST_ALG . ';'; // Hash algorythm.
136+
$dnsvalue .= ' t=s;'; // No sub domains allowed.
137+
$dnsvalue .= ' k=rsa;'; // Key type.
138+
$dnsvalue .= ' p='; // Public key.
139+
140+
$publickey = $this->publickey;
141+
$publickey = preg_replace('/^-+.*?-+$/m', '', $publickey); // Remove PEM wrapper.
142+
$publickey = str_replace(["\r", "\n"], '', $publickey); // Strip line breaks.
143+
$dnsvalue .= $publickey;
144+
145+
$this->dnsrecord = trim($dnsvalue);
146+
147+
return $this->dnsrecord;
148+
}
149+
150+
/**
151+
* Get a chunked version of the DKIM record
152+
*
153+
* Strip and split the key into smaller parts and format for DNS as many systems
154+
* don't like long TXT entries but are OK if it's split into 255-char chunks.
155+
*/
156+
public function get_dns_value_chunked() {
157+
158+
$rawvalue = $this->get_dns_value();
159+
160+
// Split into chunks.
161+
$keyparts = str_split($rawvalue, 253); // Becomes 255 when quotes are included.
162+
// Quote each chunk.
163+
foreach ($keyparts as $keypart) {
164+
$dnsvalue .= '"' . trim($keypart) . '" ';
165+
}
166+
167+
return $dnsvalue;
168+
169+
}
170+
/**
171+
* Get the alternate escaped version of the DKIM record
172+
*
173+
* Some DNS servers don't like ;(semi colon) chars unless backslash-escaped
174+
*/
175+
public function get_dns_value_escaped() {
176+
177+
$value = $this->get_dns_value_chunked();
178+
$value = str_replace(';', '\;', $value);
179+
return $value;
180+
181+
}
182+
183+
/**
184+
* Delete all info about a selector
185+
*/
186+
public function delete_selector() {
187+
$privatekeyfile = $this->get_private_key_path();
188+
$publickeyfile = $this->get_public_key_path();
189+
$dnsrecordfile = $this->get_dns_record_path();
190+
$domaindir = $this->get_domain_path();
191+
192+
@unlink($privatekeyfile);
193+
@unlink($publickeyfile);
194+
@unlink($dnsrecordfile);
195+
@rmdir($domaindir);
196+
}
197+
198+
}

dkim.php

+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* @package tool_emailutils
19+
* @copyright 2019 onwards Catalyst IT {@link http://www.catalyst-eu.net/}
20+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
21+
* @author Brendan Heywood <[email protected]>
22+
*/
23+
24+
use tool_emailutils\dkim_manager;
25+
26+
require_once(__DIR__ . '/../../../config.php');
27+
require_once($CFG->libdir . '/adminlib.php');
28+
29+
$baseurl = new moodle_url('/admin/tool/emailutils/dkim.php');
30+
$PAGE->set_url($baseurl);
31+
admin_externalpage_setup('tool_emailutils_list');
32+
33+
$action = optional_param('action', '', PARAM_ALPHA);
34+
35+
if ($action == 'delete') {
36+
require_sesskey();
37+
$domain = required_param('domain', PARAM_TEXT);
38+
$selector = required_param('selector', PARAM_TEXT);
39+
$manager = new dkim_manager($domain, $selector);
40+
$manager->delete_selector();
41+
redirect($baseurl, get_string('selectordeleted', 'tool_emailutils'), null, \core\output\notification::NOTIFY_SUCCESS);
42+
}
43+
44+
if ($action == 'create') {
45+
require_sesskey();
46+
$domain = required_param('domain', PARAM_TEXT);
47+
$selector = required_param('selector', PARAM_TEXT);
48+
$manager = new dkim_manager($domain, $selector, true);
49+
redirect($baseurl, get_string('selectorcreated', 'tool_emailutils'), null, \core\output\notification::NOTIFY_SUCCESS);
50+
}
51+
52+
if ($action == 'activate') {
53+
require_sesskey();
54+
$selector = required_param('selector', PARAM_TEXT);
55+
add_to_config_log('emaildkimselector', $CFG->emaildkimselector, $selector, '');
56+
set_config('emaildkimselector', $selector);
57+
redirect($baseurl, get_string('selectoractivated', 'tool_emailutils'), null, \core\output\notification::NOTIFY_SUCCESS);
58+
}
59+
60+
print $OUTPUT->header();
61+
print $OUTPUT->heading(get_string('dkimmanager', 'tool_emailutils'));
62+
63+
64+
$dkimdir = $CFG->dataroot . '/dkim/';
65+
$domains = scandir($dkimdir);
66+
$domaincount = 0;
67+
$noreplydomain = substr($CFG->noreplyaddress, strpos($CFG->noreplyaddress, '@') + 1);
68+
69+
print "<table class='table table-sm w-auto table-bordered'>";
70+
print '<tr><th colspan=2>Domains / selectors</th><th>Actions</th></tr>';
71+
foreach ($domains as $domain) {
72+
73+
if (substr($domain, 0, 1) == '.') {
74+
continue;
75+
}
76+
if (!is_dir($dkimdir . $domain)) {
77+
continue;
78+
}
79+
80+
$domaincount ++;
81+
82+
print '<tr><td colspan=2>';
83+
print '<h3>';
84+
print html_writer::tag('span', "@$domain ");
85+
if ($domain == $noreplydomain) {
86+
print ' ' . html_writer::tag('span', get_string('domaindefaultnoreply', 'tool_emailutils'),
87+
['class' => 'badge badge-secondary']);
88+
}
89+
print '</h3>';
90+
print '</td>';
91+
print '<td>';
92+
93+
$url = new moodle_url('https://mxtoolbox.com/SuperTool.aspx', ['action' => "spf:$domain", 'run' => 'toolpage']);
94+
print get_string('mxtoolbox', 'tool_emailutils');
95+
print '<ul>';
96+
print "<li><a href='$url' target='_blank'>SPF</a>";
97+
98+
$url = new moodle_url('https://mxtoolbox.com/SuperTool.aspx', ['action' => "txt:$domain"]);
99+
print "<li><a href='$url' target='_blank'>Raw TXT</a>";
100+
101+
print '</th></tr>';
102+
103+
104+
$selectors = scandir($dkimdir . $domain);
105+
$selectorcount = 0;
106+
107+
foreach ($selectors as $file) {
108+
109+
if (substr($file, -8, 8) !== '.private') {
110+
continue;
111+
}
112+
113+
$selector = substr($file, 0, -8);
114+
$manager = new dkim_manager($domain, $selector);
115+
116+
$context = [
117+
'domain' => $domain,
118+
'selector' => $selector,
119+
'dkimurl' => new moodle_url('https://mxtoolbox.com/SuperTool.aspx',
120+
['action' => "dkim:$domain:$selector", 'run' => 'toolpage']),
121+
'dkimrawurl' => new moodle_url('https://mxtoolbox.com/SuperTool.aspx',
122+
['action' => "txt:$selector._domainkey.$domain"]),
123+
'dnskey' => $manager->get_dns_key(),
124+
'dnsvalue' => $manager->get_dns_value(),
125+
'dnsvaluechunked' => $manager->get_dns_value_chunked(),
126+
'dnsvalueescaped' => $manager->get_dns_value_escaped(),
127+
'id' => uniqid(),
128+
];
129+
130+
if ($CFG->emaildkimselector == $selector) {
131+
$context['selectoractive'] = true;
132+
}
133+
134+
if ($CFG->emaildkimselector !== $selector) {
135+
// Only give the option to delete if it is not being used.
136+
$confirmation = new \confirm_action(
137+
get_string('selectordeleteconfirm', 'tool_emailutils'),
138+
null,
139+
get_string('selectordelete', 'tool_emailutils')
140+
);
141+
$context['selectordelete'] = $OUTPUT->action_link(
142+
new moodle_url('/admin/tool/emailutils/dkim.php', [
143+
'domain' => $domain,
144+
'selector' => $selector,
145+
'action' => 'delete',
146+
'sesskey' => sesskey()]),
147+
get_string('selectordelete', 'tool_emailutils'),
148+
$confirmation,
149+
['class' => 'btn btn-secondary btn-sm'],
150+
new pix_icon('i/delete', ''));
151+
152+
// Only give the option to make it the active select if it is not being used.
153+
$confirmation = new \confirm_action(
154+
get_string('selectoractivateconfirm', 'tool_emailutils'),
155+
null,
156+
get_string('selectoractivate', 'tool_emailutils')
157+
);
158+
$context['selectoractivate'] = $OUTPUT->action_link(
159+
new moodle_url('/admin/tool/emailutils/dkim.php', [
160+
'selector' => $selector,
161+
'action' => 'activate',
162+
'sesskey' => sesskey()]),
163+
get_string('selectoractivate', 'tool_emailutils'),
164+
$confirmation,
165+
['class' => 'btn btn-secondary btn-sm'],
166+
new pix_icon('i/star', ''));
167+
}
168+
169+
print $OUTPUT->render_from_template('tool_emailutils/dkimselector', $context);
170+
}
171+
}
172+
print "</table>";
173+
174+
print html_writer::tag('div', get_string('dkimmanagerhelp', 'tool_emailutils'), ['class' => 'crap', 'style' => 'max-width: 40em']);
175+
176+
if ($domaincount == 0) {
177+
echo $OUTPUT->notification('No DKIM files found', \core\notification::ERROR);
178+
}
179+
180+
// TODO have option to create a new key pair with form with selector
181+
// TODO put this in the proper admin tree
182+
// TODO translate fully
183+
// TODO add events when keys are created or removed
184+
// todo have option to change selector to a
185+
// todo have a check if the dns record is in place.
186+
187+
echo $OUTPUT->footer();

0 commit comments

Comments
 (0)