mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-13 17:17:23 +01:00
Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837d327c1e | ||
|
|
e39b777abc | ||
|
|
7ca42489ae | ||
|
|
8c6363f792 | ||
|
|
cbb7f29f96 | ||
|
|
8886c9b6bc | ||
|
|
a07de38e80 | ||
|
|
1f08997a9e | ||
|
|
1b2d07d81d | ||
|
|
fc32cf5bcc | ||
|
|
ce94ef38b2 | ||
|
|
1e9c587b92 | ||
|
|
9f1d633ae4 | ||
|
|
f01189631a | ||
|
|
542877ee46 | ||
|
|
f1760b516d | ||
|
|
f0143fd6c9 | ||
|
|
febfa72d60 | ||
|
|
c03e00035f | ||
|
|
2f8866ef32 | ||
|
|
f98afac6df | ||
|
|
5efd5abbe4 | ||
|
|
6bb8f5d889 | ||
|
|
35f4a49d10 | ||
|
|
a31d713fcc | ||
|
|
6f38f7afc3 | ||
|
|
e514ca0009 | ||
|
|
8c3aed2846 | ||
|
|
2cab9d5514 | ||
|
|
c38bdbb0c5 | ||
|
|
2a1704a0dc | ||
|
|
36168b4609 | ||
|
|
bd498def76 | ||
|
|
d8279c48ac | ||
|
|
4232245546 | ||
|
|
2d1186e55d | ||
|
|
072aeca1be | ||
|
|
cb656f9ef4 | ||
|
|
322a5779f1 | ||
|
|
e41df28bf2 | ||
|
|
d3239b49ce | ||
|
|
d11be61d94 | ||
|
|
916063a79b | ||
|
|
5aa0bf2d14 | ||
|
|
f3ad6b4acc | ||
|
|
ec039719de | ||
|
|
6989df0af3 | ||
|
|
14b16b2f36 | ||
|
|
cbc7e280d6 | ||
|
|
f4fa9c93a0 | ||
|
|
6c64723d7c | ||
|
|
3d21f2223e | ||
|
|
710a69b812 | ||
|
|
dd6a8d9998 | ||
|
|
9f32e5af0a | ||
|
|
298e19598b | ||
|
|
680191d7cb | ||
|
|
81d6d69b85 | ||
|
|
6df72bf4ac | ||
|
|
01f2451349 | ||
|
|
dcd971d079 | ||
|
|
4d22fb9b2a | ||
|
|
c18d58b13f | ||
|
|
b539c2df70 | ||
|
|
64fdb4ddc1 | ||
|
|
a8669197dd | ||
|
|
2412c92772 | ||
|
|
7c0ca42145 | ||
|
|
c443524ee2 | ||
|
|
e2fa01e0cf | ||
|
|
6558f05d1d | ||
|
|
1be0f39be0 | ||
|
|
d01001f2a5 | ||
|
|
7c85694d60 | ||
|
|
b2fcd4c9e5 | ||
|
|
ba8123f08a | ||
|
|
e2879a8eb1 | ||
|
|
eab8652225 | ||
|
|
ead6f96513 | ||
|
|
7ec662c83f | ||
|
|
348d2b8701 | ||
|
|
12f0dcb23b | ||
|
|
449a538e6b | ||
|
|
3c50c9a18b | ||
|
|
3c10ec70a5 | ||
|
|
1a59f343c0 | ||
|
|
fba4d4702e | ||
|
|
143bbf37f4 | ||
|
|
fd3ad267ba | ||
|
|
330583f71d | ||
|
|
d775f90f0c | ||
|
|
e096144713 | ||
|
|
7ce30ba888 | ||
|
|
6a3ec1d874 | ||
|
|
575d3a66c6 | ||
|
|
cc333b3965 | ||
|
|
351758b3bd | ||
|
|
94053d8432 | ||
|
|
e14b2826e0 | ||
|
|
150611123a | ||
|
|
abfc17ee62 | ||
|
|
97be9c94b9 | ||
|
|
21b00e8fbb | ||
|
|
01636c2e4b | ||
|
|
005315cd29 | ||
|
|
20d20df829 | ||
|
|
f945a1bc6b | ||
|
|
3a09b04786 | ||
|
|
82e752395b | ||
|
|
e330abd587 | ||
|
|
16422b4055 | ||
|
|
b9ca74c915 | ||
|
|
7e05d7478f | ||
|
|
8fd98d7db3 | ||
|
|
1039a08be6 | ||
|
|
023b38df50 | ||
|
|
3187053b3a | ||
|
|
a3e526e818 | ||
|
|
f6d4621834 | ||
|
|
d075113c1a | ||
|
|
63f2abd923 | ||
|
|
31eec9fa1c | ||
|
|
624cc7876a | ||
|
|
d3059c810f | ||
|
|
85a40da83c | ||
|
|
1bf8f1991f | ||
|
|
d155aa8745 | ||
|
|
c0bfd6d15f | ||
|
|
24cc108147 | ||
|
|
b02d7d990e | ||
|
|
87f82addbc | ||
|
|
09713e8eab | ||
|
|
0aa3941832 | ||
|
|
fea77e41df | ||
|
|
74ef9ab7c5 | ||
|
|
6499c82d7f | ||
|
|
80e97feee2 | ||
|
|
fddab5d432 | ||
|
|
c4e4805160 | ||
|
|
c75950125d | ||
|
|
f141af4b61 | ||
|
|
3d8ea0e6ed | ||
|
|
6efeff6fce | ||
|
|
399f9d9bdf | ||
|
|
2b76fd299e | ||
|
|
90592bb157 | ||
|
|
5cf38b950a | ||
|
|
3bc5361491 | ||
|
|
c3a7e3413b | ||
|
|
d390bfb215 | ||
|
|
ceba53f1c4 | ||
|
|
be59bcd47d | ||
|
|
cfe0fa912a | ||
|
|
31d6128a2b | ||
|
|
82cf5b72e4 | ||
|
|
8ec8c42441 | ||
|
|
7e36e1fd90 | ||
|
|
a7710e9058 | ||
|
|
3133dcd5a3 | ||
|
|
057c1dd913 | ||
|
|
06f2477cfd | ||
|
|
1abc8ed469 | ||
|
|
cdaa2c847d | ||
|
|
b04addda9a | ||
|
|
7e7abf3b53 | ||
|
|
9b9f5abf8f | ||
|
|
7db80458dd | ||
|
|
5775cab175 | ||
|
|
c872e6a9f0 | ||
|
|
995b7c4d2b | ||
|
|
f797eecaca | ||
|
|
de0ccd0632 | ||
|
|
be9d97902f | ||
|
|
20c5471a89 | ||
|
|
ec73c171c7 | ||
|
|
f9acf0adec | ||
|
|
8b65c11cdf | ||
|
|
34fca29dd3 | ||
|
|
b75fbf22ca | ||
|
|
d790cae0e2 | ||
|
|
a68703dfb3 | ||
|
|
f35b2081a1 | ||
|
|
f0508d8cc9 | ||
|
|
47dd59c2a7 | ||
|
|
c2fe1bc2e3 | ||
|
|
cce1184090 | ||
|
|
1adb1d8307 | ||
|
|
c2174e10a6 | ||
|
|
18283c7df0 | ||
|
|
3ff74c8dc5 | ||
|
|
e997114d6e | ||
|
|
e9aecba4df | ||
|
|
6585384daa | ||
|
|
6bc821676c | ||
|
|
f38ef0223d | ||
|
|
8902e9d1fc | ||
|
|
86a5394f07 | ||
|
|
df5df18820 | ||
|
|
0b5bf602aa | ||
|
|
06e074bd32 | ||
|
|
b5b3fca137 | ||
|
|
048e35a80f | ||
|
|
fb3045f456 | ||
|
|
495790d81d | ||
|
|
dfe0a9f187 | ||
|
|
7ca54a2bfb | ||
|
|
a81bf93fdc | ||
|
|
54d01746fe | ||
|
|
57f8ee0b09 | ||
|
|
64220292f1 | ||
|
|
82851d6d2d | ||
|
|
2f952a7915 | ||
|
|
f9eceec746 | ||
|
|
b26abc947e | ||
|
|
b78eae1351 | ||
|
|
ca57560f11 | ||
|
|
17331e7d82 | ||
|
|
5130b279d8 | ||
|
|
aac6e49b94 | ||
|
|
2f4eccd9a9 | ||
|
|
ac49912b39 | ||
|
|
0441a2e2e3 | ||
|
|
8566b78202 | ||
|
|
06a8ce1c9d | ||
|
|
443b084a17 | ||
|
|
990649af2d | ||
|
|
6f4d29a410 | ||
|
|
6ab29c3244 | ||
|
|
bf9b770255 | ||
|
|
9210ebdb9f | ||
|
|
a56bb984d6 | ||
|
|
7d1c0b3834 | ||
|
|
20892b5d5b | ||
|
|
4cf53cd8ee | ||
|
|
711db9352c | ||
|
|
f42a1c5a74 | ||
|
|
092c842a87 | ||
|
|
d9ecc50119 | ||
|
|
7c2092d48f | ||
|
|
5fd107cae5 | ||
|
|
db0967446b | ||
|
|
2ff5038c84 | ||
|
|
4ae76aa2dd | ||
|
|
ba33669a62 | ||
|
|
779d921410 | ||
|
|
68efef1164 | ||
|
|
6ecada7eed | ||
|
|
94c4352f45 | ||
|
|
6dd6353d41 | ||
|
|
d06bfa6c1b | ||
|
|
5c7ba2a4c7 | ||
|
|
e9cc3fdaab | ||
|
|
8bd37ea53c | ||
|
|
698ae03505 | ||
|
|
a4c70f7a92 | ||
|
|
39bca053ed | ||
|
|
c2eb8e5330 | ||
|
|
ab47144ae3 | ||
|
|
9b6f9859d1 | ||
|
|
4e6d572de9 | ||
|
|
145186a6b6 | ||
|
|
b5bb12d0d2 | ||
|
|
93a722f85b |
228
CHANGELOG.md
Normal file
228
CHANGELOG.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
CHANGELOG
|
||||||
|
=========
|
||||||
|
|
||||||
|
v0.09 (May 8, 2015)
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Spam checking is now performed on messages larger than the previous limit of 64KB.
|
||||||
|
* POP3S is now enabled (port 995).
|
||||||
|
* Roundcube is updated to version 1.1.1.
|
||||||
|
* Minor security improvements (more mail headers with user agent info are anonymized; crypto settings were tightened).
|
||||||
|
|
||||||
|
ownCloud:
|
||||||
|
|
||||||
|
* Downloading files you uploaded to ownCloud broke because of a change in ownCloud 8.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* Internationalized Domain Names (IDNs) should now work in email. If you had custom DNS or custom web settings for internationalized domains, check that they are still working.
|
||||||
|
* It is now possible to set multiple TXT and other types of records on the same domain in the control panel.
|
||||||
|
* The custom DNS API was completely rewritten to support setting multiple records of the same type on a domain. Any existing client code using the DNS API will have to be rewritten. (Existing code will just get 404s back.)
|
||||||
|
* On some systems the `nsd` service failed to start if network inferfaces were not ready.
|
||||||
|
|
||||||
|
System / Control Panel:
|
||||||
|
|
||||||
|
* In order to guard against misconfiguration that can lead to domain control validation hijacking, email addresses that begin with admin, administrator, postmaster, hostmaster, and webmaster can no longer be used for (new) mail user accounts, and aliases for these addresses may direct mail only to the box's administrator(s).
|
||||||
|
* Backups now use duplicity's built-in gpg symmetric AES256 encryption rather than my home-brewed encryption. Old backups will be incorporated inside the first backup after this update but then deleted from disk (i.e. your backups from the previous few days will be backed up).
|
||||||
|
* There was a race condition between backups and the new nightly status checks.
|
||||||
|
* The control panel would sometimes lock up with an unnecessary loading indicator.
|
||||||
|
* You can no longer delete your own account from the control panel.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* All Mail-in-a-Box release tags are now signed on github, instructions for verifying the signature are added to the README, and the integrity of some packages downloaded during setup is now verified against a SHA1 hash stored in the tag itself.
|
||||||
|
* Bugs in first user account creation were fixed.
|
||||||
|
|
||||||
|
v0.08 (April 1, 2015)
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube.
|
||||||
|
* Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed.
|
||||||
|
* The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records.
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* ownCloud updated to version 8.0.2.
|
||||||
|
* Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban.
|
||||||
|
* Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account).
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* The new check that system services are running mistakenly checked that the Dovecot Managesieve service is publicly accessible. Although the service binds to the public network interface we don't open the port in ufw. On some machines it seems that ufw blocks the connection from the status checks (which seems correct) and on some machines (mine) it doesn't, which is why I didn't notice the problem.
|
||||||
|
* The current backup chain will now try to predict how many days until it is deleted (always at least 3 days after the next full backup).
|
||||||
|
* The list of aliases that forward to a user are removed from the Mail Users page because when there are many alises it is slow and times-out.
|
||||||
|
* Some status check errors are turned into warnings, especially those that might not apply if External DNS is used.
|
||||||
|
|
||||||
|
v0.07 (February 28, 2015)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* If the box manages mail for a domain and a subdomain of that domain, outbound mail from the subdomain was not DKIM-signed and would therefore fail DMARC tests on the receiving end, possibly result in the mail heading into spam folders.
|
||||||
|
* Auto-configuration for Mozilla Thunderbird, Evolution, KMail, and Kontact is now available.
|
||||||
|
* Domains that only have a catch-all alias or domain alias no longer automatically create/require admin@ and postmaster@ addresses since they'll forward anyway.
|
||||||
|
* Roundcube is updated to version 1.1.0.
|
||||||
|
* Authentication-Results headers for DMARC are now added to incoming mail.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.
|
||||||
|
* If a custom DNS A record overrides one provided by the box, the a corresponding default IPv6 record by the box is removed since it will probably be incorrect.
|
||||||
|
* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.
|
||||||
|
|
||||||
|
Web:
|
||||||
|
|
||||||
|
* Static websites now deny access to certain dot (.) files and directories which typically have sensitive info: .ht*, .svn*, .git*, .hg*, .bzr*.
|
||||||
|
* The nginx server no longer reports its version and OS for better privacy.
|
||||||
|
* The HTTP->HTTPS redirect is now more efficient.
|
||||||
|
* When serving a 'www.' domain, reuse the SSL certificate for the parent domain if it covers the 'www' subdomain too
|
||||||
|
* If a custom DNS CNAME record is set on a domain, don't offer to put a website on that domain. (Same logic already applies to custom A/AAAA records.)
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* Status checks now check that system services are actually running by pinging each port that should have something running on it.
|
||||||
|
* The status checks are now parallelized so they may be a little faster.
|
||||||
|
* The status check for MX records now allow any priority, in case an unusual setup is required.
|
||||||
|
* The interface for setting website domain-specific directories is simplified.
|
||||||
|
* The mail guide now says that to use Outlook, Outlook 2007 or later on Windows 7 and later is required.
|
||||||
|
* External DNS settings now skip the special "_secondary_nameserver" key which is used for storing secondary NS information.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* Install cron if it isn't already installed.
|
||||||
|
* Fix a units problem in the minimum memory check.
|
||||||
|
* If you override the STORAGE_ROOT, your setting will now persist if you re-run setup.
|
||||||
|
* Hangs due to apt wanting the user to resolve a conflict should now be fixed (apt will just clobber the problematic file now).
|
||||||
|
|
||||||
|
v0.06 (January 4, 2015)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Set better default system limits to accommodate boxes handling mail for 20+ users.
|
||||||
|
|
||||||
|
Contacts/calendar:
|
||||||
|
|
||||||
|
* Update to ownCloud to 7.0.4.
|
||||||
|
* Contacts syncing via ActiveSync wasn't working.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* New control panel for setting custom DNS settings (without having to use the API).
|
||||||
|
* Status checks showed a false positive for Spamhause blacklists and for secondary DNS in some cases.
|
||||||
|
* Status checks would fail to load if openssh-sever was not pre-installed, but openssh-server is not required.
|
||||||
|
* The local DNS cache is cleared before running the status checks using 'rncd' now rather than restarting 'bind9', which should be faster and wont interrupt other services.
|
||||||
|
* Multi-domain and wildcard certificate can now be installed through the control panel.
|
||||||
|
* The DNS API now allows the setting of SRV records.
|
||||||
|
|
||||||
|
Misc:
|
||||||
|
|
||||||
|
* IPv6 configuration error in postgrey, nginx.
|
||||||
|
* Missing dependency on sudo.
|
||||||
|
|
||||||
|
v0.05 (November 18, 2014)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* The maximum size of outbound mail sent via webmail and Exchange/ActiveSync has been increased to 128 MB, the same as when using SMTP.
|
||||||
|
* Spam is no longer wrapped as an attachment inside a scary Spamassassin explanation. The original message is simply moved straight to the Spam folder unchanged.
|
||||||
|
* There is a new iOS/Mac OS X Configuration Profile link in the control panel which makes it easier to configure IMAP/SMTP/CalDAV/CardDAV on iOS devices and Macs.
|
||||||
|
* "Domain aliases" can now be configured in the control panel.
|
||||||
|
* Updated to [Roundcube 1.0.3](http://trac.roundcube.net/wiki/Changelog).
|
||||||
|
* IMAP/SMTP is now recommended even on iOS devices as Exchange/ActiveSync is terribly buggy.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* Installing an SSL certificate for the primary hostname would cause problems until a restart (services needed to be restarted).
|
||||||
|
* Installing SSL certificates would fail if /tmp was on a different filesystem.
|
||||||
|
* Better error messages when installing a SSL certificate fails.
|
||||||
|
* The local DNS cache is now cleared each time the system status checks are run.
|
||||||
|
* Documented how to use +tag addressing.
|
||||||
|
* Minor UI tweaks.
|
||||||
|
|
||||||
|
Other:
|
||||||
|
|
||||||
|
* Updated to [ownCloud 7.0.3](http://owncloud.org/changelog/).
|
||||||
|
* The ownCloud API is now exposed properly.
|
||||||
|
* DNSSEC now works on `.guide` domains now too (RSASHA256).
|
||||||
|
|
||||||
|
v0.04 (October 15, 2014)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Breaking changes:
|
||||||
|
|
||||||
|
* On-disk backups are now retained for a minimum of 3 days instead of 14. Beyond that the user is responsible for making off-site copies.
|
||||||
|
* IMAP no longer supports the legacy SSLv3 protocol. SSLv3 is now known to be insecure. I don't believe any modern devices will be affected by this. HTTPS and SMTP submission already had SSLv3 disabled.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* The control panel has a new page for installing SSL certificates.
|
||||||
|
* The control panel has a new page for hosting static websites.
|
||||||
|
* The control panel now shows mailbox sizes on disk.
|
||||||
|
* It is now possible to create catch-all aliases from the control panel.
|
||||||
|
* Many usability improvements in the control panel.
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
* Custom DNS A/AAAA records on subdomains were ignored.
|
||||||
|
* It is now possible to set up a secondary DNS server.
|
||||||
|
* DNS zones were updating even when nothing changed.
|
||||||
|
* Strict SPF and DMARC settings are now set on all subdomains not used for mail.
|
||||||
|
|
||||||
|
Security:
|
||||||
|
|
||||||
|
* DNSSEC is now supported for the .email TLD which required a different key algorithm.
|
||||||
|
* Nginx and Postfix now use 2048 bits of DH parameters instead of 1024.
|
||||||
|
|
||||||
|
Other:
|
||||||
|
|
||||||
|
* Spam filter learning by dragging mail in and out of the Spam folder should hopefully be working now.
|
||||||
|
* Some things were broken if the machine had an IPv6 address.
|
||||||
|
* Other things were broken if the machine was on a non-utf8 locale.
|
||||||
|
* No longer implementing webfinger.
|
||||||
|
* Removes apache before installing nginx, in case it has been installed by distro.
|
||||||
|
|
||||||
|
v0.03 (September 24, 2014)
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
* Update existing installs of Roundcube.
|
||||||
|
* Disabled catch-alls pending figuring out how to get users to take precedence.
|
||||||
|
* Z-Push was not working because in v0.02 we had accidentally moved to a different version.
|
||||||
|
* Z-Push is now locked to a specific commit so it doesn't change on us accidentally.
|
||||||
|
* The start script is now symlinked to /usr/local/bin/mailinabox.
|
||||||
|
|
||||||
|
v0.02 (September 21, 2014)
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
* Open the firewall to an alternative SSH port if set.
|
||||||
|
* Fixed missing dependencies.
|
||||||
|
* Set Z-Push to use sync command with ownCloud.
|
||||||
|
* Support more concurrent connections for z-push.
|
||||||
|
* In the status checks, handle wildcard certificates.
|
||||||
|
* Show the status of backups in the control panel.
|
||||||
|
* The control panel can now update a user's password.
|
||||||
|
* Some usability improvements in the control panel.
|
||||||
|
* Warn if a SSL cert is expiring in 30 days.
|
||||||
|
* Use SHA2 to generate CSRs.
|
||||||
|
* Better logic for determining when to take a full backup.
|
||||||
|
* Reduce DNS TTL, not that it seems to really matter.
|
||||||
|
* Add SSHFP DNS records.
|
||||||
|
* Add an API for setting custom DNS records
|
||||||
|
* Update to ownCloud 7.0.2.
|
||||||
|
* Some things were broken if the machine had an IPv6 address.
|
||||||
|
* Use a dialogs library to ask users questions during setup.
|
||||||
|
* Other fixes.
|
||||||
|
|
||||||
|
v0.01 (August 19, 2014)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
First release.
|
||||||
25
README.md
25
README.md
@@ -28,12 +28,35 @@ The Box
|
|||||||
|
|
||||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server, including SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib)), webmail ([Roundcube](http://roundcube.net/)), spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), DNS, [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [SSHFP](https://tools.ietf.org/html/rfc4255), and basic system services like a firewall, intrusion protection, and setting the system clock.
|
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server, including SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib)), webmail ([Roundcube](http://roundcube.net/)), spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), DNS, [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [SSHFP](https://tools.ietf.org/html/rfc4255), and basic system services like a firewall, intrusion protection, and setting the system clock.
|
||||||
|
|
||||||
|
Authenticity
|
||||||
|
------------
|
||||||
|
|
||||||
|
I sign the release tags. To verify that a tag is signed by me, you can perform the following steps:
|
||||||
|
|
||||||
|
# Download my PGP key.
|
||||||
|
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
||||||
|
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
||||||
|
|
||||||
|
# Clone this repository.
|
||||||
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
|
$ cd mailinabox
|
||||||
|
|
||||||
|
# Verify the tag.
|
||||||
|
$ git verify-tag v0.08
|
||||||
|
gpg: Signature made ..... using RSA key ID C10BDD81
|
||||||
|
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
||||||
|
gpg: WARNING: This key is not certified with a trusted signature!
|
||||||
|
gpg: There is no indication that the signature belongs to the owner.
|
||||||
|
Primary key fingerprint: 5F4C 0E73 13CC D744 693B 2AEA B920 41F4 C10B DD81
|
||||||
|
|
||||||
|
The key ID and fingerprint above should match my [Keybase.io key](https://keybase.io/joshdata) and the fingerprint I publish on [my homepage](https://razor.occams.info/).
|
||||||
|
|
||||||
The Acknowledgements
|
The Acknowledgements
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/al3x/sovereign) by Alex Payne, and conversations with <a href="http://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
|
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/al3x/sovereign) by Alex Payne, and conversations with <a href="http://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
|
||||||
|
|
||||||
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/).
|
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
|
||||||
|
|
||||||
The History
|
The History
|
||||||
-----------
|
-----------
|
||||||
|
|||||||
22
conf/fail2ban/dovecotimap.conf
Normal file
22
conf/fail2ban/dovecotimap.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Fail2Ban filter Dovecot authentication and pop3/imap server
|
||||||
|
# For Mail-in-a-Box
|
||||||
|
|
||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
|
||||||
|
_daemon = (auth|dovecot(-auth)?|auth-worker)
|
||||||
|
|
||||||
|
failregex = ^%(__prefix_line)s(pop3|imap)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
|
||||||
|
|
||||||
|
ignoreregex =
|
||||||
|
|
||||||
|
# DEV Notes:
|
||||||
|
# * the first regex is essentially a copy of pam-generic.conf
|
||||||
|
# * Probably doesn't do dovecot sql/ldap backends properly
|
||||||
|
#
|
||||||
|
# Author: Martin Waschbuesch
|
||||||
|
# Daniel Black (rewrote with begin and end anchors)
|
||||||
|
# Mail-in-a-Box (swapped session=...)
|
||||||
34
conf/fail2ban/jail.local
Normal file
34
conf/fail2ban/jail.local
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Fail2Ban configuration file.
|
||||||
|
# For Mail-in-a-Box
|
||||||
|
[DEFAULT]
|
||||||
|
|
||||||
|
# bantime in seconds
|
||||||
|
bantime = 60
|
||||||
|
|
||||||
|
# This should ban dumb brute-force attacks, not oblivious users.
|
||||||
|
findtime = 30
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
|
#
|
||||||
|
# JAILS
|
||||||
|
#
|
||||||
|
|
||||||
|
[ssh]
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
logpath = /var/log/auth.log
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
|
[ssh-ddos]
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
|
[sasl]
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[dovecot]
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
filter = dovecotimap
|
||||||
128
conf/ios-profile.xml
Normal file
128
conf/ios-profile.xml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
iOS/OS X Configuration Profile
|
||||||
|
|
||||||
|
Mobileconfig for iOS/OS X users to setup IMAP, SMTP, Contacts & Calendar
|
||||||
|
|
||||||
|
https://developer.apple.com/library/ios/featuredarticles/iPhoneConfigurationProfileRef/Introduction/Introduction.html
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CalDAVAccountDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME calendar</string>
|
||||||
|
<key>CalDAVHostName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
|
<key>CalDAVPort</key>
|
||||||
|
<real>443</real>
|
||||||
|
<key>CalDAVPrincipalURL</key>
|
||||||
|
<string>/cloud/remote.php/caldav/calendars/</string>
|
||||||
|
<key>CalDAVUseSSL</key>
|
||||||
|
<true/>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME calendar</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.CalDAV</string>
|
||||||
|
<key>PayloadOrganization</key>
|
||||||
|
<string></string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.caldav.account</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>UUID1</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>EmailAccountDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME mail</string>
|
||||||
|
<key>EmailAccountType</key>
|
||||||
|
<string>EmailTypeIMAP</string>
|
||||||
|
<key>IncomingMailServerAuthentication</key>
|
||||||
|
<string>EmailAuthPassword</string>
|
||||||
|
<key>IncomingMailServerHostName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
|
<key>IncomingMailServerPortNumber</key>
|
||||||
|
<integer>993</integer>
|
||||||
|
<key>IncomingMailServerUseSSL</key>
|
||||||
|
<true/>
|
||||||
|
<key>OutgoingMailServerAuthentication</key>
|
||||||
|
<string>EmailAuthPassword</string>
|
||||||
|
<key>OutgoingMailServerHostName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
|
<key>OutgoingMailServerPortNumber</key>
|
||||||
|
<integer>587</integer>
|
||||||
|
<key>OutgoingMailServerUseSSL</key>
|
||||||
|
<true/>
|
||||||
|
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
||||||
|
<true/>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME mail</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.E-Mail</string>
|
||||||
|
<key>PayloadOrganization</key>
|
||||||
|
<string></string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.mail.managed</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>UUID2</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>PreventAppSheet</key>
|
||||||
|
<false/>
|
||||||
|
<key>PreventMove</key>
|
||||||
|
<false/>
|
||||||
|
<key>SMIMEEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>CardDAVAccountDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME contacts</string>
|
||||||
|
<key>CardDAVHostName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
|
<key>CardDAVPort</key>
|
||||||
|
<integer>443</integer>
|
||||||
|
<key>CardDAVPrincipalURL</key>
|
||||||
|
<string>/cloud/remote.php/carddav/addressbooks/</string>
|
||||||
|
<key>CardDAVUseSSL</key>
|
||||||
|
<true/>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME contacts</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.carddav</string>
|
||||||
|
<key>PayloadOrganization</key>
|
||||||
|
<string></string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.carddav.account</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>UUID3</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME</string>
|
||||||
|
<key>PayloadOrganization</key>
|
||||||
|
<string></string>
|
||||||
|
<key>PayloadRemovalDisallowed</key>
|
||||||
|
<false/>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>UUID4</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -25,6 +25,16 @@ SCRIPTNAME=/etc/init.d/$NAME
|
|||||||
START=yes
|
START=yes
|
||||||
EXEC_AS_USER=root
|
EXEC_AS_USER=root
|
||||||
|
|
||||||
|
# Ensure Python reads/writes files in UTF-8. If the machine
|
||||||
|
# triggers some other locale in Python, like ASCII encoding,
|
||||||
|
# Python may not be able to read/write files. Here and in
|
||||||
|
# setup/start.sh (where the locale is also installed if not
|
||||||
|
# already present).
|
||||||
|
export LANGUAGE=en_US.UTF-8
|
||||||
|
export LC_ALL=en_US.UTF-8
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
# Read configuration variable file if it is present
|
# Read configuration variable file if it is present
|
||||||
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
||||||
|
|
||||||
|
|||||||
44
conf/mozilla-autoconfig.xml
Normal file
44
conf/mozilla-autoconfig.xml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<clientConfig version="1.1">
|
||||||
|
<emailProvider id="PRIMARY_HOSTNAME">
|
||||||
|
<domain>PRIMARY_HOSTNAME</domain>
|
||||||
|
|
||||||
|
<displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>
|
||||||
|
<displayShortName>PRIMARY_HOSTNAME</displayShortName>
|
||||||
|
|
||||||
|
<incomingServer type="imap">
|
||||||
|
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||||
|
<port>993</port>
|
||||||
|
<socketType>SSL</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
<authentication>password-cleartext</authentication>
|
||||||
|
</incomingServer>
|
||||||
|
|
||||||
|
<outgoingServer type="smtp">
|
||||||
|
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||||
|
<port>587</port>
|
||||||
|
<socketType>STARTTLS</socketType>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
<authentication>password-cleartext</authentication>
|
||||||
|
<addThisServer>true</addThisServer>
|
||||||
|
<useGlobalPreferredServer>true</useGlobalPreferredServer>
|
||||||
|
</outgoingServer>
|
||||||
|
|
||||||
|
<documentation url="https://PRIMARY_HOSTNAME/">
|
||||||
|
<descr lang="en">PRIMARY_HOSTNAME website.</descr>
|
||||||
|
</documentation>
|
||||||
|
</emailProvider>
|
||||||
|
|
||||||
|
<webMail>
|
||||||
|
<loginPage url="https://PRIMARY_HOSTNAME/mail/" />
|
||||||
|
<loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" >
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
<usernameField id="rcmloginuser" name="_user" />
|
||||||
|
<passwordField id="rcmloginpwd" name="_pass" />
|
||||||
|
<loginButton id="rcmloginsubmit" />
|
||||||
|
</loginPageInfo>
|
||||||
|
</webMail>
|
||||||
|
|
||||||
|
<clientConfigUpdate url="https://PRIMARY_HOSTNAME/.well-known/autoconfig/mail/config-v1.1.xml" />
|
||||||
|
|
||||||
|
</clientConfig>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# Control Panel
|
# Control Panel
|
||||||
|
# Proxy /admin to our Python based control panel daemon. It is
|
||||||
|
# listening on IPv4 only so use an IP address and not 'localhost'.
|
||||||
rewrite ^/admin$ /admin/;
|
rewrite ^/admin$ /admin/;
|
||||||
location /admin/ {
|
location /admin/ {
|
||||||
proxy_pass http://localhost:10222/;
|
proxy_pass http://127.0.0.1:10222/;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,12 +18,17 @@
|
|||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
location ~ ^(/cloud)(/[^/]+\.php)(/.*)?$ {
|
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
|
||||||
# note: ~ has precendence over a regular location block
|
# note: ~ has precendence over a regular location block
|
||||||
|
# Accept URLs like:
|
||||||
|
# /cloud/index.php/apps/files/
|
||||||
|
# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)
|
||||||
|
# /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240)
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
|
||||||
fastcgi_param SCRIPT_NAME $1$2;
|
fastcgi_param SCRIPT_NAME $1$2;
|
||||||
fastcgi_param PATH_INFO $3;
|
fastcgi_param PATH_INFO $3;
|
||||||
|
# TODO: see the dispreferred "method 2" for xaccel at https://doc.owncloud.org/server/8.1/admin_manual/configuration_files/serving_static_files_configuration.html
|
||||||
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
|
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
|
||||||
fastcgi_read_timeout 630;
|
fastcgi_read_timeout 630;
|
||||||
fastcgi_pass php-fpm;
|
fastcgi_pass php-fpm;
|
||||||
@@ -30,10 +37,13 @@
|
|||||||
client_max_body_size 1G;
|
client_max_body_size 1G;
|
||||||
fastcgi_buffers 64 4K;
|
fastcgi_buffers 64 4K;
|
||||||
}
|
}
|
||||||
location ^~ /cloud/data {
|
location ^~ $STORAGE_ROOT/owncloud {
|
||||||
# In order to support MOD_X_ACCEL_REDIRECT_ENABLED, we need to expose
|
# This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. It works a little weird.
|
||||||
# the data directory but only allow 'internal' redirects within nginx
|
# The full path on disk of the file is passed as the URL path. ownCloud 8 totally
|
||||||
# so that this is not exposed to the world.
|
# busted the sane way this worked in ownCloud 7. There's a pending change using
|
||||||
|
# a new parameter to make this make more sense.
|
||||||
|
# We need to only allow 'internal' redirects within nginx so that the filesystem
|
||||||
|
# is not exposed to the world.
|
||||||
internal;
|
internal;
|
||||||
alias $STORAGE_ROOT/owncloud;
|
alias $STORAGE_ROOT/owncloud;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ spdy_headers_comp 0;
|
|||||||
# openssl dhparam -outform pem -out dhparam2048.pem 2048
|
# openssl dhparam -outform pem -out dhparam2048.pem 2048
|
||||||
#
|
#
|
||||||
# Note: raising the bits to 2048 excludes Java 6 clients. Comment out if a problem.
|
# Note: raising the bits to 2048 excludes Java 6 clients. Comment out if a problem.
|
||||||
#ssl_dhparam /path/to/dhparam2048.pem;
|
ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
||||||
|
|
||||||
|
|
||||||
# OCSP stapling - means nginx will poll the CA for signed OCSP responses,
|
# OCSP stapling - means nginx will poll the CA for signed OCSP responses,
|
||||||
|
|||||||
@@ -7,15 +7,28 @@ server {
|
|||||||
|
|
||||||
server_name $HOSTNAME;
|
server_name $HOSTNAME;
|
||||||
root /tmp/invalid-path-nothing-here;
|
root /tmp/invalid-path-nothing-here;
|
||||||
rewrite ^/(.*)$ https://$HOSTNAME/$1 permanent;
|
|
||||||
|
# Improve privacy: Hide version an OS information on
|
||||||
|
# error pages and in the "Server" HTTP-Header.
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# Redirect using the 'return' directive and the built-in
|
||||||
|
# variable '$request_uri' to avoid any capturing, matching
|
||||||
|
# or evaluation of regular expressions.
|
||||||
|
return 301 https://$HOSTNAME$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
# The secure HTTPS server.
|
# The secure HTTPS server.
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
|
||||||
server_name $HOSTNAME;
|
server_name $HOSTNAME;
|
||||||
|
|
||||||
|
# Improve privacy: Hide version an OS information on
|
||||||
|
# error pages and in the "Server" HTTP-Header.
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
ssl_certificate $SSL_CERTIFICATE;
|
ssl_certificate $SSL_CERTIFICATE;
|
||||||
ssl_certificate_key $SSL_KEY;
|
ssl_certificate_key $SSL_KEY;
|
||||||
include /etc/nginx/nginx-ssl.conf;
|
include /etc/nginx/nginx-ssl.conf;
|
||||||
@@ -24,6 +37,30 @@ server {
|
|||||||
root $ROOT;
|
root $ROOT;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.ico {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /mailinabox.mobileconfig {
|
||||||
|
alias /var/lib/mailinabox/mobileconfig.xml;
|
||||||
|
}
|
||||||
|
location = /.well-known/autoconfig/mail/config-v1.1.xml {
|
||||||
|
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
|
||||||
|
location ~ /\.(ht|svn|git|hg|bzr) {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
# Roundcube Webmail configuration.
|
# Roundcube Webmail configuration.
|
||||||
rewrite ^/mail$ /mail/ redirect;
|
rewrite ^/mail$ /mail/ redirect;
|
||||||
rewrite ^/mail/$ /mail/index.php;
|
rewrite ^/mail/$ /mail/index.php;
|
||||||
@@ -42,14 +79,10 @@ server {
|
|||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
||||||
fastcgi_pass php-fpm;
|
fastcgi_pass php-fpm;
|
||||||
client_max_body_size 20M;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Webfinger configuration.
|
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||||
location = /.well-known/webfinger {
|
# file upload limit to match the corresponding Postfix limit.
|
||||||
include fastcgi_params;
|
client_max_body_size 128M;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-webfinger.php;
|
|
||||||
fastcgi_pass php-fpm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Z-Push (Microsoft Exchange ActiveSync)
|
# Z-Push (Microsoft Exchange ActiveSync)
|
||||||
@@ -59,6 +92,10 @@ server {
|
|||||||
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
||||||
fastcgi_read_timeout 630;
|
fastcgi_read_timeout 630;
|
||||||
fastcgi_pass php-fpm;
|
fastcgi_pass php-fpm;
|
||||||
|
|
||||||
|
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||||
|
# file upload limit to match the corresponding Postfix limit.
|
||||||
|
client_max_body_size 128M;
|
||||||
}
|
}
|
||||||
location /autodiscover/autodiscover.xml {
|
location /autodiscover/autodiscover.xml {
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
@@ -70,4 +107,3 @@ server {
|
|||||||
|
|
||||||
# ADDITIONAL DIRECTIVES HERE
|
# ADDITIONAL DIRECTIVES HERE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,7 @@
|
|||||||
/^\s*X-Enigmail:/ IGNORE
|
/^\s*X-Enigmail:/ IGNORE
|
||||||
/^\s*X-Mailer:/ IGNORE
|
/^\s*X-Mailer:/ IGNORE
|
||||||
/^\s*X-Originating-IP:/ IGNORE
|
/^\s*X-Originating-IP:/ IGNORE
|
||||||
|
/^\s*X-Pgp-Agent:/ IGNORE
|
||||||
|
|
||||||
|
# The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)).
|
||||||
|
/^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
require ["regex", "fileinto", "imap4flags"];
|
require ["regex", "fileinto", "imap4flags"];
|
||||||
|
|
||||||
if allof (header :regex "X-Spam-Status" "^Yes") {
|
if allof (header :regex "X-Spam-Status" "^Yes") {
|
||||||
setflag "\\Seen";
|
|
||||||
fileinto "Spam";
|
fileinto "Spam";
|
||||||
stop;
|
stop;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ define('CALDAV_SERVER', 'https://localhost');
|
|||||||
define('CALDAV_PORT', '443');
|
define('CALDAV_PORT', '443');
|
||||||
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
||||||
define('CALDAV_PERSONAL', '');
|
define('CALDAV_PERSONAL', '');
|
||||||
|
define('CALDAV_SUPPORTS_SYNC', true);
|
||||||
// If the CalDAV server supports the sync-collection operation
|
|
||||||
// DAViCal and SOGo support it
|
|
||||||
// Setting this to false will work with most servers, but it will be slower
|
|
||||||
define('CALDAV_SUPPORTS_SYNC', false);
|
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -14,14 +14,8 @@ define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdire
|
|||||||
define('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */
|
define('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */
|
||||||
define('CARDDAV_GAL_MIN_LENGTH', 5);
|
define('CARDDAV_GAL_MIN_LENGTH', 5);
|
||||||
define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
|
define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
|
||||||
|
|
||||||
|
|
||||||
// If the CardDAV server supports the sync-collection operation
|
|
||||||
// DAViCal supports it, but SabreDav, Owncloud, SOGo don't
|
|
||||||
// Setting this to false will work with most servers, but it will be slower: 1 petition for the href of vcards, and 1 petition for each vcard
|
|
||||||
define('CARDDAV_SUPPORTS_SYNC', false);
|
define('CARDDAV_SUPPORTS_SYNC', false);
|
||||||
|
|
||||||
|
|
||||||
// If the CardDAV server supports the FN attribute for searches
|
// If the CardDAV server supports the FN attribute for searches
|
||||||
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't
|
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't
|
||||||
// Setting this to true will search by FN. If false will search by sn, givenName and email
|
// Setting this to true will search by FN. If false will search by sn, givenName and email
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import base64, os, os.path
|
import base64, os, os.path, hmac
|
||||||
|
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
|
||||||
import utils
|
import utils
|
||||||
from mailconfig import get_mail_user_privileges
|
from mailconfig import get_mail_password, get_mail_user_privileges
|
||||||
|
|
||||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||||
@@ -40,10 +40,12 @@ class KeyAuthService:
|
|||||||
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
||||||
key_file.write(self.key + '\n')
|
key_file.write(self.key + '\n')
|
||||||
|
|
||||||
def is_authenticated(self, request, env):
|
def authenticate(self, request, env):
|
||||||
"""Test if the client key passed in HTTP Authorization header matches the service key
|
"""Test if the client key passed in HTTP Authorization header matches the service key
|
||||||
or if the or username/password passed in the header matches an administrator user.
|
or if the or username/password passed in the header matches an administrator user.
|
||||||
Returns 'OK' if the key is good or the user is an administrator, otherwise an error message."""
|
Returns a tuple of the user's email address and list of user privileges (e.g.
|
||||||
|
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
|
||||||
|
If the user used an API key, the user's email is returned as None."""
|
||||||
|
|
||||||
def decode(s):
|
def decode(s):
|
||||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||||
@@ -63,46 +65,64 @@ class KeyAuthService:
|
|||||||
|
|
||||||
header = request.headers.get('Authorization')
|
header = request.headers.get('Authorization')
|
||||||
if not header:
|
if not header:
|
||||||
return "No authorization header provided."
|
raise ValueError("No authorization header provided.")
|
||||||
|
|
||||||
username, password = parse_basic_auth(header)
|
username, password = parse_basic_auth(header)
|
||||||
|
|
||||||
if username in (None, ""):
|
if username in (None, ""):
|
||||||
return "Authorization header invalid."
|
raise ValueError("Authorization header invalid.")
|
||||||
elif username == self.key:
|
elif username == self.key:
|
||||||
return "OK"
|
# The user passed the API key which grants administrative privs.
|
||||||
|
return (None, ["admin"])
|
||||||
else:
|
else:
|
||||||
return self.check_imap_login( username, password, env)
|
# The user is trying to log in with a username and user-specific
|
||||||
|
# API key or password. Raises or returns privs.
|
||||||
|
return (username, self.get_user_credentials(username, password, env))
|
||||||
|
|
||||||
def check_imap_login(self, email, pw, env):
|
def get_user_credentials(self, email, pw, env):
|
||||||
# Validate a user's credentials.
|
# Validate a user's credentials. On success returns a list of
|
||||||
|
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
|
||||||
|
# with a login error message.
|
||||||
|
|
||||||
# Sanity check.
|
# Sanity check.
|
||||||
if email == "" or pw == "":
|
if email == "" or pw == "":
|
||||||
return "Enter an email address and password."
|
raise ValueError("Enter an email address and password.")
|
||||||
|
|
||||||
|
# The password might be a user-specific API key.
|
||||||
|
if hmac.compare_digest(self.create_user_key(email), pw):
|
||||||
|
# OK.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Get the hashed password of the user. Raise a ValueError if the
|
||||||
|
# email address does not correspond to a user.
|
||||||
|
pw_hash = get_mail_password(email, env)
|
||||||
|
|
||||||
# Authenticate.
|
# Authenticate.
|
||||||
try:
|
try:
|
||||||
# Use doveadm to check credentials. doveadm will return
|
# Use 'doveadm pw' to check credentials. doveadm will return
|
||||||
# a non-zero exit status if the credentials are no good,
|
# a non-zero exit status if the credentials are no good,
|
||||||
# and check_call will raise an exception in that case.
|
# and check_call will raise an exception in that case.
|
||||||
utils.shell('check_call', [
|
utils.shell('check_call', [
|
||||||
"/usr/bin/doveadm",
|
"/usr/bin/doveadm", "pw",
|
||||||
"auth", "test",
|
"-p", pw,
|
||||||
email, pw
|
"-t", pw_hash,
|
||||||
])
|
])
|
||||||
except:
|
except:
|
||||||
# Login failed.
|
# Login failed.
|
||||||
return "Invalid email address or password."
|
raise ValueError("Invalid password.")
|
||||||
|
|
||||||
# Authorize.
|
# Get privileges for authorization. This call should never fail on a valid user,
|
||||||
# (This call should never fail on a valid user.)
|
# but if the caller passed a user-specific API key then the user may no longer
|
||||||
|
# exist --- in that case, get_mail_user_privileges will return a tuple of an
|
||||||
|
# error message and an HTTP status code.
|
||||||
privs = get_mail_user_privileges(email, env)
|
privs = get_mail_user_privileges(email, env)
|
||||||
if isinstance(privs, tuple): raise Exception("Error getting privileges.")
|
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||||
if "admin" not in privs:
|
|
||||||
return "You are not an administrator for this system."
|
|
||||||
|
|
||||||
return "OK"
|
# Return a list of privileges.
|
||||||
|
return privs
|
||||||
|
|
||||||
|
def create_user_key(self, email):
|
||||||
|
return hmac.new(self.key.encode('ascii'), b"AUTH:" + email.encode("utf8"), digestmod="sha1").hexdigest()
|
||||||
|
|
||||||
def _generate_key(self):
|
def _generate_key(self):
|
||||||
raw_key = os.urandom(32)
|
raw_key = os.urandom(32)
|
||||||
|
|||||||
@@ -2,40 +2,43 @@
|
|||||||
|
|
||||||
# This script performs a backup of all user data:
|
# This script performs a backup of all user data:
|
||||||
# 1) System services are stopped while a copy of user data is made.
|
# 1) System services are stopped while a copy of user data is made.
|
||||||
# 2) An incremental backup is made using duplicity into the
|
# 2) An incremental encrypted backup is made using duplicity into the
|
||||||
# directory STORAGE_ROOT/backup/duplicity.
|
# directory STORAGE_ROOT/backup/encrypted. The password used for
|
||||||
|
# encryption is stored in backup/secret_key.txt.
|
||||||
# 3) The stopped services are restarted.
|
# 3) The stopped services are restarted.
|
||||||
# 4) The backup files are encrypted with a long password (stored in
|
|
||||||
# backup/secret_key.txt) to STORAGE_ROOT/backup/encrypted.
|
|
||||||
# 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
|
# 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
|
||||||
|
|
||||||
import os, os.path, shutil, glob, re, datetime
|
import os, os.path, shutil, glob, re, datetime
|
||||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||||
|
|
||||||
from utils import exclusive_process, load_environment, shell
|
from utils import exclusive_process, load_environment, shell, wait_for_service
|
||||||
|
|
||||||
# destroy backups when the most recent increment in the chain
|
# Destroy backups when the most recent increment in the chain
|
||||||
# that depends on it is this many days old.
|
# that depends on it is this many days old.
|
||||||
keep_backups_for_days = 14
|
keep_backups_for_days = 3
|
||||||
|
|
||||||
def backup_status(env):
|
def backup_status(env):
|
||||||
# What is the current status of backups?
|
# What is the current status of backups?
|
||||||
# Loop through all of the files in STORAGE_ROOT/backup/duplicity to
|
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
|
||||||
# get a list of all of the backups taken and sum up file sizes to
|
# get a list of all of the backups taken and sum up file sizes to
|
||||||
# see how large the storage is.
|
# see how large the storage is.
|
||||||
|
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
def reldate(date, ref):
|
def reldate(date, ref, clip):
|
||||||
|
if ref < date: return clip
|
||||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||||
|
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
|
||||||
|
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days)
|
||||||
if rd.days >= 7: return "%d days" % rd.days
|
if rd.days >= 7: return "%d days" % rd.days
|
||||||
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
|
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
|
||||||
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
||||||
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||||
|
|
||||||
backups = { }
|
backups = { }
|
||||||
basedir = os.path.join(env['STORAGE_ROOT'], 'backup/duplicity/')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
encdir = os.path.join(env['STORAGE_ROOT'], 'backup/encrypted/')
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
for fn in os.listdir(basedir):
|
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
|
||||||
|
for fn in os.listdir(backup_dir):
|
||||||
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
|
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
|
||||||
if not m: raise ValueError(fn)
|
if not m: raise ValueError(fn)
|
||||||
|
|
||||||
@@ -45,27 +48,41 @@ def backup_status(env):
|
|||||||
backups[key] = {
|
backups[key] = {
|
||||||
"date": m.group("date"),
|
"date": m.group("date"),
|
||||||
"date_str": date.strftime("%x %X"),
|
"date_str": date.strftime("%x %X"),
|
||||||
"date_delta": reldate(date, now),
|
"date_delta": reldate(date, now, "the future?"),
|
||||||
"full": m.group("incbase") is None,
|
"full": m.group("incbase") is None,
|
||||||
"previous": m.group("incbase"),
|
"previous": m.group("incbase"),
|
||||||
"size": 0,
|
"size": 0,
|
||||||
"encsize": 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backups[key]["size"] += os.path.getsize(os.path.join(basedir, fn))
|
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
|
||||||
|
|
||||||
# Also check encrypted size.
|
|
||||||
encfn = os.path.join(encdir, fn + ".enc")
|
|
||||||
if os.path.exists(encfn):
|
|
||||||
backups[key]["encsize"] += os.path.getsize(encfn)
|
|
||||||
|
|
||||||
# Ensure the rows are sorted reverse chronologically.
|
# Ensure the rows are sorted reverse chronologically.
|
||||||
# This is relied on by should_force_full() and the next step.
|
# This is relied on by should_force_full() and the next step.
|
||||||
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
||||||
|
|
||||||
|
# Get the average size of incremental backups and the size of the
|
||||||
|
# most recent full backup.
|
||||||
|
incremental_count = 0
|
||||||
|
incremental_size = 0
|
||||||
|
first_full_size = None
|
||||||
|
for bak in backups:
|
||||||
|
if bak["full"]:
|
||||||
|
first_full_size = bak["size"]
|
||||||
|
break
|
||||||
|
incremental_count += 1
|
||||||
|
incremental_size += bak["size"]
|
||||||
|
|
||||||
|
# Predict how many more increments until the next full backup,
|
||||||
|
# and add to that the time we hold onto backups, to predict
|
||||||
|
# how long the most recent full backup+increments will be held
|
||||||
|
# onto. Round up since the backup occurs on the night following
|
||||||
|
# when the threshold is met.
|
||||||
|
deleted_in = None
|
||||||
|
if incremental_count > 0 and first_full_size is not None:
|
||||||
|
deleted_in = "approx. %d days" % round(keep_backups_for_days + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
|
||||||
|
|
||||||
# When will a backup be deleted?
|
# When will a backup be deleted?
|
||||||
saw_full = False
|
saw_full = False
|
||||||
deleted_in = None
|
|
||||||
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
||||||
for bak in backups:
|
for bak in backups:
|
||||||
if deleted_in:
|
if deleted_in:
|
||||||
@@ -78,13 +95,12 @@ def backup_status(env):
|
|||||||
deleted_in = None
|
deleted_in = None
|
||||||
elif saw_full and not deleted_in:
|
elif saw_full and not deleted_in:
|
||||||
# Mark deleted_in only on the first increment after a full backup.
|
# Mark deleted_in only on the first increment after a full backup.
|
||||||
deleted_in = reldate(days_ago, dateutil.parser.parse(bak["date"]))
|
deleted_in = reldate(days_ago, dateutil.parser.parse(bak["date"]), "on next daily backup")
|
||||||
bak["deleted_in"] = deleted_in
|
bak["deleted_in"] = deleted_in
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"directory": basedir,
|
"directory": backup_dir,
|
||||||
"encpwfile": os.path.join(env['STORAGE_ROOT'], 'backup/secret_key.txt'),
|
"encpwfile": os.path.join(backup_root, 'secret_key.txt'),
|
||||||
"encdirectory": encdir,
|
|
||||||
"tz": now.tzname(),
|
"tz": now.tzname(),
|
||||||
"backups": backups,
|
"backups": backups,
|
||||||
}
|
}
|
||||||
@@ -113,10 +129,35 @@ def perform_backup(full_backup):
|
|||||||
|
|
||||||
exclusive_process("backup")
|
exclusive_process("backup")
|
||||||
|
|
||||||
# Ensure the backup directory exists.
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
os.makedirs(backup_duplicity_dir, exist_ok=True)
|
|
||||||
|
# In an older version of this script, duplicity was called
|
||||||
|
# such that it did not encrypt the backups it created (in
|
||||||
|
# backup/duplicity), and instead openssl was called separately
|
||||||
|
# after each backup run, creating AES256 encrypted copies of
|
||||||
|
# each file created by duplicity in backup/encrypted.
|
||||||
|
#
|
||||||
|
# We detect the transition by the presence of backup/duplicity
|
||||||
|
# and handle it by 'dupliception': we move all the old *un*encrypted
|
||||||
|
# duplicity files up out of the backup/duplicity directory (as
|
||||||
|
# backup/ is excluded from duplicity runs) in order that it is
|
||||||
|
# included in the next run, and we delete backup/encrypted (which
|
||||||
|
# duplicity will output files directly to, post-transition).
|
||||||
|
old_backup_dir = os.path.join(backup_root, 'duplicity')
|
||||||
|
migrated_unencrypted_backup_dir = os.path.join(env["STORAGE_ROOT"], "migrated_unencrypted_backup")
|
||||||
|
if os.path.isdir(old_backup_dir):
|
||||||
|
# Move the old unencrpyted files to a new location outside of
|
||||||
|
# the backup root so they get included in the next (new) backup.
|
||||||
|
# Then we'll delete them. Also so that they do not get in the
|
||||||
|
# way of duplicity doing a full backup on the first run after
|
||||||
|
# we take care of this.
|
||||||
|
shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)
|
||||||
|
|
||||||
|
# The backup_dir (backup/encrypted) now has a new purpose.
|
||||||
|
# Clear it out.
|
||||||
|
shutil.rmtree(backup_dir)
|
||||||
|
|
||||||
# On the first run, always do a full backup. Incremental
|
# On the first run, always do a full backup. Incremental
|
||||||
# will fail. Otherwise do a full backup when the size of
|
# will fail. Otherwise do a full backup when the size of
|
||||||
@@ -128,81 +169,111 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
||||||
|
|
||||||
|
# Get the encryption passphrase. secret_key.txt is 2048 random
|
||||||
|
# bits base64-encoded and with line breaks every 65 characters.
|
||||||
|
# gpg will only take the first line of text, so sanity check that
|
||||||
|
# that line is long enough to be a reasonable passphrase. It
|
||||||
|
# only needs to be 43 base64-characters to match AES256's key
|
||||||
|
# length of 32 bytes.
|
||||||
|
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
||||||
|
passphrase = f.readline().strip()
|
||||||
|
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
||||||
|
env_with_passphrase = { "PASSPHRASE" : passphrase }
|
||||||
|
|
||||||
# Update the backup mirror directory which mirrors the current
|
# Update the backup mirror directory which mirrors the current
|
||||||
# STORAGE_ROOT (but excluding the backups themselves!).
|
# STORAGE_ROOT (but excluding the backups themselves!).
|
||||||
try:
|
try:
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"full" if full_backup else "incr",
|
"full" if full_backup else "incr",
|
||||||
"--no-encryption",
|
"--archive-dir", backup_cache_dir,
|
||||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
"--exclude", backup_root,
|
||||||
"--name", "mailinabox",
|
"--volsize", "250",
|
||||||
"--exclude", backup_dir,
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
"--volsize", "100",
|
|
||||||
"--verbosity", "warning",
|
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
"file://" + backup_duplicity_dir
|
"file://" + backup_dir
|
||||||
])
|
],
|
||||||
|
env_with_passphrase)
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
||||||
|
|
||||||
|
# Once the migrated backup is included in a new backup, it can be deleted.
|
||||||
|
if os.path.isdir(migrated_unencrypted_backup_dir):
|
||||||
|
shutil.rmtree(migrated_unencrypted_backup_dir)
|
||||||
|
|
||||||
# Remove old backups. This deletes all backup data no longer needed
|
# Remove old backups. This deletes all backup data no longer needed
|
||||||
# from more than 31 days ago. Must do this before destroying the
|
# from more than 3 days ago.
|
||||||
# cache directory or else this command will re-create it.
|
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"remove-older-than",
|
"remove-older-than",
|
||||||
"%dD" % keep_backups_for_days,
|
"%dD" % keep_backups_for_days,
|
||||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
"--archive-dir", backup_cache_dir,
|
||||||
"--name", "mailinabox",
|
|
||||||
"--force",
|
"--force",
|
||||||
"--verbosity", "warning",
|
"file://" + backup_dir
|
||||||
"file://" + backup_duplicity_dir
|
],
|
||||||
])
|
env_with_passphrase)
|
||||||
|
|
||||||
# Remove duplicity's cache directory because it's redundant with our backup directory.
|
# From duplicity's manual:
|
||||||
shutil.rmtree("/tmp/duplicity-archive-dir")
|
# "This should only be necessary after a duplicity session fails or is
|
||||||
|
# aborted prematurely."
|
||||||
# Encrypt all of the new files.
|
# That may be unlikely here but we may as well ensure we tidy up if
|
||||||
backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
|
# that does happen - it might just have been a poorly timed reboot.
|
||||||
os.makedirs(backup_encrypted_dir, exist_ok=True)
|
|
||||||
for fn in os.listdir(backup_duplicity_dir):
|
|
||||||
fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
|
|
||||||
if os.path.exists(fn2): continue
|
|
||||||
|
|
||||||
# Encrypt the backup using the backup private key.
|
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/openssl",
|
"/usr/bin/duplicity",
|
||||||
"enc",
|
"cleanup",
|
||||||
"-aes-256-cbc",
|
"--archive-dir", backup_cache_dir,
|
||||||
"-a",
|
"--force",
|
||||||
"-salt",
|
"file://" + backup_dir
|
||||||
"-in", os.path.join(backup_duplicity_dir, fn),
|
],
|
||||||
"-out", fn2,
|
env_with_passphrase)
|
||||||
"-pass", "file:%s" % os.path.join(backup_dir, "secret_key.txt"),
|
|
||||||
])
|
|
||||||
|
|
||||||
# The backup can be decrypted with:
|
# Change ownership of backups to the user-data user, so that the after-bcakup
|
||||||
# openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z
|
# script can access them.
|
||||||
|
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||||
# Remove encrypted backups that are no longer needed.
|
|
||||||
for fn in os.listdir(backup_encrypted_dir):
|
|
||||||
fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
|
|
||||||
if os.path.exists(fn2): continue
|
|
||||||
os.unlink(os.path.join(backup_encrypted_dir, fn))
|
|
||||||
|
|
||||||
# Execute a post-backup script that does the copying to a remote server.
|
# Execute a post-backup script that does the copying to a remote server.
|
||||||
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||||
# environment variables so the script has access to STORAGE_ROOT.
|
# environment variables so the script has access to STORAGE_ROOT.
|
||||||
post_script = os.path.join(backup_dir, 'after-backup')
|
post_script = os.path.join(backup_root, 'after-backup')
|
||||||
if os.path.exists(post_script):
|
if os.path.exists(post_script):
|
||||||
shell('check_call',
|
shell('check_call',
|
||||||
['su', env['STORAGE_USER'], '-c', post_script],
|
['su', env['STORAGE_USER'], '-c', post_script],
|
||||||
env=env)
|
env=env)
|
||||||
|
|
||||||
|
# Our nightly cron job executes system status checks immediately after this
|
||||||
|
# backup. Since it checks that dovecot and postfix are running, block for a
|
||||||
|
# bit (maximum of 10 seconds each) to give each a chance to finish restarting
|
||||||
|
# before the status checks might catch them down. See #381.
|
||||||
|
wait_for_service(25, True, env, 10)
|
||||||
|
wait_for_service(993, True, env, 10)
|
||||||
|
|
||||||
|
def run_duplicity_verification():
|
||||||
|
env = load_environment()
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
|
env_with_passphrase = { "PASSPHRASE" : open(os.path.join(backup_root, 'secret_key.txt')).read() }
|
||||||
|
shell('check_call', [
|
||||||
|
"/usr/bin/duplicity",
|
||||||
|
"--verbosity", "info",
|
||||||
|
"verify",
|
||||||
|
"--compare-data",
|
||||||
|
"--archive-dir", backup_cache_dir,
|
||||||
|
"--exclude", backup_root,
|
||||||
|
"file://" + backup_dir,
|
||||||
|
env["STORAGE_ROOT"],
|
||||||
|
], env_with_passphrase)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
if sys.argv[-1] == "--verify":
|
||||||
|
# Run duplicity's verification command to check a) the backup files
|
||||||
|
# are readable, and b) report if they are up to date.
|
||||||
|
run_duplicity_verification()
|
||||||
|
else:
|
||||||
|
# Perform a backup. Add --full to force a full backup rather than
|
||||||
|
# possibly performing an incremental backup.
|
||||||
full_backup = "--full" in sys.argv
|
full_backup = "--full" in sys.argv
|
||||||
perform_backup(full_backup)
|
perform_backup(full_backup)
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
# Helps you purchase a SSL certificate from Gandi.net using
|
|
||||||
# their API.
|
|
||||||
#
|
|
||||||
# Before you begin:
|
|
||||||
# 1) Create an account on Gandi.net.
|
|
||||||
# 2) Pre-pay $16 into your account at https://www.gandi.net/prepaid/operations. Wait until the payment goes through.
|
|
||||||
# 3) Activate your API key first on the test platform (wait a while, refresh the page) and then activate the production API at https://www.gandi.net/admin/api_key.
|
|
||||||
|
|
||||||
import sys, re, os.path, urllib.request
|
|
||||||
import xmlrpc.client
|
|
||||||
import rtyaml
|
|
||||||
|
|
||||||
from utils import load_environment, shell
|
|
||||||
from web_update import get_web_domains, get_domain_ssl_files, get_web_root
|
|
||||||
from status_checks import check_certificate
|
|
||||||
|
|
||||||
def buy_ssl_certificate(api_key, domain, command, env):
|
|
||||||
if domain != env['PRIMARY_HOSTNAME'] \
|
|
||||||
and domain not in get_web_domains(env):
|
|
||||||
raise ValueError("Domain is not %s or a domain we're serving a website for." % env['PRIMARY_HOSTNAME'])
|
|
||||||
|
|
||||||
# Initialize.
|
|
||||||
|
|
||||||
gandi = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/')
|
|
||||||
|
|
||||||
try:
|
|
||||||
existing_certs = gandi.cert.list(api_key)
|
|
||||||
except Exception as e:
|
|
||||||
if "Invalid API key" in str(e):
|
|
||||||
print("Invalid API key. Check that you copied the API Key correctly from https://www.gandi.net/admin/api_key.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Where is the SSL cert stored?
|
|
||||||
|
|
||||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
|
||||||
|
|
||||||
# Have we already created a cert for this domain?
|
|
||||||
|
|
||||||
for cert in existing_certs:
|
|
||||||
if cert['cn'] == domain:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# No existing cert found. Purchase one.
|
|
||||||
if command != 'purchase':
|
|
||||||
print("No certificate or order found yet. If you haven't yet purchased a certificate, run ths script again with the 'purchase' command. Otherwise wait a moment and try again.")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
# Start an order for a single standard SSL certificate.
|
|
||||||
# Use DNS validation. Web-based validation won't work because they
|
|
||||||
# require a file on HTTP but not HTTPS w/o redirects and we don't
|
|
||||||
# serve anything plainly over HTTP. Email might be another way but
|
|
||||||
# DNS is easier to automate.
|
|
||||||
op = gandi.cert.create(api_key, {
|
|
||||||
"csr": open(ssl_csr_path).read(),
|
|
||||||
"dcv_method": "dns",
|
|
||||||
"duration": 1, # year?
|
|
||||||
"package": "cert_std_1_0_0",
|
|
||||||
})
|
|
||||||
print("An SSL certificate has been ordered.")
|
|
||||||
print()
|
|
||||||
print(op)
|
|
||||||
print()
|
|
||||||
print("In a moment please run this script again with the 'setup' command.")
|
|
||||||
|
|
||||||
if cert['status'] == 'pending':
|
|
||||||
# Get the information we need to update our DNS with a code so that
|
|
||||||
# Gandi can verify that we own the domain.
|
|
||||||
|
|
||||||
dcv = gandi.cert.get_dcv_params(api_key, {
|
|
||||||
"csr": open(ssl_csr_path).read(),
|
|
||||||
"cert_id": cert['id'],
|
|
||||||
"dcv_method": "dns",
|
|
||||||
"duration": 1, # year?
|
|
||||||
"package": "cert_std_1_0_0",
|
|
||||||
})
|
|
||||||
if dcv["dcv_method"] != "dns":
|
|
||||||
raise Exception("Certificate ordered with an unknown validation method.")
|
|
||||||
|
|
||||||
# Update our DNS data.
|
|
||||||
|
|
||||||
dns_config = env['STORAGE_ROOT'] + '/dns/custom.yaml'
|
|
||||||
if os.path.exists(dns_config):
|
|
||||||
dns_records = rtyaml.load(open(dns_config))
|
|
||||||
else:
|
|
||||||
dns_records = { }
|
|
||||||
|
|
||||||
qname = dcv['md5'] + '.' + domain
|
|
||||||
value = dcv['sha1'] + '.comodoca.com.'
|
|
||||||
dns_records[qname] = { "CNAME": value }
|
|
||||||
|
|
||||||
with open(dns_config, 'w') as f:
|
|
||||||
f.write(rtyaml.dump(dns_records))
|
|
||||||
|
|
||||||
shell('check_call', ['tools/dns_update'])
|
|
||||||
|
|
||||||
# Okay, done with this step.
|
|
||||||
|
|
||||||
print("DNS has been updated. Gandi will check within 60 minutes.")
|
|
||||||
print()
|
|
||||||
print("See https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
|
|
||||||
|
|
||||||
elif cert['status'] == 'valid':
|
|
||||||
# The certificate is ready.
|
|
||||||
|
|
||||||
# Check before we overwrite something we shouldn't.
|
|
||||||
if os.path.exists(ssl_certificate):
|
|
||||||
cert_status = check_certificate(None, ssl_certificate, None)
|
|
||||||
if cert_status != "SELF-SIGNED":
|
|
||||||
print("Please back up and delete the file %s so I can save your new certificate." % ssl_certificate)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Form the certificate.
|
|
||||||
|
|
||||||
# The certificate comes as a long base64-encoded string. Break in
|
|
||||||
# into lines in the usual way.
|
|
||||||
pem = "-----BEGIN CERTIFICATE-----\n"
|
|
||||||
pem += "\n".join(chunk for chunk in re.split(r"(.{64})", cert['cert']) if chunk != "")
|
|
||||||
pem += "\n-----END CERTIFICATE-----\n\n"
|
|
||||||
|
|
||||||
# Append intermediary certificates.
|
|
||||||
pem += urllib.request.urlopen("https://www.gandi.net/static/CAs/GandiStandardSSLCA.pem").read().decode("ascii")
|
|
||||||
|
|
||||||
# Write out.
|
|
||||||
|
|
||||||
with open(ssl_certificate, "w") as f:
|
|
||||||
f.write(pem)
|
|
||||||
|
|
||||||
print("The certificate has been installed in %s. Restarting services..." % ssl_certificate)
|
|
||||||
|
|
||||||
# Restart dovecot and if this is for PRIMARY_HOSTNAME.
|
|
||||||
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "restart"])
|
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "restart"])
|
|
||||||
|
|
||||||
# Restart nginx in all cases.
|
|
||||||
|
|
||||||
shell('check_call', ["/usr/sbin/service", "nginx", "restart"])
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("The certificate has an unknown status. Please check https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 4:
|
|
||||||
print("Usage: python management/buy_certificate.py gandi_api_key domain_name {purchase, setup}")
|
|
||||||
sys.exit(1)
|
|
||||||
api_key = sys.argv[1]
|
|
||||||
domain_name = sys.argv[2]
|
|
||||||
cmd = sys.argv[3]
|
|
||||||
buy_ssl_certificate(api_key, domain_name, cmd, load_environment())
|
|
||||||
|
|
||||||
@@ -7,9 +7,15 @@ from functools import wraps
|
|||||||
from flask import Flask, request, render_template, abort, Response
|
from flask import Flask, request, render_template, abort, Response
|
||||||
|
|
||||||
import auth, utils
|
import auth, utils
|
||||||
from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_archived_mail_users
|
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
||||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
||||||
from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias
|
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||||
|
|
||||||
|
# Create a worker pool for the status checks. The pool should
|
||||||
|
# live across http requests so we don't baloon the system with
|
||||||
|
# processes.
|
||||||
|
import multiprocessing.pool
|
||||||
|
pool = multiprocessing.pool.Pool(processes=10)
|
||||||
|
|
||||||
env = utils.load_environment()
|
env = utils.load_environment()
|
||||||
|
|
||||||
@@ -24,19 +30,32 @@ except OSError:
|
|||||||
|
|
||||||
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
||||||
|
|
||||||
# Decorator to protect views that require authentication.
|
# Decorator to protect views that require a user with 'admin' privileges.
|
||||||
def authorized_personnel_only(viewfunc):
|
def authorized_personnel_only(viewfunc):
|
||||||
@wraps(viewfunc)
|
@wraps(viewfunc)
|
||||||
def newview(*args, **kwargs):
|
def newview(*args, **kwargs):
|
||||||
# Check if the user is authorized.
|
# Authenticate the passed credentials, which is either the API key or a username:password pair.
|
||||||
authorized_status = auth_service.is_authenticated(request, env)
|
error = None
|
||||||
if authorized_status == "OK":
|
try:
|
||||||
# Authorized. Call view func.
|
email, privs = auth_service.authenticate(request, env)
|
||||||
|
except ValueError as e:
|
||||||
|
# Authentication failed.
|
||||||
|
privs = []
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
# Authorized to access an API view?
|
||||||
|
if "admin" in privs:
|
||||||
|
# Call view func.
|
||||||
return viewfunc(*args, **kwargs)
|
return viewfunc(*args, **kwargs)
|
||||||
|
elif not error:
|
||||||
|
error = "You are not an administrator."
|
||||||
|
|
||||||
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
||||||
status = 401
|
status = 401
|
||||||
headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) }
|
headers = {
|
||||||
|
'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm),
|
||||||
|
'X-Reason': error,
|
||||||
|
}
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
# Don't issue a 401 to an AJAX request because the user will
|
# Don't issue a 401 to an AJAX request because the user will
|
||||||
@@ -46,13 +65,13 @@ def authorized_personnel_only(viewfunc):
|
|||||||
|
|
||||||
if request.headers.get('Accept') in (None, "", "*/*"):
|
if request.headers.get('Accept') in (None, "", "*/*"):
|
||||||
# Return plain text output.
|
# Return plain text output.
|
||||||
return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers)
|
return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
|
||||||
else:
|
else:
|
||||||
# Return JSON output.
|
# Return JSON output.
|
||||||
return Response(json.dumps({
|
return Response(json.dumps({
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"reason": authorized_status
|
"reason": error,
|
||||||
}+"\n"), status=status, mimetype='application/json', headers=headers)
|
})+"\n", status=status, mimetype='application/json', headers=headers)
|
||||||
|
|
||||||
return newview
|
return newview
|
||||||
|
|
||||||
@@ -61,7 +80,7 @@ def unauthorized(error):
|
|||||||
return auth_service.make_unauthorized_response()
|
return auth_service.make_unauthorized_response()
|
||||||
|
|
||||||
def json_response(data):
|
def json_response(data):
|
||||||
return Response(json.dumps(data), status=200, mimetype='application/json')
|
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
|
||||||
|
|
||||||
###################################
|
###################################
|
||||||
|
|
||||||
@@ -71,33 +90,46 @@ def json_response(data):
|
|||||||
def index():
|
def index():
|
||||||
# Render the control panel. This route does not require user authentication
|
# Render the control panel. This route does not require user authentication
|
||||||
# so it must be safe!
|
# so it must be safe!
|
||||||
no_admins_exist = (len([user for user in get_mail_users(env, as_json=True) if "admin" in user['privileges']]) == 0)
|
no_users_exist = (len(get_mail_users(env)) == 0)
|
||||||
|
no_admins_exist = (len(get_admins(env)) == 0)
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
hostname=env['PRIMARY_HOSTNAME'],
|
hostname=env['PRIMARY_HOSTNAME'],
|
||||||
|
storage_root=env['STORAGE_ROOT'],
|
||||||
|
no_users_exist=no_users_exist,
|
||||||
no_admins_exist=no_admins_exist,
|
no_admins_exist=no_admins_exist,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/me')
|
@app.route('/me')
|
||||||
def me():
|
def me():
|
||||||
# Is the caller authorized?
|
# Is the caller authorized?
|
||||||
authorized_status = auth_service.is_authenticated(request, env)
|
try:
|
||||||
if authorized_status != "OK":
|
email, privs = auth_service.authenticate(request, env)
|
||||||
|
except ValueError as e:
|
||||||
return json_response({
|
return json_response({
|
||||||
"status": "not-authorized",
|
"status": "invalid",
|
||||||
"reason": authorized_status,
|
"reason": str(e),
|
||||||
})
|
|
||||||
return json_response({
|
|
||||||
"status": "authorized",
|
|
||||||
"api_key": auth_service.key,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"status": "ok",
|
||||||
|
"email": email,
|
||||||
|
"privileges": privs,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Is authorized as admin? Return an API key for future use.
|
||||||
|
if "admin" in privs:
|
||||||
|
resp["api_key"] = auth_service.create_user_key(email)
|
||||||
|
|
||||||
|
# Return.
|
||||||
|
return json_response(resp)
|
||||||
|
|
||||||
# MAIL
|
# MAIL
|
||||||
|
|
||||||
@app.route('/mail/users')
|
@app.route('/mail/users')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_users():
|
def mail_users():
|
||||||
if request.args.get("format", "") == "json":
|
if request.args.get("format", "") == "json":
|
||||||
return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env))
|
return json_response(get_mail_users_ex(env, with_archived=True, with_slow_info=True))
|
||||||
else:
|
else:
|
||||||
return "".join(x+"\n" for x in get_mail_users(env))
|
return "".join(x+"\n" for x in get_mail_users(env))
|
||||||
|
|
||||||
@@ -145,7 +177,7 @@ def mail_user_privs_remove():
|
|||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_aliases():
|
def mail_aliases():
|
||||||
if request.args.get("format", "") == "json":
|
if request.args.get("format", "") == "json":
|
||||||
return json_response(get_mail_aliases(env, as_json=True))
|
return json_response(get_mail_aliases_ex(env))
|
||||||
else:
|
else:
|
||||||
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
||||||
|
|
||||||
@@ -171,6 +203,12 @@ def mail_domains():
|
|||||||
|
|
||||||
# DNS
|
# DNS
|
||||||
|
|
||||||
|
@app.route('/dns/zones')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def dns_zones():
|
||||||
|
from dns_update import get_dns_zones
|
||||||
|
return json_response([z[0] for z in get_dns_zones(env)])
|
||||||
|
|
||||||
@app.route('/dns/update', methods=['POST'])
|
@app.route('/dns/update', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_update():
|
def dns_update():
|
||||||
@@ -180,26 +218,85 @@ def dns_update():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return (str(e), 500)
|
return (str(e), 500)
|
||||||
|
|
||||||
@app.route('/dns/set/<qname>', methods=['POST'])
|
@app.route('/dns/secondary-nameserver')
|
||||||
@app.route('/dns/set/<qname>/<rtype>', methods=['POST'])
|
|
||||||
@app.route('/dns/set/<qname>/<rtype>/<value>', methods=['POST'])
|
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_set_record(qname, rtype="A", value=None):
|
def dns_get_secondary_nameserver():
|
||||||
|
from dns_update import get_custom_dns_config, get_secondary_dns
|
||||||
|
return json_response({ "hostname": get_secondary_dns(get_custom_dns_config(env)) })
|
||||||
|
|
||||||
|
@app.route('/dns/secondary-nameserver', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def dns_set_secondary_nameserver():
|
||||||
|
from dns_update import set_secondary_dns
|
||||||
|
try:
|
||||||
|
return set_secondary_dns(request.form.get('hostname'), env)
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
|
||||||
|
@app.route('/dns/custom')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def dns_get_records(qname=None, rtype=None):
|
||||||
|
from dns_update import get_custom_dns_config
|
||||||
|
return json_response([
|
||||||
|
{
|
||||||
|
"qname": r[0],
|
||||||
|
"rtype": r[1],
|
||||||
|
"value": r[2],
|
||||||
|
}
|
||||||
|
for r in get_custom_dns_config(env)
|
||||||
|
if r[0] != "_secondary_nameserver"
|
||||||
|
and (not qname or r[0] == qname)
|
||||||
|
and (not rtype or r[1] == rtype) ])
|
||||||
|
|
||||||
|
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
||||||
|
@app.route('/dns/custom/<qname>/<rtype>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def dns_set_record(qname, rtype="A"):
|
||||||
from dns_update import do_dns_update, set_custom_dns_record
|
from dns_update import do_dns_update, set_custom_dns_record
|
||||||
try:
|
try:
|
||||||
# Get the value from the URL, then the POST parameters, or if it is not set then
|
# Normalize.
|
||||||
# use the remote IP address of the request --- makes dynamic DNS easy. To clear a
|
rtype = rtype.upper()
|
||||||
# value, '' must be explicitly passed.
|
|
||||||
if value is None:
|
# Read the record value from the request BODY, which must be
|
||||||
value = request.form.get("value")
|
# ASCII-only. Not used with GET.
|
||||||
if value is None:
|
value = request.stream.read().decode("ascii", "ignore").strip()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
# Get the existing records matching the qname and rtype.
|
||||||
|
return dns_get_records(qname, rtype)
|
||||||
|
|
||||||
|
elif request.method in ("POST", "PUT"):
|
||||||
|
# There is a default value for A/AAAA records.
|
||||||
|
if rtype in ("A", "AAAA") and value == "":
|
||||||
value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy
|
value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy
|
||||||
if value == '' or value == '__delete__':
|
|
||||||
# request deletion
|
# Cannot add empty records.
|
||||||
|
if value == '':
|
||||||
|
return ("No value for the record provided.", 400)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
# Add a new record (in addition to any existing records
|
||||||
|
# for this qname-rtype pair).
|
||||||
|
action = "add"
|
||||||
|
elif request.method == "PUT":
|
||||||
|
# In REST, PUT is supposed to be idempotent, so we'll
|
||||||
|
# make this action set (replace all records for this
|
||||||
|
# qname-rtype pair) rather than add (add a new record).
|
||||||
|
action = "set"
|
||||||
|
|
||||||
|
elif request.method == "DELETE":
|
||||||
|
if value == '':
|
||||||
|
# Delete all records for this qname-type pair.
|
||||||
value = None
|
value = None
|
||||||
if set_custom_dns_record(qname, rtype, value, env):
|
else:
|
||||||
return do_dns_update(env)
|
# Delete just the qname-rtype-value record exactly.
|
||||||
|
pass
|
||||||
|
action = "remove"
|
||||||
|
|
||||||
|
if set_custom_dns_record(qname, rtype, value, action, env):
|
||||||
|
return do_dns_update(env) or "Something isn't right."
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return (str(e), 400)
|
return (str(e), 400)
|
||||||
|
|
||||||
@@ -209,8 +306,32 @@ def dns_get_dump():
|
|||||||
from dns_update import build_recommended_dns
|
from dns_update import build_recommended_dns
|
||||||
return json_response(build_recommended_dns(env))
|
return json_response(build_recommended_dns(env))
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
|
||||||
|
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def ssl_get_csr(domain):
|
||||||
|
from web_update import get_domain_ssl_files, create_csr
|
||||||
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
|
return create_csr(domain, ssl_key, env)
|
||||||
|
|
||||||
|
@app.route('/ssl/install', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def ssl_install_cert():
|
||||||
|
from web_update import install_cert
|
||||||
|
domain = request.form.get('domain')
|
||||||
|
ssl_cert = request.form.get('cert')
|
||||||
|
ssl_chain = request.form.get('chain')
|
||||||
|
return install_cert(domain, ssl_cert, ssl_chain, env)
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
|
|
||||||
|
@app.route('/web/domains')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def web_get_domains():
|
||||||
|
from web_update import get_web_domains_info
|
||||||
|
return json_response(get_web_domains_info(env))
|
||||||
|
|
||||||
@app.route('/web/update', methods=['POST'])
|
@app.route('/web/update', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def web_update():
|
def web_update():
|
||||||
@@ -232,10 +353,12 @@ def system_status():
|
|||||||
self.items.append({ "type": "ok", "text": message, "extra": [] })
|
self.items.append({ "type": "ok", "text": message, "extra": [] })
|
||||||
def print_error(self, message):
|
def print_error(self, message):
|
||||||
self.items.append({ "type": "error", "text": message, "extra": [] })
|
self.items.append({ "type": "error", "text": message, "extra": [] })
|
||||||
|
def print_warning(self, message):
|
||||||
|
self.items.append({ "type": "warning", "text": message, "extra": [] })
|
||||||
def print_line(self, message, monospace=False):
|
def print_line(self, message, monospace=False):
|
||||||
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
||||||
output = WebOutput()
|
output = WebOutput()
|
||||||
run_checks(env, output)
|
run_checks(False, env, output, pool)
|
||||||
return json_response(output.items)
|
return json_response(output.items)
|
||||||
|
|
||||||
@app.route('/system/updates')
|
@app.route('/system/updates')
|
||||||
@@ -278,5 +401,6 @@ if __name__ == '__main__':
|
|||||||
# debug console and enter that as the username
|
# debug console and enter that as the username
|
||||||
app.logger.info('API key: ' + auth_service.key)
|
app.logger.info('API key: ' + auth_service.key)
|
||||||
|
|
||||||
|
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
||||||
app.run(port=10222)
|
app.run(port=10222)
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
# and mail aliases and restarts nsd.
|
# and mail aliases and restarts nsd.
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
import os, os.path, urllib.parse, datetime, re, hashlib, base64
|
import sys, os, os.path, urllib.parse, datetime, re, hashlib, base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import rtyaml
|
import rtyaml
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
from mailconfig import get_mail_domains
|
||||||
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
|
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
|
||||||
@@ -49,19 +50,13 @@ def get_dns_zones(env):
|
|||||||
|
|
||||||
return zonefiles
|
return zonefiles
|
||||||
|
|
||||||
def get_custom_dns_config(env):
|
|
||||||
try:
|
|
||||||
return rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
|
||||||
except:
|
|
||||||
return { }
|
|
||||||
|
|
||||||
def do_dns_update(env, force=False):
|
def do_dns_update(env, force=False):
|
||||||
# What domains (and their zone filenames) should we build?
|
# What domains (and their zone filenames) should we build?
|
||||||
domains = get_dns_domains(env)
|
domains = get_dns_domains(env)
|
||||||
zonefiles = get_dns_zones(env)
|
zonefiles = get_dns_zones(env)
|
||||||
|
|
||||||
# Custom records to add to zones.
|
# Custom records to add to zones.
|
||||||
additional_records = get_custom_dns_config(env)
|
additional_records = list(get_custom_dns_config(env))
|
||||||
|
|
||||||
# Write zone files.
|
# Write zone files.
|
||||||
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
||||||
@@ -105,7 +100,7 @@ def do_dns_update(env, force=False):
|
|||||||
zonefiles[i][1] += ".signed"
|
zonefiles[i][1] += ".signed"
|
||||||
|
|
||||||
# Write the main nsd.conf file.
|
# Write the main nsd.conf file.
|
||||||
if write_nsd_conf(zonefiles, env):
|
if write_nsd_conf(zonefiles, additional_records, env):
|
||||||
# Make sure updated_domains contains *something* if we wrote an updated
|
# Make sure updated_domains contains *something* if we wrote an updated
|
||||||
# nsd.conf so that we know to restart nsd.
|
# nsd.conf so that we know to restart nsd.
|
||||||
if len(updated_domains) == 0:
|
if len(updated_domains) == 0:
|
||||||
@@ -116,7 +111,7 @@ def do_dns_update(env, force=False):
|
|||||||
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
||||||
|
|
||||||
# Write the OpenDKIM configuration tables.
|
# Write the OpenDKIM configuration tables.
|
||||||
if write_opendkim_tables(zonefiles, env):
|
if write_opendkim_tables(domains, env):
|
||||||
# Settings changed. Kick opendkim.
|
# Settings changed. Kick opendkim.
|
||||||
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
|
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
|
||||||
if len(updated_domains) == 0:
|
if len(updated_domains) == 0:
|
||||||
@@ -134,12 +129,22 @@ def do_dns_update(env, force=False):
|
|||||||
def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||||
records = []
|
records = []
|
||||||
|
|
||||||
# For top-level zones, define ourselves as the authoritative name server.
|
# For top-level zones, define the authoritative name servers.
|
||||||
|
#
|
||||||
|
# Normally we are our own nameservers. Some TLDs require two distinct IP addresses,
|
||||||
|
# so we allow the user to override the second nameserver definition so that
|
||||||
|
# secondary DNS can be set up elsewhere.
|
||||||
|
#
|
||||||
# 'False' in the tuple indicates these records would not be used if the zone
|
# 'False' in the tuple indicates these records would not be used if the zone
|
||||||
# is managed outside of the box.
|
# is managed outside of the box.
|
||||||
if is_zone:
|
if is_zone:
|
||||||
|
# Obligatory definition of ns1.PRIMARY_HOSTNAME.
|
||||||
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
||||||
records.append((None, "NS", "ns2.%s." % env["PRIMARY_HOSTNAME"], False))
|
|
||||||
|
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
||||||
|
secondary_ns = get_secondary_dns(additional_records) or ("ns2." + env["PRIMARY_HOSTNAME"])
|
||||||
|
records.append((None, "NS", secondary_ns+'.', False))
|
||||||
|
|
||||||
|
|
||||||
# In PRIMARY_HOSTNAME...
|
# In PRIMARY_HOSTNAME...
|
||||||
if domain == env["PRIMARY_HOSTNAME"]:
|
if domain == env["PRIMARY_HOSTNAME"]:
|
||||||
@@ -167,16 +172,12 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
# The MX record says where email for the domain should be delivered: Here!
|
# The MX record says where email for the domain should be delivered: Here!
|
||||||
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
|
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
|
||||||
|
|
||||||
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
|
||||||
# the domain, and no one else.
|
|
||||||
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
|
|
||||||
|
|
||||||
# Add DNS records for any subdomains of this domain. We should not have a zone for
|
# Add DNS records for any subdomains of this domain. We should not have a zone for
|
||||||
# both a domain and one of its subdomains.
|
# both a domain and one of its subdomains.
|
||||||
subdomains = [d for d in all_domains if d.endswith("." + domain)]
|
subdomains = [d for d in all_domains if d.endswith("." + domain)]
|
||||||
for subdomain in subdomains:
|
for subdomain in subdomains:
|
||||||
subdomain_qname = subdomain[0:-len("." + domain)]
|
subdomain_qname = subdomain[0:-len("." + domain)]
|
||||||
subzone = build_zone(subdomain, [], {}, env, is_zone=False)
|
subzone = build_zone(subdomain, [], additional_records, env, is_zone=False)
|
||||||
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
||||||
if child_qname == None:
|
if child_qname == None:
|
||||||
child_qname = subdomain_qname
|
child_qname = subdomain_qname
|
||||||
@@ -184,18 +185,34 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
child_qname += "." + subdomain_qname
|
child_qname += "." + subdomain_qname
|
||||||
records.append((child_qname, child_rtype, child_value, child_explanation))
|
records.append((child_qname, child_rtype, child_value, child_explanation))
|
||||||
|
|
||||||
def has_rec(qname, rtype):
|
has_rec_base = list(records) # clone current state
|
||||||
for rec in records:
|
def has_rec(qname, rtype, prefix=None):
|
||||||
if rec[0] == qname and rec[1] == rtype:
|
for rec in has_rec_base:
|
||||||
|
if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# The user may set other records that don't conflict with our settings.
|
# The user may set other records that don't conflict with our settings.
|
||||||
for qname, rtype, value in get_custom_records(domain, additional_records, env):
|
# Don't put any TXT records above this line, or it'll prevent any custom TXT records.
|
||||||
|
for qname, rtype, value in filter_custom_records(domain, additional_records):
|
||||||
|
# Don't allow custom records for record types that override anything above.
|
||||||
|
# But allow multiple custom records for the same rtype --- see how has_rec_base is used.
|
||||||
if has_rec(qname, rtype): continue
|
if has_rec(qname, rtype): continue
|
||||||
|
|
||||||
|
# The "local" keyword on A/AAAA records are short-hand for our own IP.
|
||||||
|
# This also flags for web configuration that the user wants a website here.
|
||||||
|
if rtype == "A" and value == "local":
|
||||||
|
value = env["PUBLIC_IP"]
|
||||||
|
if rtype == "AAAA" and value == "local":
|
||||||
|
if "PUBLIC_IPV6" in env:
|
||||||
|
value = env["PUBLIC_IPV6"]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
records.append((qname, rtype, value, "(Set by user.)"))
|
records.append((qname, rtype, value, "(Set by user.)"))
|
||||||
|
|
||||||
# Add defaults if not overridden by the user's custom settings.
|
# Add defaults if not overridden by the user's custom settings (and not otherwise configured).
|
||||||
|
# Any "CNAME" record on the qname overrides A and AAAA.
|
||||||
|
has_rec_base = records
|
||||||
defaults = [
|
defaults = [
|
||||||
(None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
|
(None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
|
||||||
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
|
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
|
||||||
@@ -205,21 +222,43 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
for qname, rtype, value, explanation in defaults:
|
for qname, rtype, value, explanation in defaults:
|
||||||
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||||
if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains
|
if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains
|
||||||
if not has_rec(qname, rtype):
|
# Set the default record, but not if:
|
||||||
|
# (1) there is not a user-set record of the same type already
|
||||||
|
# (2) there is not a CNAME record already, since you can't set both and who knows what takes precedence
|
||||||
|
# (2) there is not an A record already (if this is an A record this is a dup of (1), and if this is an AAAA record then don't set a default AAAA record if the user sets a custom A record, since the default wouldn't make sense and it should not resolve if the user doesn't provide a new AAAA record)
|
||||||
|
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"):
|
||||||
records.append((qname, rtype, value, explanation))
|
records.append((qname, rtype, value, explanation))
|
||||||
|
|
||||||
# If OpenDKIM is in use..
|
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
||||||
|
# the domain, and no one else.
|
||||||
|
# Skip if the user has set a custom SPF record.
|
||||||
|
if not has_rec(None, "TXT", prefix="v=spf1 "):
|
||||||
|
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
|
||||||
|
|
||||||
|
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
||||||
|
# Skip if the user has set a DKIM record already.
|
||||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||||
if os.path.exists(opendkim_record_file):
|
|
||||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above.
|
|
||||||
with open(opendkim_record_file) as orf:
|
with open(opendkim_record_file) as orf:
|
||||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
|
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
|
||||||
val = m.group(2) + m.group(3)
|
val = m.group(2) + m.group(3)
|
||||||
|
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
|
||||||
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
||||||
|
|
||||||
# Append a DMARC record.
|
# Append a DMARC record.
|
||||||
|
# Skip if the user has set a DMARC record already.
|
||||||
|
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
|
||||||
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain))
|
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain))
|
||||||
|
|
||||||
|
# For any subdomain with an A record but no SPF or DMARC record, add strict policy records.
|
||||||
|
all_resolvable_qnames = set(r[0] for r in records if r[1] in ("A", "AAAA"))
|
||||||
|
for qname in all_resolvable_qnames:
|
||||||
|
if not has_rec(qname, "TXT", prefix="v=spf1 "):
|
||||||
|
records.append((qname, "TXT", 'v=spf1 a mx -all', "Prevents unauthorized use of this domain name for outbound mail by requiring outbound mail to originate from the indicated host(s)."))
|
||||||
|
dmarc_qname = "_dmarc" + ("" if qname is None else "." + qname)
|
||||||
|
if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "):
|
||||||
|
records.append((dmarc_qname, "TXT", 'v=DMARC1; p=reject', "Prevents unauthorized use of this domain name for outbound mail by requiring a valid DKIM signature."))
|
||||||
|
|
||||||
|
|
||||||
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
||||||
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
||||||
|
|
||||||
@@ -227,45 +266,6 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def get_custom_records(domain, additional_records, env):
|
|
||||||
for qname, value in additional_records.items():
|
|
||||||
# Is this record for the domain or one of its subdomains?
|
|
||||||
if qname != domain and not qname.endswith("." + domain): continue
|
|
||||||
|
|
||||||
# Turn the fully qualified domain name in the YAML file into
|
|
||||||
# our short form (None => domain, or a relative QNAME).
|
|
||||||
if qname == domain:
|
|
||||||
qname = None
|
|
||||||
else:
|
|
||||||
qname = qname[0:len(qname)-len("." + domain)]
|
|
||||||
|
|
||||||
# Short form. Mapping a domain name to a string is short-hand
|
|
||||||
# for creating A records.
|
|
||||||
if isinstance(value, str):
|
|
||||||
values = [("A", value)]
|
|
||||||
if value == "local" and env.get("PUBLIC_IPV6"):
|
|
||||||
values.append( ("AAAA", value) )
|
|
||||||
|
|
||||||
# A mapping creates multiple records.
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
values = value.items()
|
|
||||||
|
|
||||||
# No other type of data is allowed.
|
|
||||||
else:
|
|
||||||
raise ValueError()
|
|
||||||
|
|
||||||
for rtype, value2 in values:
|
|
||||||
# The "local" keyword on A/AAAA records are short-hand for our own IP.
|
|
||||||
# This also flags for web configuration that the user wants a website here.
|
|
||||||
if rtype == "A" and value2 == "local":
|
|
||||||
value2 = env["PUBLIC_IP"]
|
|
||||||
if rtype == "AAAA" and value2 == "local":
|
|
||||||
if "PUBLIC_IPV6" not in env: continue # no IPv6 address is available so don't set anything
|
|
||||||
value2 = env["PUBLIC_IPV6"]
|
|
||||||
yield (qname, rtype, value2)
|
|
||||||
|
|
||||||
########################################################################
|
|
||||||
|
|
||||||
def build_tlsa_record(env):
|
def build_tlsa_record(env):
|
||||||
# A DANE TLSA record in DNS specifies that connections on a port
|
# A DANE TLSA record in DNS specifies that connections on a port
|
||||||
# must use TLS and the certificate must match a particular certificate.
|
# must use TLS and the certificate must match a particular certificate.
|
||||||
@@ -308,9 +308,11 @@ def build_sshfp_records():
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get our local fingerprints by running ssh-keyscan. The output looks
|
# Get our local fingerprints by running ssh-keyscan. The output looks
|
||||||
# like the known_hosts file: hostname, keytype, fingerprint.
|
# like the known_hosts file: hostname, keytype, fingerprint. The order
|
||||||
|
# of the output is arbitrary, so sort it to prevent spurrious updates
|
||||||
|
# to the zone file (that trigger bumping the serial number).
|
||||||
keys = shell("check_output", ["ssh-keyscan", "localhost"])
|
keys = shell("check_output", ["ssh-keyscan", "localhost"])
|
||||||
for key in keys.split("\n"):
|
for key in sorted(keys.split("\n")):
|
||||||
if key.strip() == "" or key[0] == "#": continue
|
if key.strip() == "" or key[0] == "#": continue
|
||||||
try:
|
try:
|
||||||
host, keytype, pubkey = key.split(" ")
|
host, keytype, pubkey = key.split(" ")
|
||||||
@@ -429,26 +431,10 @@ $TTL 1800 ; default time to live
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def write_nsd_conf(zonefiles, env):
|
def write_nsd_conf(zonefiles, additional_records, env):
|
||||||
# Basic header.
|
# Write the list of zones to a configuration file.
|
||||||
nsdconf = """
|
nsd_conf_file = "/etc/nsd/zones.conf"
|
||||||
server:
|
nsdconf = ""
|
||||||
hide-version: yes
|
|
||||||
|
|
||||||
# identify the server (CH TXT ID.SERVER entry).
|
|
||||||
identity: ""
|
|
||||||
|
|
||||||
# The directory for zonefile: files.
|
|
||||||
zonesdir: "/etc/nsd/zones"
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Since we have bind9 listening on localhost for locally-generated
|
|
||||||
# DNS queries that require a recursive nameserver, and the system
|
|
||||||
# might have other network interfaces for e.g. tunnelling, we have
|
|
||||||
# to be specific about the network interfaces that nsd binds to.
|
|
||||||
for ipaddr in (env.get("PRIVATE_IP", "") + " " + env.get("PRIVATE_IPV6", "")).split(" "):
|
|
||||||
if ipaddr == "": continue
|
|
||||||
nsdconf += " ip-address: %s\n" % ipaddr
|
|
||||||
|
|
||||||
# Append the zones.
|
# Append the zones.
|
||||||
for domain, zonefile in zonefiles:
|
for domain, zonefile in zonefiles:
|
||||||
@@ -458,21 +444,49 @@ zone:
|
|||||||
zonefile: %s
|
zonefile: %s
|
||||||
""" % (domain, zonefile)
|
""" % (domain, zonefile)
|
||||||
|
|
||||||
# Check if the nsd.conf is changing. If it isn't changing,
|
# If a custom secondary nameserver has been set, allow zone transfers
|
||||||
|
# and notifies to that nameserver.
|
||||||
|
if get_secondary_dns(additional_records):
|
||||||
|
# Get the IP address of the nameserver by resolving it.
|
||||||
|
hostname = get_secondary_dns(additional_records)
|
||||||
|
resolver = dns.resolver.get_default_resolver()
|
||||||
|
response = dns.resolver.query(hostname+'.', "A")
|
||||||
|
ipaddr = str(response[0])
|
||||||
|
nsdconf += """\tnotify: %s NOKEY
|
||||||
|
provide-xfr: %s NOKEY
|
||||||
|
""" % (ipaddr, ipaddr)
|
||||||
|
|
||||||
|
# Check if the file is changing. If it isn't changing,
|
||||||
# return False to flag that no change was made.
|
# return False to flag that no change was made.
|
||||||
with open("/etc/nsd/nsd.conf") as f:
|
if os.path.exists(nsd_conf_file):
|
||||||
|
with open(nsd_conf_file) as f:
|
||||||
if f.read() == nsdconf:
|
if f.read() == nsdconf:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with open("/etc/nsd/nsd.conf", "w") as f:
|
# Write out new contents and return True to signal that
|
||||||
|
# configuration changed.
|
||||||
|
with open(nsd_conf_file, "w") as f:
|
||||||
f.write(nsdconf)
|
f.write(nsdconf)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
|
def dnssec_choose_algo(domain, env):
|
||||||
|
if '.' in domain and domain.rsplit('.')[-1] in \
|
||||||
|
("email", "guide", "fund"):
|
||||||
|
# At GoDaddy, RSASHA256 is the only algorithm supported
|
||||||
|
# for .email and .guide.
|
||||||
|
# A variety of algorithms are supported for .fund. This
|
||||||
|
# is preferred.
|
||||||
|
return "RSASHA256"
|
||||||
|
|
||||||
|
# For any domain we were able to sign before, don't change the algorithm
|
||||||
|
# on existing users. We'll probably want to migrate to SHA256 later.
|
||||||
|
return "RSASHA1-NSEC3-SHA1"
|
||||||
|
|
||||||
def sign_zone(domain, zonefile, env):
|
def sign_zone(domain, zonefile, env):
|
||||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/keys.conf'))
|
algo = dnssec_choose_algo(domain, env)
|
||||||
|
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
|
||||||
|
|
||||||
# In order to use the same keys for all domains, we have to generate
|
# In order to use the same keys for all domains, we have to generate
|
||||||
# a new .key file with a DNSSEC record for the specific domain. We
|
# a new .key file with a DNSSEC record for the specific domain. We
|
||||||
@@ -543,8 +557,9 @@ def sign_zone(domain, zonefile, env):
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def write_opendkim_tables(zonefiles, env):
|
def write_opendkim_tables(domains, env):
|
||||||
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
|
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain
|
||||||
|
# that we send mail from (zones and all subdomains).
|
||||||
|
|
||||||
opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')
|
opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')
|
||||||
|
|
||||||
@@ -563,7 +578,7 @@ def write_opendkim_tables(zonefiles, env):
|
|||||||
"SigningTable":
|
"SigningTable":
|
||||||
"".join(
|
"".join(
|
||||||
"*@{domain} {domain}\n".format(domain=domain)
|
"*@{domain} {domain}\n".format(domain=domain)
|
||||||
for domain, zonefile in zonefiles
|
for domain in domains
|
||||||
),
|
),
|
||||||
|
|
||||||
# The KeyTable specifies the signing domain, the DKIM selector, and the
|
# The KeyTable specifies the signing domain, the DKIM selector, and the
|
||||||
@@ -572,7 +587,7 @@ def write_opendkim_tables(zonefiles, env):
|
|||||||
"KeyTable":
|
"KeyTable":
|
||||||
"".join(
|
"".join(
|
||||||
"{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file)
|
"{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file)
|
||||||
for domain, zonefile in zonefiles
|
for domain in domains
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +610,94 @@ def write_opendkim_tables(zonefiles, env):
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def set_custom_dns_record(qname, rtype, value, env):
|
def get_custom_dns_config(env):
|
||||||
|
try:
|
||||||
|
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
||||||
|
if not isinstance(custom_dns, dict): raise ValueError() # caught below
|
||||||
|
except:
|
||||||
|
return [ ]
|
||||||
|
|
||||||
|
for qname, value in custom_dns.items():
|
||||||
|
# Short form. Mapping a domain name to a string is short-hand
|
||||||
|
# for creating A records.
|
||||||
|
if isinstance(value, str):
|
||||||
|
values = [("A", value)]
|
||||||
|
|
||||||
|
# A mapping creates multiple records.
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
values = value.items()
|
||||||
|
|
||||||
|
# No other type of data is allowed.
|
||||||
|
else:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
for rtype, value2 in values:
|
||||||
|
if isinstance(value2, str):
|
||||||
|
yield (qname, rtype, value2)
|
||||||
|
elif isinstance(value2, list):
|
||||||
|
for value3 in value2:
|
||||||
|
yield (qname, rtype, value3)
|
||||||
|
# No other type of data is allowed.
|
||||||
|
else:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
def filter_custom_records(domain, custom_dns_iter):
|
||||||
|
for qname, rtype, value in custom_dns_iter:
|
||||||
|
# We don't count the secondary nameserver config (if present) as a record - that would just be
|
||||||
|
# confusing to users. Instead it is accessed/manipulated directly via (get/set)_custom_dns_config.
|
||||||
|
if qname == "_secondary_nameserver": continue
|
||||||
|
|
||||||
|
# Is this record for the domain or one of its subdomains?
|
||||||
|
# If `domain` is None, return records for all domains.
|
||||||
|
if domain is not None and qname != domain and not qname.endswith("." + domain): continue
|
||||||
|
|
||||||
|
# Turn the fully qualified domain name in the YAML file into
|
||||||
|
# our short form (None => domain, or a relative QNAME) if
|
||||||
|
# domain is not None.
|
||||||
|
if domain is not None:
|
||||||
|
if qname == domain:
|
||||||
|
qname = None
|
||||||
|
else:
|
||||||
|
qname = qname[0:len(qname)-len("." + domain)]
|
||||||
|
|
||||||
|
yield (qname, rtype, value)
|
||||||
|
|
||||||
|
def write_custom_dns_config(config, env):
|
||||||
|
# We get a list of (qname, rtype, value) triples. Convert this into a
|
||||||
|
# nice dictionary format for storage on disk.
|
||||||
|
from collections import OrderedDict
|
||||||
|
config = list(config)
|
||||||
|
dns = OrderedDict()
|
||||||
|
seen_qnames = set()
|
||||||
|
|
||||||
|
# Process the qnames in the order we see them.
|
||||||
|
for qname in [rec[0] for rec in config]:
|
||||||
|
if qname in seen_qnames: continue
|
||||||
|
seen_qnames.add(qname)
|
||||||
|
|
||||||
|
records = [(rec[1], rec[2]) for rec in config if rec[0] == qname]
|
||||||
|
if len(records) == 1 and records[0][0] == "A":
|
||||||
|
dns[qname] = records[0][1]
|
||||||
|
else:
|
||||||
|
dns[qname] = OrderedDict()
|
||||||
|
seen_rtypes = set()
|
||||||
|
|
||||||
|
# Process the rtypes in the order we see them.
|
||||||
|
for rtype in [rec[0] for rec in records]:
|
||||||
|
if rtype in seen_rtypes: continue
|
||||||
|
seen_rtypes.add(rtype)
|
||||||
|
|
||||||
|
values = [rec[1] for rec in records if rec[0] == rtype]
|
||||||
|
if len(values) == 1:
|
||||||
|
values = values[0]
|
||||||
|
dns[qname][rtype] = values
|
||||||
|
|
||||||
|
# Write.
|
||||||
|
config_yaml = rtyaml.dump(dns)
|
||||||
|
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
|
||||||
|
f.write(config_yaml)
|
||||||
|
|
||||||
|
def set_custom_dns_record(qname, rtype, value, action, env):
|
||||||
# validate qname
|
# validate qname
|
||||||
for zone, fn in get_dns_zones(env):
|
for zone, fn in get_dns_zones(env):
|
||||||
# It must match a zone apex or be a subdomain of a zone
|
# It must match a zone apex or be a subdomain of a zone
|
||||||
@@ -604,77 +706,98 @@ def set_custom_dns_record(qname, rtype, value, env):
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# No match.
|
# No match.
|
||||||
|
if qname != "_secondary_nameserver":
|
||||||
raise ValueError("%s is not a domain name or a subdomain of a domain name managed by this box." % qname)
|
raise ValueError("%s is not a domain name or a subdomain of a domain name managed by this box." % qname)
|
||||||
|
|
||||||
# validate rtype
|
# validate rtype
|
||||||
rtype = rtype.upper()
|
rtype = rtype.upper()
|
||||||
if value is not None:
|
if value is not None and qname != "_secondary_nameserver":
|
||||||
if rtype in ("A", "AAAA"):
|
if rtype in ("A", "AAAA"):
|
||||||
v = ipaddress.ip_address(value)
|
if value != "local": # "local" is a special flag for us
|
||||||
|
v = ipaddress.ip_address(value) # raises a ValueError if there's a problem
|
||||||
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
||||||
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
||||||
elif rtype in ("CNAME", "TXT"):
|
elif rtype in ("CNAME", "TXT", "SRV", "MX"):
|
||||||
# anything goes
|
# anything goes
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown record type '%s'." % rtype)
|
raise ValueError("Unknown record type '%s'." % rtype)
|
||||||
|
|
||||||
# load existing config
|
# load existing config
|
||||||
config = get_custom_dns_config(env)
|
config = list(get_custom_dns_config(env))
|
||||||
|
|
||||||
# update
|
# update
|
||||||
if qname not in config:
|
newconfig = []
|
||||||
if value is None:
|
made_change = False
|
||||||
# Is asking to delete a record that does not exist.
|
needs_add = True
|
||||||
|
for _qname, _rtype, _value in config:
|
||||||
|
if action == "add":
|
||||||
|
if (_qname, _rtype, _value) == (qname, rtype, value):
|
||||||
|
# Record already exists. Bail.
|
||||||
return False
|
return False
|
||||||
elif rtype == "A":
|
elif action == "set":
|
||||||
# Add this record using the short form 'qname: value'.
|
if (_qname, _rtype) == (qname, rtype):
|
||||||
config[qname] = value
|
if _value == value:
|
||||||
|
# Flag that the record already exists, don't
|
||||||
|
# need to add it.
|
||||||
|
needs_add = False
|
||||||
else:
|
else:
|
||||||
# Add this record. This is the qname's first record.
|
# Drop any other values for this (qname, rtype).
|
||||||
config[qname] = { rtype: value }
|
made_change = True
|
||||||
|
continue
|
||||||
|
elif action == "remove":
|
||||||
|
if (_qname, _rtype, _value) == (qname, rtype, value):
|
||||||
|
# Drop this record.
|
||||||
|
made_change = True
|
||||||
|
continue
|
||||||
|
if value == None and (_qname, _rtype) == (qname, rtype):
|
||||||
|
# Drop all qname-rtype records.
|
||||||
|
made_change = True
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
if isinstance(config[qname], str):
|
raise ValueError("Invalid action: " + action)
|
||||||
# This is a short-form 'qname: value' implicit-A record.
|
|
||||||
if value is None and rtype != "A":
|
|
||||||
# Is asking to delete a record that doesn't exist.
|
|
||||||
return False
|
|
||||||
elif value is None and rtype == "A":
|
|
||||||
# Delete record.
|
|
||||||
del config[qname]
|
|
||||||
elif rtype == "A":
|
|
||||||
# Update, keeping short form.
|
|
||||||
if config[qname] == "value":
|
|
||||||
# No change.
|
|
||||||
return False
|
|
||||||
config[qname] = value
|
|
||||||
else:
|
|
||||||
# Expand short form so we can add a new record type.
|
|
||||||
config[qname] = { "A": config[qname], rtype: value }
|
|
||||||
else:
|
|
||||||
# This is the qname: { ... } (dict) format.
|
|
||||||
if value is None:
|
|
||||||
if rtype not in config[qname]:
|
|
||||||
# Is asking to delete a record that doesn't exist.
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# Delete the record. If it's the last record, delete the domain.
|
|
||||||
del config[qname][rtype]
|
|
||||||
if len(config[qname]) == 0:
|
|
||||||
del config[qname]
|
|
||||||
else:
|
|
||||||
# Update the record.
|
|
||||||
if config[qname].get(rtype) == "value":
|
|
||||||
# No change.
|
|
||||||
return False
|
|
||||||
config[qname][rtype] = value
|
|
||||||
|
|
||||||
|
# Preserve this record.
|
||||||
|
newconfig.append((_qname, _rtype, _value))
|
||||||
|
|
||||||
|
if action in ("add", "set") and needs_add and value is not None:
|
||||||
|
newconfig.append((qname, rtype, value))
|
||||||
|
made_change = True
|
||||||
|
|
||||||
|
if made_change:
|
||||||
# serialize & save
|
# serialize & save
|
||||||
config_yaml = rtyaml.dump(config)
|
write_custom_dns_config(newconfig, env)
|
||||||
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
|
|
||||||
f.write(config_yaml)
|
return made_change
|
||||||
|
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
def get_secondary_dns(custom_dns):
|
||||||
|
for qname, rtype, value in custom_dns:
|
||||||
|
if qname == "_secondary_nameserver":
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_secondary_dns(hostname, env):
|
||||||
|
|
||||||
|
if hostname in (None, ""):
|
||||||
|
# Clear.
|
||||||
|
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
||||||
|
else:
|
||||||
|
# Validate.
|
||||||
|
hostname = hostname.strip().lower()
|
||||||
|
resolver = dns.resolver.get_default_resolver()
|
||||||
|
try:
|
||||||
|
response = dns.resolver.query(hostname, "A")
|
||||||
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
|
raise ValueError("Could not resolve the IP address of %s." % hostname)
|
||||||
|
|
||||||
|
# Set.
|
||||||
|
set_custom_dns_record("_secondary_nameserver", "A", hostname, "set", env)
|
||||||
|
|
||||||
|
# Apply.
|
||||||
|
return do_dns_update(env)
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
@@ -723,7 +846,7 @@ def build_recommended_dns(env):
|
|||||||
ret = []
|
ret = []
|
||||||
domains = get_dns_domains(env)
|
domains = get_dns_domains(env)
|
||||||
zonefiles = get_dns_zones(env)
|
zonefiles = get_dns_zones(env)
|
||||||
additional_records = get_custom_dns_config(env)
|
additional_records = list(get_custom_dns_config(env))
|
||||||
for domain, zonefile in zonefiles:
|
for domain, zonefile in zonefiles:
|
||||||
records = build_zone(domain, domains, additional_records, env)
|
records = build_zone(domain, domains, additional_records, env)
|
||||||
|
|
||||||
@@ -754,6 +877,9 @@ def build_recommended_dns(env):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from utils import load_environment
|
from utils import load_environment
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
|
if sys.argv[-1] == "--lint":
|
||||||
|
write_custom_dns_config(get_custom_dns_config(env), env)
|
||||||
|
else:
|
||||||
for zone, records in build_recommended_dns(env):
|
for zone, records in build_recommended_dns(env):
|
||||||
for record in records:
|
for record in records:
|
||||||
print("; " + record['explanation'])
|
print("; " + record['explanation'])
|
||||||
|
|||||||
121
management/mail_log.py
Executable file
121
management/mail_log.py
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import re, os.path
|
||||||
|
import dateutil.parser
|
||||||
|
|
||||||
|
import mailconfig
|
||||||
|
import utils
|
||||||
|
|
||||||
|
def scan_mail_log(logger, env):
|
||||||
|
collector = {
|
||||||
|
"other-services": set(),
|
||||||
|
"imap-logins": { },
|
||||||
|
"postgrey": { },
|
||||||
|
"rejected-mail": { },
|
||||||
|
}
|
||||||
|
|
||||||
|
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
||||||
|
|
||||||
|
for fn in ('/var/log/mail.log.1', '/var/log/mail.log'):
|
||||||
|
if not os.path.exists(fn): continue
|
||||||
|
with open(fn, 'rb') as log:
|
||||||
|
for line in log:
|
||||||
|
line = line.decode("utf8", errors='replace')
|
||||||
|
scan_mail_log_line(line.strip(), collector)
|
||||||
|
|
||||||
|
if collector["imap-logins"]:
|
||||||
|
logger.add_heading("Recent IMAP Logins")
|
||||||
|
logger.print_block("The most recent login from each remote IP adddress is show.")
|
||||||
|
for k in utils.sort_email_addresses(collector["imap-logins"], env):
|
||||||
|
for ip, date in sorted(collector["imap-logins"][k].items(), key = lambda kv : kv[1]):
|
||||||
|
logger.print_line(k + "\t" + str(date) + "\t" + ip)
|
||||||
|
|
||||||
|
if collector["postgrey"]:
|
||||||
|
logger.add_heading("Greylisted Mail")
|
||||||
|
logger.print_block("The following mail was greylisted, meaning the emails were temporarily rejected. Legitimate senders will try again within ten minutes.")
|
||||||
|
logger.print_line("recipient" + "\t" + "received" + "\t" + "sender" + "\t" + "delivered")
|
||||||
|
for recipient in utils.sort_email_addresses(collector["postgrey"], env):
|
||||||
|
for (client_address, sender), (first_date, delivered_date) in sorted(collector["postgrey"][recipient].items(), key = lambda kv : kv[1][0]):
|
||||||
|
logger.print_line(recipient + "\t" + str(first_date) + "\t" + sender + "\t" + (("delivered " + str(delivered_date)) if delivered_date else "no retry yet"))
|
||||||
|
|
||||||
|
if collector["rejected-mail"]:
|
||||||
|
logger.add_heading("Rejected Mail")
|
||||||
|
logger.print_block("The following incoming mail was rejected.")
|
||||||
|
for k in utils.sort_email_addresses(collector["rejected-mail"], env):
|
||||||
|
for date, sender, message in collector["rejected-mail"][k]:
|
||||||
|
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
||||||
|
|
||||||
|
if len(collector["other-services"]) > 0:
|
||||||
|
logger.add_heading("Other")
|
||||||
|
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
||||||
|
|
||||||
|
def scan_mail_log_line(line, collector):
|
||||||
|
m = re.match(r"(\S+ \d+ \d+:\d+:\d+) (\S+) (\S+?)(\[\d+\])?: (.*)", line)
|
||||||
|
if not m: return
|
||||||
|
|
||||||
|
date, system, service, pid, log = m.groups()
|
||||||
|
date = dateutil.parser.parse(date)
|
||||||
|
|
||||||
|
if service == "dovecot":
|
||||||
|
scan_dovecot_line(date, log, collector)
|
||||||
|
|
||||||
|
elif service == "postgrey":
|
||||||
|
scan_postgrey_line(date, log, collector)
|
||||||
|
|
||||||
|
elif service == "postfix/smtpd":
|
||||||
|
scan_postfix_smtpd_line(date, log, collector)
|
||||||
|
|
||||||
|
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
||||||
|
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
||||||
|
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
||||||
|
# nothing to look at
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
collector["other-services"].add(service)
|
||||||
|
|
||||||
|
def scan_dovecot_line(date, log, collector):
|
||||||
|
m = re.match("imap-login: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log)
|
||||||
|
if m:
|
||||||
|
login, ip = m.group(1), m.group(2)
|
||||||
|
if ip != "127.0.0.1": # local login from webmail/zpush
|
||||||
|
collector["imap-logins"].setdefault(login, {})[ip] = date
|
||||||
|
|
||||||
|
def scan_postgrey_line(date, log, collector):
|
||||||
|
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), client_address=(.*), sender=(.*), recipient=(.*)", log)
|
||||||
|
if m:
|
||||||
|
action, reason, client_name, client_address, sender, recipient = m.groups()
|
||||||
|
key = (client_address, sender)
|
||||||
|
if action == "greylist" and reason == "new":
|
||||||
|
collector["postgrey"].setdefault(recipient, {})[key] = (date, None)
|
||||||
|
elif action == "pass" and reason == "triplet found" and key in collector["postgrey"].get(recipient, {}):
|
||||||
|
collector["postgrey"][recipient][key] = (collector["postgrey"][recipient][key][0], date)
|
||||||
|
|
||||||
|
def scan_postfix_smtpd_line(date, log, collector):
|
||||||
|
m = re.match("NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
|
||||||
|
if m:
|
||||||
|
message, sender, recipient = m.groups()
|
||||||
|
if recipient in collector["real_mail_addresses"]:
|
||||||
|
# only log mail to real recipients
|
||||||
|
|
||||||
|
# skip this, is reported in the greylisting report
|
||||||
|
if "Recipient address rejected: Greylisted" in message:
|
||||||
|
return
|
||||||
|
|
||||||
|
# simplify this one
|
||||||
|
m = re.search(r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message)
|
||||||
|
if m:
|
||||||
|
message = "ip blocked: " + m.group(2)
|
||||||
|
|
||||||
|
# simplify this one too
|
||||||
|
m = re.search(r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message)
|
||||||
|
if m:
|
||||||
|
message = "domain blocked: " + m.group(2)
|
||||||
|
|
||||||
|
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from status_checks import ConsoleOutput
|
||||||
|
env = utils.load_environment()
|
||||||
|
scan_mail_log(ConsoleOutput(), env)
|
||||||
@@ -2,42 +2,78 @@
|
|||||||
|
|
||||||
import subprocess, shutil, os, sqlite3, re
|
import subprocess, shutil, os, sqlite3, re
|
||||||
import utils
|
import utils
|
||||||
|
from email_validator import validate_email as validate_email_, EmailNotValidError
|
||||||
|
|
||||||
def validate_email(email, mode=None):
|
def validate_email(email, mode=None):
|
||||||
|
# Checks that an email address is syntactically valid. Returns True/False.
|
||||||
|
# Until Postfix supports SMTPUTF8, an email address may contain ASCII
|
||||||
|
# characters only; IDNs must be IDNA-encoded.
|
||||||
|
#
|
||||||
|
# When mode=="user", we're checking that this can be a user account name.
|
||||||
|
# Dovecot has tighter restrictions - letters, numbers, underscore, and
|
||||||
|
# dash only!
|
||||||
|
#
|
||||||
|
# When mode=="alias", we're allowing anything that can be in a Postfix
|
||||||
|
# alias table, i.e. omitting the local part ("@domain.tld") is OK.
|
||||||
|
|
||||||
|
# Check the syntax of the address.
|
||||||
|
try:
|
||||||
|
validate_email_(email,
|
||||||
|
allow_smtputf8=False,
|
||||||
|
check_deliverability=False,
|
||||||
|
allow_empty_local=(mode=="alias")
|
||||||
|
)
|
||||||
|
except EmailNotValidError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if mode == 'user':
|
||||||
# There are a lot of characters permitted in email addresses, but
|
# There are a lot of characters permitted in email addresses, but
|
||||||
# Dovecot's sqlite driver seems to get confused if there are any
|
# Dovecot's sqlite auth driver seems to get confused if there are any
|
||||||
# unusual characters in the address. Bah. Also note that since
|
# unusual characters in the address. Bah. Also note that since
|
||||||
# the mailbox path name is based on the email address, the address
|
# the mailbox path name is based on the email address, the address
|
||||||
# shouldn't be absurdly long and must not have a forward slash.
|
# shouldn't be absurdly long and must not have a forward slash.
|
||||||
|
|
||||||
if len(email) > 255: return False
|
if len(email) > 255: return False
|
||||||
|
if re.search(r'[^\@\.a-zA-Z0-9_\-]+', email):
|
||||||
|
return False
|
||||||
|
|
||||||
if mode == 'user':
|
# Everything looks good.
|
||||||
# For Dovecot's benefit, only allow basic characters.
|
return True
|
||||||
ATEXT = r'[\w\-]'
|
|
||||||
elif mode == 'alias':
|
|
||||||
# For aliases, we can allow any valid email address.
|
|
||||||
# Based on RFC 2822 and https://github.com/SyrusAkbary/validate_email/blob/master/validate_email.py,
|
|
||||||
# these characters are permitted in email addresses.
|
|
||||||
ATEXT = r'[\w!#$%&\'\*\+\-/=\?\^`\{\|\}~]' # see 3.2.4
|
|
||||||
else:
|
|
||||||
raise ValueError(mode)
|
|
||||||
|
|
||||||
# per RFC 2822 3.2.4
|
def sanitize_idn_email_address(email):
|
||||||
DOT_ATOM_TEXT_LOCAL = ATEXT + r'+(?:\.' + ATEXT + r'+)*'
|
# The user may enter Unicode in an email address. Convert the domain part
|
||||||
if mode == 'alias':
|
# to IDNA before going into our database. Leave the local part alone ---
|
||||||
# For aliases, Postfix accepts '@domain.tld' format for
|
# although validate_email will reject non-ASCII characters.
|
||||||
# catch-all addresses. Make the local part optional.
|
#
|
||||||
DOT_ATOM_TEXT_LOCAL = '(?:' + DOT_ATOM_TEXT_LOCAL + ')?'
|
# The domain name system only exists in ASCII, so it doesn't make sense
|
||||||
|
# to store domain names in Unicode. We want to store what is meaningful
|
||||||
|
# to the underlying protocols.
|
||||||
|
try:
|
||||||
|
localpart, domainpart = email.split("@")
|
||||||
|
domainpart = domainpart.encode("idna").decode('ascii')
|
||||||
|
return localpart + "@" + domainpart
|
||||||
|
except:
|
||||||
|
# Domain part is not IDNA-valid, so leave unchanged. If there
|
||||||
|
# are non-ASCII characters it will be filtered out by
|
||||||
|
# validate_email.
|
||||||
|
return email
|
||||||
|
|
||||||
# as above, but we can require that the host part have at least
|
def prettify_idn_email_address(email):
|
||||||
# one period in it, so use a "+" rather than a "*" at the end
|
# This is the opposite of sanitize_idn_email_address. We store domain
|
||||||
DOT_ATOM_TEXT_HOST = ATEXT + r'+(?:\.' + ATEXT + r'+)+'
|
# names in IDNA in the database, but we want to show Unicode to the user.
|
||||||
|
try:
|
||||||
|
localpart, domainpart = email.split("@")
|
||||||
|
domainpart = domainpart.encode("ascii").decode('idna')
|
||||||
|
return localpart + "@" + domainpart
|
||||||
|
except:
|
||||||
|
# Failed to decode IDNA. Should never happen.
|
||||||
|
return email
|
||||||
|
|
||||||
# per RFC 2822 3.4.1
|
def is_dcv_address(email):
|
||||||
ADDR_SPEC = '^%s@%s$' % (DOT_ATOM_TEXT_LOCAL, DOT_ATOM_TEXT_HOST)
|
email = email.lower()
|
||||||
|
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster"):
|
||||||
return re.match(ADDR_SPEC, email)
|
if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def open_database(env, with_connection=False):
|
def open_database(env, with_connection=False):
|
||||||
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
||||||
@@ -46,99 +82,185 @@ def open_database(env, with_connection=False):
|
|||||||
else:
|
else:
|
||||||
return conn, conn.cursor()
|
return conn, conn.cursor()
|
||||||
|
|
||||||
def get_mail_users(env, as_json=False):
|
def get_mail_users(env):
|
||||||
|
# Returns a flat, sorted list of all user accounts.
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT email FROM users')
|
||||||
|
users = [ row[0] for row in c.fetchall() ]
|
||||||
|
return utils.sort_email_addresses(users, env)
|
||||||
|
|
||||||
|
def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
|
||||||
|
# Returns a complex data structure of all user accounts, optionally
|
||||||
|
# including archived (status="inactive") accounts.
|
||||||
|
#
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# domain: "domain.tld",
|
||||||
|
# users: [
|
||||||
|
# {
|
||||||
|
# email: "name@domain.tld",
|
||||||
|
# privileges: [ "priv1", "priv2", ... ],
|
||||||
|
# status: "active" | "inactive",
|
||||||
|
# },
|
||||||
|
# ...
|
||||||
|
# ]
|
||||||
|
# },
|
||||||
|
# ...
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Get users and their privileges.
|
||||||
|
users = []
|
||||||
|
active_accounts = set()
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT email, privileges FROM users')
|
c.execute('SELECT email, privileges FROM users')
|
||||||
|
for email, privileges in c.fetchall():
|
||||||
|
active_accounts.add(email)
|
||||||
|
|
||||||
# turn into a list of tuples, but sorted by domain & email address
|
user = {
|
||||||
users = { row[0]: row[1] for row in c.fetchall() } # make dict
|
|
||||||
users = [ (email, users[email]) for email in utils.sort_email_addresses(users.keys(), env) ]
|
|
||||||
|
|
||||||
if not as_json:
|
|
||||||
return [email for email, privileges in users]
|
|
||||||
else:
|
|
||||||
aliases = get_mail_alias_map(env)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"email": email,
|
"email": email,
|
||||||
"privileges": parse_privs(privileges),
|
"privileges": parse_privs(privileges),
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"aliases": [
|
|
||||||
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
|
|
||||||
for alias in aliases.get(email.lower(), [])
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
for email, privileges in users
|
users.append(user)
|
||||||
]
|
|
||||||
|
|
||||||
def get_archived_mail_users(env):
|
if with_slow_info:
|
||||||
real_users = set(get_mail_users(env))
|
user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
|
||||||
|
|
||||||
|
# Add in archived accounts.
|
||||||
|
if with_archived:
|
||||||
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
|
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
|
||||||
ret = []
|
for domain in os.listdir(root):
|
||||||
for domain_enc in os.listdir(root):
|
for user in os.listdir(os.path.join(root, domain)):
|
||||||
for user_enc in os.listdir(os.path.join(root, domain_enc)):
|
email = user + "@" + domain
|
||||||
email = utils.unsafe_domain_name(user_enc) + "@" + utils.unsafe_domain_name(domain_enc)
|
mbox = os.path.join(root, domain, user)
|
||||||
if email in real_users: continue
|
if email in active_accounts: continue
|
||||||
ret.append({
|
user = {
|
||||||
"email": email,
|
"email": email,
|
||||||
"privileges": "",
|
"privileges": "",
|
||||||
"status": "inactive"
|
"status": "inactive",
|
||||||
})
|
"mailbox": mbox,
|
||||||
return ret
|
}
|
||||||
|
users.append(user)
|
||||||
|
if with_slow_info:
|
||||||
|
user["mailbox_size"] = utils.du(mbox)
|
||||||
|
|
||||||
def get_mail_aliases(env, as_json=False):
|
# Group by domain.
|
||||||
|
domains = { }
|
||||||
|
for user in users:
|
||||||
|
domain = get_domain(user["email"])
|
||||||
|
if domain not in domains:
|
||||||
|
domains[domain] = {
|
||||||
|
"domain": domain,
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
|
domains[domain]["users"].append(user)
|
||||||
|
|
||||||
|
# Sort domains.
|
||||||
|
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||||
|
|
||||||
|
# Sort users within each domain first by status then lexicographically by email address.
|
||||||
|
for domain in domains:
|
||||||
|
domain["users"].sort(key = lambda user : (user["status"] != "active", user["email"]))
|
||||||
|
|
||||||
|
return domains
|
||||||
|
|
||||||
|
def get_admins(env):
|
||||||
|
# Returns a set of users with admin privileges.
|
||||||
|
users = set()
|
||||||
|
for domain in get_mail_users_ex(env):
|
||||||
|
for user in domain["users"]:
|
||||||
|
if "admin" in user["privileges"]:
|
||||||
|
users.add(user["email"])
|
||||||
|
return users
|
||||||
|
|
||||||
|
def get_mail_aliases(env):
|
||||||
|
# Returns a sorted list of tuples of (alias, forward-to string).
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT source, destination FROM aliases')
|
c.execute('SELECT source, destination FROM aliases')
|
||||||
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
|
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
|
||||||
|
|
||||||
# put in a canonical order: sort by domain, then by email address lexicographically
|
# put in a canonical order: sort by domain, then by email address lexicographically
|
||||||
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ] # sort
|
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ]
|
||||||
|
return aliases
|
||||||
|
|
||||||
# but put automatic aliases to administrator@ last
|
def get_mail_aliases_ex(env):
|
||||||
aliases.sort(key = lambda x : x[1] == get_system_administrator(env))
|
# Returns a complex data structure of all mail aliases, similar
|
||||||
|
# to get_mail_users_ex.
|
||||||
|
#
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# domain: "domain.tld",
|
||||||
|
# alias: [
|
||||||
|
# {
|
||||||
|
# source: "name@domain.tld", # IDNA-encoded
|
||||||
|
# source_display: "name@domain.tld", # full Unicode
|
||||||
|
# destination: ["target1@domain.com", "target2@domain.com", ...],
|
||||||
|
# required: True|False
|
||||||
|
# },
|
||||||
|
# ...
|
||||||
|
# ]
|
||||||
|
# },
|
||||||
|
# ...
|
||||||
|
# ]
|
||||||
|
|
||||||
if as_json:
|
|
||||||
required_aliases = get_required_aliases(env)
|
required_aliases = get_required_aliases(env)
|
||||||
aliases = [
|
domains = {}
|
||||||
{
|
for source, destination in get_mail_aliases(env):
|
||||||
"source": alias[0],
|
# get alias info
|
||||||
"destination": [d.strip() for d in alias[1].split(",")],
|
domain = get_domain(source)
|
||||||
"required": alias[0] in required_aliases or alias[0] == get_system_administrator(env),
|
required = ((source in required_aliases) or (source == get_system_administrator(env)))
|
||||||
|
|
||||||
|
# add to list
|
||||||
|
if not domain in domains:
|
||||||
|
domains[domain] = {
|
||||||
|
"domain": domain,
|
||||||
|
"aliases": [],
|
||||||
}
|
}
|
||||||
for alias in aliases
|
domains[domain]["aliases"].append({
|
||||||
]
|
"source": source,
|
||||||
return aliases
|
"source_display": prettify_idn_email_address(source),
|
||||||
|
"destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")],
|
||||||
|
"required": required,
|
||||||
|
})
|
||||||
|
|
||||||
def get_mail_alias_map(env):
|
# Sort domains.
|
||||||
aliases = { }
|
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||||
for alias, targets in get_mail_aliases(env):
|
|
||||||
for em in targets.split(","):
|
|
||||||
em = em.strip().lower()
|
|
||||||
aliases.setdefault(em, []).append(alias)
|
|
||||||
return aliases
|
|
||||||
|
|
||||||
def evaluate_mail_alias_map(email, aliases, env):
|
# Sort aliases within each domain first by required-ness then lexicographically by source address.
|
||||||
ret = set()
|
for domain in domains:
|
||||||
for alias in aliases.get(email.lower(), []):
|
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
|
||||||
ret.add(alias)
|
return domains
|
||||||
ret |= evaluate_mail_alias_map(alias, aliases, env)
|
|
||||||
|
def get_domain(emailaddr, as_unicode=True):
|
||||||
|
# Gets the domain part of an email address. Turns IDNA
|
||||||
|
# back to Unicode for display.
|
||||||
|
ret = emailaddr.split('@', 1)[1]
|
||||||
|
if as_unicode: ret = ret.encode('ascii').decode('idna')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||||
def get_domain(emailaddr):
|
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
||||||
return emailaddr.split('@', 1)[1]
|
# configured on the system.
|
||||||
return set(
|
return set(
|
||||||
[get_domain(addr) for addr in get_mail_users(env)]
|
[get_domain(addr, as_unicode=False) for addr in get_mail_users(env)]
|
||||||
+ [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
+ [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, env):
|
def add_mail_user(email, pw, privs, env):
|
||||||
# validate email
|
# validate email
|
||||||
if email.strip() == "":
|
if email.strip() == "":
|
||||||
return ("No email address provided.", 400)
|
return ("No email address provided.", 400)
|
||||||
if not validate_email(email, mode='user'):
|
elif not validate_email(email):
|
||||||
return ("Invalid email address.", 400)
|
return ("Invalid email address.", 400)
|
||||||
|
elif not validate_email(email, mode='user'):
|
||||||
|
return ("User account email addresses may only use the ASCII letters A-Z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
|
||||||
|
elif is_dcv_address(email) and len(get_mail_users(env)) > 0:
|
||||||
|
# Make domain control validation hijacking a little harder to mess up by preventing the usual
|
||||||
|
# addresses used for DCV from being user accounts. Except let it be the first account because
|
||||||
|
# during box setup the user won't know the rules.
|
||||||
|
return ("You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.", 400)
|
||||||
|
|
||||||
|
# validate password
|
||||||
validate_password(pw)
|
validate_password(pw)
|
||||||
|
|
||||||
# validate privileges
|
# validate privileges
|
||||||
@@ -154,7 +276,7 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
|
||||||
# hash the password
|
# hash the password
|
||||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
pw = hash_password(pw)
|
||||||
|
|
||||||
# add the user to the database
|
# add the user to the database
|
||||||
try:
|
try:
|
||||||
@@ -166,8 +288,10 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
# write databasebefore next step
|
# write databasebefore next step
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Create the user's INBOX, Spam, and Drafts folders, and subscribe them.
|
# Create & subscribe the user's INBOX, Trash, Spam, and Drafts folders.
|
||||||
# K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
|
# * Our sieve rule for spam expects that the Spam folder exists.
|
||||||
|
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
|
||||||
|
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
|
||||||
# to avoid unnecessary polling.
|
# to avoid unnecessary polling.
|
||||||
|
|
||||||
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
||||||
@@ -179,7 +303,7 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
|
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
|
||||||
|
|
||||||
for folder in ("INBOX", "Spam", "Drafts"):
|
for folder in ("INBOX", "Trash", "Spam", "Drafts"):
|
||||||
if folder not in existing_mboxes:
|
if folder not in existing_mboxes:
|
||||||
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
|
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
|
||||||
|
|
||||||
@@ -187,10 +311,11 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
return kick(env, "mail user added")
|
return kick(env, "mail user added")
|
||||||
|
|
||||||
def set_mail_password(email, pw, env):
|
def set_mail_password(email, pw, env):
|
||||||
|
# validate that password is acceptable
|
||||||
validate_password(pw)
|
validate_password(pw)
|
||||||
|
|
||||||
# hash the password
|
# hash the password
|
||||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
pw = hash_password(pw)
|
||||||
|
|
||||||
# update the database
|
# update the database
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
@@ -200,7 +325,26 @@ def set_mail_password(email, pw, env):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
def hash_password(pw):
|
||||||
|
# Turn the plain password into a Dovecot-format hashed password, meaning
|
||||||
|
# something like "{SCHEME}hashedpassworddata".
|
||||||
|
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||||
|
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||||
|
|
||||||
|
def get_mail_password(email, env):
|
||||||
|
# Gets the hashed password for a user. Passwords are stored in Dovecot's
|
||||||
|
# password format, with a prefixed scheme.
|
||||||
|
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||||
|
# update the database
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT password FROM users WHERE email=?', (email,))
|
||||||
|
rows = c.fetchall()
|
||||||
|
if len(rows) != 1:
|
||||||
|
raise ValueError("That's not a user (%s)." % email)
|
||||||
|
return rows[0][0]
|
||||||
|
|
||||||
def remove_mail_user(email, env):
|
def remove_mail_user(email, env):
|
||||||
|
# remove
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM users WHERE email=?", (email,))
|
c.execute("DELETE FROM users WHERE email=?", (email,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
@@ -213,11 +357,13 @@ def remove_mail_user(email, env):
|
|||||||
def parse_privs(value):
|
def parse_privs(value):
|
||||||
return [p for p in value.split("\n") if p.strip() != ""]
|
return [p for p in value.split("\n") if p.strip() != ""]
|
||||||
|
|
||||||
def get_mail_user_privileges(email, env):
|
def get_mail_user_privileges(email, env, empty_on_error=False):
|
||||||
|
# get privs
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT privileges FROM users WHERE email=?', (email,))
|
c.execute('SELECT privileges FROM users WHERE email=?', (email,))
|
||||||
rows = c.fetchall()
|
rows = c.fetchall()
|
||||||
if len(rows) != 1:
|
if len(rows) != 1:
|
||||||
|
if empty_on_error: return []
|
||||||
return ("That's not a user (%s)." % email, 400)
|
return ("That's not a user (%s)." % email, 400)
|
||||||
return parse_privs(rows[0][0])
|
return parse_privs(rows[0][0])
|
||||||
|
|
||||||
@@ -254,25 +400,53 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
|
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
|
||||||
|
# convert Unicode domain to IDNA
|
||||||
|
source = sanitize_idn_email_address(source)
|
||||||
|
|
||||||
# validate source
|
# validate source
|
||||||
if source.strip() == "":
|
source = source.strip()
|
||||||
|
if source == "":
|
||||||
return ("No incoming email address provided.", 400)
|
return ("No incoming email address provided.", 400)
|
||||||
if not validate_email(source, mode='alias'):
|
if not validate_email(source, mode='alias'):
|
||||||
return ("Invalid incoming email address (%s)." % source, 400)
|
return ("Invalid incoming email address (%s)." % source, 400)
|
||||||
|
|
||||||
# parse comma and \n-separated destination emails & validate
|
# extra checks for email addresses used in domain control validation
|
||||||
|
is_dcv_source = is_dcv_address(source)
|
||||||
|
|
||||||
|
# validate destination
|
||||||
dests = []
|
dests = []
|
||||||
|
destination = destination.strip()
|
||||||
|
|
||||||
|
# Postfix allows a single @domain.tld as the destination, which means
|
||||||
|
# the local part on the address is preserved in the rewrite. We must
|
||||||
|
# try to convert Unicode to IDNA first before validating that it's a
|
||||||
|
# legitimate alias address. Don't allow this sort of rewriting for
|
||||||
|
# DCV source addresses.
|
||||||
|
d1 = sanitize_idn_email_address(destination)
|
||||||
|
if validate_email(d1, mode='alias') and not is_dcv_source:
|
||||||
|
dests.append(d1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Parse comma and \n-separated destination emails & validate. In this
|
||||||
|
# case, the recipients must be complete email addresses.
|
||||||
for line in destination.split("\n"):
|
for line in destination.split("\n"):
|
||||||
for email in line.split(","):
|
for email in line.split(","):
|
||||||
email = email.strip()
|
email = email.strip()
|
||||||
if email == "": continue
|
if email == "": continue
|
||||||
if not validate_email(email, mode='alias'):
|
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
||||||
|
if not validate_email(email):
|
||||||
return ("Invalid destination email address (%s)." % email, 400)
|
return ("Invalid destination email address (%s)." % email, 400)
|
||||||
|
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(email, env, empty_on_error=True):
|
||||||
|
# Make domain control validation hijacking a little harder to mess up by
|
||||||
|
# requiring aliases for email addresses typically used in DCV to forward
|
||||||
|
# only to accounts that are administrators on this system.
|
||||||
|
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
|
||||||
dests.append(email)
|
dests.append(email)
|
||||||
if len(destination) == 0:
|
if len(destination) == 0:
|
||||||
return ("No destination email address(es) provided.", 400)
|
return ("No destination email address(es) provided.", 400)
|
||||||
destination = ",".join(dests)
|
destination = ",".join(dests)
|
||||||
|
|
||||||
|
# save to db
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
try:
|
try:
|
||||||
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
|
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
|
||||||
@@ -291,6 +465,10 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
|
|||||||
return kick(env, return_status)
|
return kick(env, return_status)
|
||||||
|
|
||||||
def remove_mail_alias(source, env, do_kick=True):
|
def remove_mail_alias(source, env, do_kick=True):
|
||||||
|
# convert Unicode domain to IDNA
|
||||||
|
source = sanitize_idn_email_address(source)
|
||||||
|
|
||||||
|
# remove
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM aliases WHERE source=?", (source,))
|
c.execute("DELETE FROM aliases WHERE source=?", (source,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
@@ -312,12 +490,12 @@ def get_required_aliases(env):
|
|||||||
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
||||||
|
|
||||||
# Get a list of domains we serve mail for, except ones for which the only
|
# Get a list of domains we serve mail for, except ones for which the only
|
||||||
# email on that domain is a postmaster/admin alias to the administrator.
|
# email on that domain is a postmaster/admin alias to the administrator
|
||||||
|
# or a wildcard alias (since it will forward postmaster/admin).
|
||||||
real_mail_domains = get_mail_domains(env,
|
real_mail_domains = get_mail_domains(env,
|
||||||
filter_aliases = lambda alias : \
|
filter_aliases = lambda alias :
|
||||||
(not alias[0].startswith("postmaster@") \
|
((not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@")) or alias[1] != get_system_administrator(env))
|
||||||
and not alias[0].startswith("admin@")) \
|
and not alias[0].startswith("@")
|
||||||
or alias[1] != get_system_administrator(env) \
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
# Create postmaster@ and admin@ for all domains we serve mail on.
|
||||||
|
|||||||
@@ -6,65 +6,201 @@
|
|||||||
|
|
||||||
__ALL__ = ['check_certificate']
|
__ALL__ = ['check_certificate']
|
||||||
|
|
||||||
import os, os.path, re, subprocess, datetime
|
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
||||||
|
|
||||||
import dns.reversename, dns.resolver
|
import dns.reversename, dns.resolver
|
||||||
import dateutil.parser, dateutil.tz
|
import dateutil.parser, dateutil.tz
|
||||||
|
|
||||||
from dns_update import get_dns_zones, build_tlsa_record
|
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns
|
||||||
from web_update import get_web_domains, get_domain_ssl_files
|
from web_update import get_web_domains, get_domain_ssl_files
|
||||||
from mailconfig import get_mail_domains, get_mail_aliases
|
from mailconfig import get_mail_domains, get_mail_aliases
|
||||||
|
|
||||||
from utils import shell, sort_domains, load_env_vars_from_file
|
from utils import shell, sort_domains, load_env_vars_from_file
|
||||||
|
|
||||||
def run_checks(env, output):
|
def run_checks(rounded_values, env, output, pool):
|
||||||
env["out"] = output
|
# run systems checks
|
||||||
run_system_checks(env)
|
output.add_heading("System")
|
||||||
run_network_checks(env)
|
|
||||||
run_domain_checks(env)
|
|
||||||
|
|
||||||
def run_system_checks(env):
|
# check that services are running
|
||||||
env["out"].add_heading("System")
|
if not run_services_checks(env, output, pool):
|
||||||
|
# If critical services are not running, stop. If bind9 isn't running,
|
||||||
|
# all later DNS checks will timeout and that will take forever to
|
||||||
|
# go through, and if running over the web will cause a fastcgi timeout.
|
||||||
|
return
|
||||||
|
|
||||||
# Check that SSH login with password is disabled.
|
# clear bind9's DNS cache so our DNS checks are up to date
|
||||||
|
# (ignore errors; if bind9/rndc isn't running we'd already report
|
||||||
|
# that in run_services checks.)
|
||||||
|
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
||||||
|
|
||||||
|
run_system_checks(rounded_values, env, output)
|
||||||
|
|
||||||
|
# perform other checks asynchronously
|
||||||
|
|
||||||
|
run_network_checks(env, output)
|
||||||
|
run_domain_checks(rounded_values, env, output, pool)
|
||||||
|
|
||||||
|
def get_ssh_port():
|
||||||
|
# Returns ssh port
|
||||||
|
output = shell('check_output', ['sshd', '-T'])
|
||||||
|
returnNext = False
|
||||||
|
|
||||||
|
for e in output.split():
|
||||||
|
if returnNext:
|
||||||
|
return int(e)
|
||||||
|
if e == "port":
|
||||||
|
returnNext = True
|
||||||
|
|
||||||
|
def run_services_checks(env, output, pool):
|
||||||
|
# Check that system services are running.
|
||||||
|
|
||||||
|
services = [
|
||||||
|
{ "name": "Local DNS (bind9)", "port": 53, "public": False, },
|
||||||
|
#{ "name": "NSD Control", "port": 8952, "public": False, },
|
||||||
|
{ "name": "Local DNS Control (bind9/rndc)", "port": 953, "public": False, },
|
||||||
|
{ "name": "Dovecot LMTP LDA", "port": 10026, "public": False, },
|
||||||
|
{ "name": "Postgrey", "port": 10023, "public": False, },
|
||||||
|
{ "name": "Spamassassin", "port": 10025, "public": False, },
|
||||||
|
{ "name": "OpenDKIM", "port": 8891, "public": False, },
|
||||||
|
{ "name": "OpenDMARC", "port": 8893, "public": False, },
|
||||||
|
{ "name": "Memcached", "port": 11211, "public": False, },
|
||||||
|
{ "name": "Sieve (dovecot)", "port": 4190, "public": False, },
|
||||||
|
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
|
||||||
|
|
||||||
|
{ "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, },
|
||||||
|
{ "name": "Public DNS (nsd4)", "port": 53, "public": True, },
|
||||||
|
{ "name": "Incoming Mail (SMTP/postfix)", "port": 25, "public": True, },
|
||||||
|
{ "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, },
|
||||||
|
#{ "name": "Postfix/master", "port": 10587, "public": True, },
|
||||||
|
{ "name": "IMAPS (dovecot)", "port": 993, "public": True, },
|
||||||
|
{ "name": "HTTP Web (nginx)", "port": 80, "public": True, },
|
||||||
|
{ "name": "HTTPS Web (nginx)", "port": 443, "public": True, },
|
||||||
|
]
|
||||||
|
|
||||||
|
all_running = True
|
||||||
|
fatal = False
|
||||||
|
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1)
|
||||||
|
for i, running, fatal2, output2 in sorted(ret):
|
||||||
|
all_running = all_running and running
|
||||||
|
fatal = fatal or fatal2
|
||||||
|
output2.playback(output)
|
||||||
|
|
||||||
|
if all_running:
|
||||||
|
output.print_ok("All system services are running.")
|
||||||
|
|
||||||
|
return not fatal
|
||||||
|
|
||||||
|
def check_service(i, service, env):
|
||||||
|
import socket
|
||||||
|
output = BufferedOutput()
|
||||||
|
running = False
|
||||||
|
fatal = False
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(1)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
s.connect((
|
||||||
|
"127.0.0.1" if not service["public"] else env['PUBLIC_IP'],
|
||||||
|
service["port"]))
|
||||||
|
running = True
|
||||||
|
except OSError as e1:
|
||||||
|
if service["public"] and service["port"] != 53:
|
||||||
|
# For public services (except DNS), try the private IP as a fallback.
|
||||||
|
s1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s1.settimeout(1)
|
||||||
|
try:
|
||||||
|
s1.connect(("127.0.0.1", service["port"]))
|
||||||
|
output.print_error("%s is running but is not publicly accessible at %s:%d (%s)." % (service['name'], env['PUBLIC_IP'], service['port'], str(e1)))
|
||||||
|
except:
|
||||||
|
raise e1
|
||||||
|
finally:
|
||||||
|
s1.close()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
output.print_error("%s is not running (%s; port %d)." % (service['name'], str(e), service['port']))
|
||||||
|
|
||||||
|
# Why is nginx not running?
|
||||||
|
if service["port"] in (80, 443):
|
||||||
|
output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())
|
||||||
|
|
||||||
|
# Flag if local DNS is not running.
|
||||||
|
if service["port"] == 53 and service["public"] == False:
|
||||||
|
fatal = True
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
return (i, running, fatal, output)
|
||||||
|
|
||||||
|
def run_system_checks(rounded_values, env, output):
|
||||||
|
check_ssh_password(env, output)
|
||||||
|
check_software_updates(env, output)
|
||||||
|
check_system_aliases(env, output)
|
||||||
|
check_free_disk_space(rounded_values, env, output)
|
||||||
|
|
||||||
|
def check_ssh_password(env, output):
|
||||||
|
# Check that SSH login with password is disabled. The openssh-server
|
||||||
|
# package may not be installed so check that before trying to access
|
||||||
|
# the configuration file.
|
||||||
|
if not os.path.exists("/etc/ssh/sshd_config"):
|
||||||
|
return
|
||||||
sshd = open("/etc/ssh/sshd_config").read()
|
sshd = open("/etc/ssh/sshd_config").read()
|
||||||
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
||||||
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
||||||
env['out'].print_error("""The SSH server on this machine permits password-based login. A more secure
|
output.print_error("""The SSH server on this machine permits password-based login. A more secure
|
||||||
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
|
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
|
||||||
that you can log in without a password, set the option 'PasswordAuthentication no' in
|
that you can log in without a password, set the option 'PasswordAuthentication no' in
|
||||||
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
|
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
|
||||||
else:
|
else:
|
||||||
env['out'].print_ok("SSH disallows password-based login.")
|
output.print_ok("SSH disallows password-based login.")
|
||||||
|
|
||||||
|
def check_software_updates(env, output):
|
||||||
# Check for any software package updates.
|
# Check for any software package updates.
|
||||||
pkgs = list_apt_updates(apt_update=False)
|
pkgs = list_apt_updates(apt_update=False)
|
||||||
if os.path.exists("/var/run/reboot-required"):
|
if os.path.exists("/var/run/reboot-required"):
|
||||||
env['out'].print_error("System updates have been installed and a reboot of the machine is required.")
|
output.print_error("System updates have been installed and a reboot of the machine is required.")
|
||||||
elif len(pkgs) == 0:
|
elif len(pkgs) == 0:
|
||||||
env['out'].print_ok("System software is up to date.")
|
output.print_ok("System software is up to date.")
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("There are %d software packages that can be updated." % len(pkgs))
|
output.print_error("There are %d software packages that can be updated." % len(pkgs))
|
||||||
for p in pkgs:
|
for p in pkgs:
|
||||||
env['out'].print_line("%s (%s)" % (p["package"], p["version"]))
|
output.print_line("%s (%s)" % (p["package"], p["version"]))
|
||||||
|
|
||||||
|
def check_system_aliases(env, output):
|
||||||
# Check that the administrator alias exists since that's where all
|
# Check that the administrator alias exists since that's where all
|
||||||
# admin email is automatically directed.
|
# admin email is automatically directed.
|
||||||
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env)
|
check_alias_exists("System administrator address", "administrator@" + env['PRIMARY_HOSTNAME'], env, output)
|
||||||
|
|
||||||
def run_network_checks(env):
|
def check_free_disk_space(rounded_values, env, output):
|
||||||
|
# Check free disk space.
|
||||||
|
st = os.statvfs(env['STORAGE_ROOT'])
|
||||||
|
bytes_total = st.f_blocks * st.f_frsize
|
||||||
|
bytes_free = st.f_bavail * st.f_frsize
|
||||||
|
if not rounded_values:
|
||||||
|
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10)
|
||||||
|
else:
|
||||||
|
disk_msg = "The disk has less than %s%% space left." % str(round(bytes_free/bytes_total/10 + .5)*10)
|
||||||
|
if bytes_free > .3 * bytes_total:
|
||||||
|
output.print_ok(disk_msg)
|
||||||
|
elif bytes_free > .15 * bytes_total:
|
||||||
|
output.print_warning(disk_msg)
|
||||||
|
else:
|
||||||
|
output.print_error(disk_msg)
|
||||||
|
|
||||||
|
def run_network_checks(env, output):
|
||||||
# Also see setup/network-checks.sh.
|
# Also see setup/network-checks.sh.
|
||||||
|
|
||||||
env["out"].add_heading("Network")
|
output.add_heading("Network")
|
||||||
|
|
||||||
# Stop if we cannot make an outbound connection on port 25. Many residential
|
# Stop if we cannot make an outbound connection on port 25. Many residential
|
||||||
# networks block outbound port 25 to prevent their network from sending spam.
|
# networks block outbound port 25 to prevent their network from sending spam.
|
||||||
# See if we can reach one of Google's MTAs with a 5-second timeout.
|
# See if we can reach one of Google's MTAs with a 5-second timeout.
|
||||||
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
|
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
|
||||||
if ret == 0:
|
if ret == 0:
|
||||||
env['out'].print_ok("Outbound mail (SMTP port 25) is not blocked.")
|
output.print_ok("Outbound mail (SMTP port 25) is not blocked.")
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
|
output.print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
|
||||||
will not be able to send any mail. Many residential networks block port 25 to prevent hijacked
|
will not be able to send any mail. Many residential networks block port 25 to prevent hijacked
|
||||||
machines from being able to send spam. A quick connection test to Google's mail server on port 25
|
machines from being able to send spam. A quick connection test to Google's mail server on port 25
|
||||||
failed.""")
|
failed.""")
|
||||||
@@ -76,13 +212,13 @@ def run_network_checks(env):
|
|||||||
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
|
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
|
||||||
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
||||||
if zen is None:
|
if zen is None:
|
||||||
env['out'].print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
output.print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
|
output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
|
||||||
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
|
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
|
||||||
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
|
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
|
||||||
|
|
||||||
def run_domain_checks(env):
|
def run_domain_checks(rounded_time, env, output, pool):
|
||||||
# Get the list of domains we handle mail for.
|
# Get the list of domains we handle mail for.
|
||||||
mail_domains = get_mail_domains(env)
|
mail_domains = get_mail_domains(env)
|
||||||
|
|
||||||
@@ -93,40 +229,81 @@ def run_domain_checks(env):
|
|||||||
# Get the list of domains we serve HTTPS for.
|
# Get the list of domains we serve HTTPS for.
|
||||||
web_domains = set(get_web_domains(env))
|
web_domains = set(get_web_domains(env))
|
||||||
|
|
||||||
# Check the domains.
|
domains_to_check = mail_domains | dns_domains | web_domains
|
||||||
for domain in sort_domains(mail_domains | dns_domains | web_domains, env):
|
|
||||||
env["out"].add_heading(domain)
|
# Serial version:
|
||||||
|
#for domain in sort_domains(domains_to_check, env):
|
||||||
|
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||||
|
|
||||||
|
# Parallelize the checks across a worker pool.
|
||||||
|
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||||
|
for domain in domains_to_check)
|
||||||
|
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
||||||
|
ret = dict(ret) # (domain, output) => { domain: output }
|
||||||
|
for domain in sort_domains(ret, env):
|
||||||
|
ret[domain].playback(output)
|
||||||
|
|
||||||
|
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
|
||||||
|
output = BufferedOutput()
|
||||||
|
|
||||||
|
# The domain is IDNA-encoded, but for display use Unicode.
|
||||||
|
output.add_heading(domain.encode('ascii').decode('idna'))
|
||||||
|
|
||||||
if domain == env["PRIMARY_HOSTNAME"]:
|
if domain == env["PRIMARY_HOSTNAME"]:
|
||||||
check_primary_hostname_dns(domain, env)
|
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
|
||||||
|
|
||||||
if domain in dns_domains:
|
if domain in dns_domains:
|
||||||
check_dns_zone(domain, env, dns_zonefiles)
|
check_dns_zone(domain, env, output, dns_zonefiles)
|
||||||
|
|
||||||
if domain in mail_domains:
|
if domain in mail_domains:
|
||||||
check_mail_domain(domain, env)
|
check_mail_domain(domain, env, output)
|
||||||
|
|
||||||
if domain in web_domains:
|
if domain in web_domains:
|
||||||
check_web_domain(domain, env)
|
check_web_domain(domain, rounded_time, env, output)
|
||||||
|
|
||||||
|
if domain in dns_domains:
|
||||||
|
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
|
||||||
|
|
||||||
|
return (domain, output)
|
||||||
|
|
||||||
|
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
||||||
|
# If a DS record is set on the zone containing this domain, check DNSSEC now.
|
||||||
|
has_dnssec = False
|
||||||
|
for zone in dns_domains:
|
||||||
|
if zone == domain or domain.endswith("." + zone):
|
||||||
|
if query_dns(zone, "DS", nxdomain=None) is not None:
|
||||||
|
has_dnssec = True
|
||||||
|
check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True)
|
||||||
|
|
||||||
|
ip = query_dns(domain, "A")
|
||||||
|
ns_ips = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
|
||||||
|
|
||||||
def check_primary_hostname_dns(domain, env):
|
|
||||||
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
||||||
# comes from the TLD since the information is set at the registrar.
|
# comes from the TLD since the information is set at the registrar as glue records.
|
||||||
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
|
# We're probably not actually checking that here but instead checking that we, as
|
||||||
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
|
# the nameserver, are reporting the right info --- but if the glue is incorrect this
|
||||||
env['out'].print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
# will probably fail.
|
||||||
|
if ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
|
||||||
|
output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s ↦ %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||||
|
|
||||||
|
elif ip == env['PUBLIC_IP']:
|
||||||
|
# The NS records are not what we expect, but the domain resolves correctly, so
|
||||||
|
# the user may have set up external DNS. List this discrepancy as a warning.
|
||||||
|
output.print_warning("""Nameserver glue records (ns1.%s and ns2.%s) should be configured at your domain name
|
||||||
|
registrar as having the IP address of this box (%s). They currently report addresses of %s. If you have set up External DNS, this may be OK."""
|
||||||
|
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
||||||
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
|
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
|
||||||
public DNS to update after a change."""
|
public DNS to update after a change."""
|
||||||
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip))
|
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
|
||||||
|
|
||||||
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
|
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
|
||||||
ip = query_dns(domain, "A")
|
|
||||||
if ip == env['PUBLIC_IP']:
|
if ip == env['PUBLIC_IP']:
|
||||||
env['out'].print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
output.print_ok("Domain resolves to box's IP address. [%s ↦ %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
||||||
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
||||||
issues listed here."""
|
issues listed here."""
|
||||||
% (env['PUBLIC_IP'], ip))
|
% (env['PUBLIC_IP'], ip))
|
||||||
@@ -136,9 +313,9 @@ def check_primary_hostname_dns(domain, env):
|
|||||||
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
|
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
|
||||||
existing_rdns = query_dns(ipaddr_rev, "PTR")
|
existing_rdns = query_dns(ipaddr_rev, "PTR")
|
||||||
if existing_rdns == domain:
|
if existing_rdns == domain:
|
||||||
env['out'].print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
output.print_ok("Reverse DNS is set correctly at ISP. [%s ↦ %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
||||||
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
|
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
|
||||||
|
|
||||||
# Check the TLSA record.
|
# Check the TLSA record.
|
||||||
@@ -146,37 +323,66 @@ def check_primary_hostname_dns(domain, env):
|
|||||||
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
|
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
|
||||||
tlsa25_expected = build_tlsa_record(env)
|
tlsa25_expected = build_tlsa_record(env)
|
||||||
if tlsa25 == tlsa25_expected:
|
if tlsa25 == tlsa25_expected:
|
||||||
env['out'].print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
|
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
|
||||||
elif tlsa25 is None:
|
elif tlsa25 is None:
|
||||||
env['out'].print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""")
|
if has_dnssec:
|
||||||
|
# Omit a warning about it not being set if DNSSEC isn't enabled,
|
||||||
|
# since TLSA shouldn't be used without DNSSEC.
|
||||||
|
output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""")
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. Try running tools/dns_update to
|
output.print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'.
|
||||||
regenerate the record. It may take several hours for
|
It may take several hours for public DNS to update after a change."""
|
||||||
public DNS to update after a change."""
|
|
||||||
% (tlsa_qname, tlsa25, tlsa25_expected))
|
% (tlsa_qname, tlsa25, tlsa25_expected))
|
||||||
|
|
||||||
# Check that the hostmaster@ email address exists.
|
# Check that the hostmaster@ email address exists.
|
||||||
check_alias_exists("hostmaster@" + domain, env)
|
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
|
||||||
|
|
||||||
def check_alias_exists(alias, env):
|
def check_alias_exists(alias_name, alias, env, output):
|
||||||
mail_alises = dict(get_mail_aliases(env))
|
mail_alises = dict(get_mail_aliases(env))
|
||||||
if alias in mail_alises:
|
if alias in mail_alises:
|
||||||
env['out'].print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias]))
|
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_alises[alias]))
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
|
output.print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
|
||||||
|
|
||||||
|
def check_dns_zone(domain, env, output, dns_zonefiles):
|
||||||
|
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
|
||||||
|
# If it is not set, we suggest it last.
|
||||||
|
if query_dns(domain, "DS", nxdomain=None) is not None:
|
||||||
|
check_dnssec(domain, env, output, dns_zonefiles)
|
||||||
|
|
||||||
def check_dns_zone(domain, env, dns_zonefiles):
|
|
||||||
# We provide a DNS zone for the domain. It should have NS records set up
|
# We provide a DNS zone for the domain. It should have NS records set up
|
||||||
# at the domain name's registrar pointing to this box.
|
# at the domain name's registrar pointing to this box. The secondary DNS
|
||||||
|
# server may be customized. Unfortunately this may not check the domain's
|
||||||
|
# whois information -- we may be getting the NS records from us rather than
|
||||||
|
# the TLD, and so we're not actually checking the TLD. For that we'd need
|
||||||
|
# to do a DNS trace.
|
||||||
|
ip = query_dns(domain, "A")
|
||||||
|
secondary_ns = get_secondary_dns(get_custom_dns_config(env)) or "ns2." + env['PRIMARY_HOSTNAME']
|
||||||
existing_ns = query_dns(domain, "NS")
|
existing_ns = query_dns(domain, "NS")
|
||||||
correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", env['PRIMARY_HOSTNAME'])
|
correct_ns = "; ".join(sorted([
|
||||||
|
"ns1." + env['PRIMARY_HOSTNAME'],
|
||||||
|
secondary_ns,
|
||||||
|
]))
|
||||||
if existing_ns.lower() == correct_ns.lower():
|
if existing_ns.lower() == correct_ns.lower():
|
||||||
env['out'].print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||||
|
elif ip == env['PUBLIC_IP']:
|
||||||
|
# The domain resolves correctly, so maybe the user is using External DNS.
|
||||||
|
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
|
||||||
|
If you are using External DNS, this may be OK."""
|
||||||
|
% (correct_ns, existing_ns) )
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registar's
|
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
|
||||||
control panel to set the nameservers to %s."""
|
control panel to set the nameservers to %s."""
|
||||||
% (existing_ns, correct_ns) )
|
% (existing_ns, correct_ns) )
|
||||||
|
|
||||||
|
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles):
|
||||||
|
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
|
||||||
|
# (If it was set, we did the check earlier.)
|
||||||
|
if query_dns(domain, "DS", nxdomain=None) is None:
|
||||||
|
check_dnssec(domain, env, output, dns_zonefiles)
|
||||||
|
|
||||||
|
|
||||||
|
def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
|
||||||
# See if the domain has a DS record set at the registrar. The DS record may have
|
# See if the domain has a DS record set at the registrar. The DS record may have
|
||||||
# several forms. We have to be prepared to check for any valid record. We've
|
# several forms. We have to be prepared to check for any valid record. We've
|
||||||
# pre-generated all of the valid digests --- read them in.
|
# pre-generated all of the valid digests --- read them in.
|
||||||
@@ -188,56 +394,68 @@ def check_dns_zone(domain, env, dns_zonefiles):
|
|||||||
|
|
||||||
# Some registrars may want the public key so they can compute the digest. The DS
|
# Some registrars may want the public key so they can compute the digest. The DS
|
||||||
# record that we suggest using is for the KSK (and that's how the DS records were generated).
|
# record that we suggest using is for the KSK (and that's how the DS records were generated).
|
||||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/keys.conf'))
|
alg_name_map = { '7': 'RSASHA1-NSEC3-SHA1', '8': 'RSASHA256' }
|
||||||
|
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg]))
|
||||||
dnsssec_pubkey = open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')).read().split("\t")[3].split(" ")[3]
|
dnsssec_pubkey = open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')).read().split("\t")[3].split(" ")[3]
|
||||||
|
|
||||||
# Query public DNS for the DS record at the registrar.
|
# Query public DNS for the DS record at the registrar.
|
||||||
ds = query_dns(domain, "DS", nxdomain=None)
|
ds = query_dns(domain, "DS", nxdomain=None)
|
||||||
ds_looks_valid = ds and len(ds.split(" ")) == 4
|
ds_looks_valid = ds and len(ds.split(" ")) == 4
|
||||||
if ds_looks_valid: ds = ds.split(" ")
|
if ds_looks_valid: ds = ds.split(" ")
|
||||||
if ds_looks_valid and ds[0] == ds_keytag and ds[1] == '7' and ds[3] == digests.get(ds[2]):
|
if ds_looks_valid and ds[0] == ds_keytag and ds[1] == ds_alg and ds[3] == digests.get(ds[2]):
|
||||||
env['out'].print_ok("DNS 'DS' record is set correctly at registrar.")
|
if is_checking_primary: return
|
||||||
|
output.print_ok("DNSSEC 'DS' record is set correctly at registrar.")
|
||||||
else:
|
else:
|
||||||
if ds == None:
|
if ds == None:
|
||||||
env['out'].print_error("""This domain's DNS DS record is not set. The DS record is optional. The DS record activates DNSSEC.
|
if is_checking_primary: return
|
||||||
|
output.print_warning("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC.
|
||||||
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
|
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""This domain's DNS DS record is incorrect. The chain of trust is broken between the public DNS system
|
if is_checking_primary:
|
||||||
|
output.print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain)
|
||||||
|
return
|
||||||
|
output.print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system
|
||||||
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
|
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
|
||||||
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
|
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
|
||||||
provide to them this information:""")
|
provide to them this information:""")
|
||||||
env['out'].print_line("")
|
output.print_line("")
|
||||||
env['out'].print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
|
output.print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
|
||||||
env['out'].print_line("Key Flags: KSK")
|
output.print_line("Key Flags: KSK")
|
||||||
env['out'].print_line("Algorithm: 7 / RSASHA1-NSEC3-SHA1" + ("" if not ds_looks_valid or ds[1] == '7' else " (Got '%s')" % ds[1]))
|
output.print_line(
|
||||||
|
("Algorithm: %s / %s" % (ds_alg, alg_name_map[ds_alg]))
|
||||||
|
+ ("" if not ds_looks_valid or ds[1] == ds_alg else " (Got '%s')" % ds[1]))
|
||||||
# see http://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
|
# see http://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
|
||||||
env['out'].print_line("Digest Type: 2 / SHA-256")
|
output.print_line("Digest Type: 2 / SHA-256")
|
||||||
# http://www.ietf.org/assignments/ds-rr-types/ds-rr-types.xml
|
# http://www.ietf.org/assignments/ds-rr-types/ds-rr-types.xml
|
||||||
env['out'].print_line("Digest: " + digests['2'])
|
output.print_line("Digest: " + digests['2'])
|
||||||
if ds_looks_valid and ds[3] != digests.get(ds[2]):
|
if ds_looks_valid and ds[3] != digests.get(ds[2]):
|
||||||
env['out'].print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
|
output.print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
|
||||||
env['out'].print_line("Public Key: ")
|
output.print_line("Public Key: ")
|
||||||
env['out'].print_line(dnsssec_pubkey, monospace=True)
|
output.print_line(dnsssec_pubkey, monospace=True)
|
||||||
env['out'].print_line("")
|
output.print_line("")
|
||||||
env['out'].print_line("Bulk/Record Format:")
|
output.print_line("Bulk/Record Format:")
|
||||||
env['out'].print_line("" + ds_correct[0])
|
output.print_line("" + ds_correct[0])
|
||||||
env['out'].print_line("")
|
output.print_line("")
|
||||||
|
|
||||||
def check_mail_domain(domain, env):
|
def check_mail_domain(domain, env, output):
|
||||||
# Check the MX record.
|
# Check the MX record.
|
||||||
|
|
||||||
|
recommended_mx = "10 " + env['PRIMARY_HOSTNAME']
|
||||||
mx = query_dns(domain, "MX", nxdomain=None)
|
mx = query_dns(domain, "MX", nxdomain=None)
|
||||||
expected_mx = "10 " + env['PRIMARY_HOSTNAME']
|
|
||||||
|
|
||||||
if mx == expected_mx:
|
if mx is None:
|
||||||
env['out'].print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx))
|
mxhost = None
|
||||||
|
else:
|
||||||
|
# query_dns returns a semicolon-delimited list
|
||||||
|
# of priority-host pairs.
|
||||||
|
mxhost = mx.split('; ')[0].split(' ')[1]
|
||||||
|
|
||||||
elif mx == None:
|
if mxhost == None:
|
||||||
# A missing MX record is okay on the primary hostname because
|
# A missing MX record is okay on the primary hostname because
|
||||||
# the primary hostname's A record (the MX fallback) is... itself,
|
# the primary hostname's A record (the MX fallback) is... itself,
|
||||||
# which is what we want the MX to be.
|
# which is what we want the MX to be.
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
output.print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
||||||
|
|
||||||
# And a missing MX record is okay on other domains if the A record
|
# And a missing MX record is okay on other domains if the A record
|
||||||
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
|
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
|
||||||
@@ -246,57 +464,74 @@ def check_mail_domain(domain, env):
|
|||||||
domain_a = query_dns(domain, "A", nxdomain=None)
|
domain_a = query_dns(domain, "A", nxdomain=None)
|
||||||
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
|
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
|
||||||
if domain_a != None and domain_a == primary_a:
|
if domain_a != None and domain_a == primary_a:
|
||||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
|
output.print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not
|
output.print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not
|
||||||
be delivered to this box. It may take several hours for public DNS to update after a
|
be delivered to this box. It may take several hours for public DNS to update after a
|
||||||
change. This problem may result from other issues listed here.""" % (expected_mx,))
|
change. This problem may result from other issues listed here.""" % (recommended_mx,))
|
||||||
|
|
||||||
|
elif mxhost == env['PRIMARY_HOSTNAME']:
|
||||||
|
good_news = "Domain's email is directed to this domain. [%s ↦ %s]" % (domain, mx)
|
||||||
|
if mx != recommended_mx:
|
||||||
|
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,)
|
||||||
|
output.print_ok(good_news)
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
|
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
|
||||||
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
|
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
|
||||||
other issues listed here.""" % (mx, expected_mx))
|
other issues listed here.""" % (mx, recommended_mx))
|
||||||
|
|
||||||
# Check that the postmaster@ email address exists.
|
# Check that the postmaster@ email address exists. Not required if the domain has a
|
||||||
check_alias_exists("postmaster@" + domain, env)
|
# catch-all address or domain alias.
|
||||||
|
if "@" + domain not in dict(get_mail_aliases(env)):
|
||||||
|
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
|
||||||
|
|
||||||
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
||||||
# The user might have chosen a domain that was previously in use by a spammer
|
# The user might have chosen a domain that was previously in use by a spammer
|
||||||
# and will not be able to reliably send mail.
|
# and will not be able to reliably send mail.
|
||||||
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
|
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
|
||||||
if dbl is None:
|
if dbl is None:
|
||||||
env['out'].print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
||||||
which may prevent recipients from receiving your mail.
|
which may prevent recipients from receiving your mail.
|
||||||
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
||||||
|
|
||||||
def check_web_domain(domain, env):
|
def check_web_domain(domain, rounded_time, env, output):
|
||||||
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
||||||
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
||||||
# other domains, it is required to access its website.
|
# other domains, it is required to access its website.
|
||||||
if domain != env['PRIMARY_HOSTNAME']:
|
if domain != env['PRIMARY_HOSTNAME']:
|
||||||
ip = query_dns(domain, "A")
|
ip = query_dns(domain, "A")
|
||||||
if ip == env['PUBLIC_IP']:
|
if ip == env['PUBLIC_IP']:
|
||||||
env['out'].print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP']))
|
output.print_ok("Domain resolves to this box's IP address. [%s ↦ %s]" % (domain, env['PUBLIC_IP']))
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
output.print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
||||||
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
||||||
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
|
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
|
||||||
|
|
||||||
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
||||||
# user will log in with IMAP or webmail. Any other domain we serve a
|
# user will log in with IMAP or webmail. Any other domain we serve a
|
||||||
# website for also needs a signed certificate.
|
# website for also needs a signed certificate.
|
||||||
check_ssl_cert(domain, env)
|
check_ssl_cert(domain, rounded_time, env, output)
|
||||||
|
|
||||||
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
||||||
resolver = dns.resolver.get_default_resolver()
|
# Make the qname absolute by appending a period. Without this, dns.resolver.query
|
||||||
|
# will fall back a failed lookup to a second query with this machine's hostname
|
||||||
|
# appended. This has been causing some false-positive Spamhaus reports. The
|
||||||
|
# reverse DNS lookup will pass a dns.name.Name instance which is already
|
||||||
|
# absolute so we should not modify that.
|
||||||
|
if isinstance(qname, str):
|
||||||
|
qname += "."
|
||||||
|
|
||||||
|
# Do the query.
|
||||||
try:
|
try:
|
||||||
response = dns.resolver.query(qname, rtype)
|
response = dns.resolver.query(qname, rtype)
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
# Host did not have an answer for this query; not sure what the
|
# Host did not have an answer for this query; not sure what the
|
||||||
# difference is between the two exceptions.
|
# difference is between the two exceptions.
|
||||||
return nxdomain
|
return nxdomain
|
||||||
|
except dns.exception.Timeout:
|
||||||
|
return "[timeout]"
|
||||||
|
|
||||||
# There may be multiple answers; concatenate the response. Remove trailing
|
# There may be multiple answers; concatenate the response. Remove trailing
|
||||||
# periods from responses since that's how qnames are encoded in DNS but is
|
# periods from responses since that's how qnames are encoded in DNS but is
|
||||||
@@ -304,24 +539,30 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
|||||||
# can compare to a well known order.
|
# can compare to a well known order.
|
||||||
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
||||||
|
|
||||||
def check_ssl_cert(domain, env):
|
def check_ssl_cert(domain, rounded_time, env, output):
|
||||||
# Check that SSL certificate is signed.
|
# Check that SSL certificate is signed.
|
||||||
|
|
||||||
# Skip the check if the A record is not pointed here.
|
# Skip the check if the A record is not pointed here.
|
||||||
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
||||||
|
|
||||||
# Where is the SSL stored?
|
# Where is the SSL stored?
|
||||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
|
|
||||||
if not os.path.exists(ssl_certificate):
|
if not os.path.exists(ssl_certificate):
|
||||||
env['out'].print_error("The SSL certificate file for this domain is missing.")
|
output.print_error("The SSL certificate file for this domain is missing.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check that the certificate is good.
|
# Check that the certificate is good.
|
||||||
|
|
||||||
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
|
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
|
||||||
|
|
||||||
|
if cert_status == "OK":
|
||||||
|
# The certificate is ok. The details has expiry info.
|
||||||
|
output.print_ok("SSL certificate is signed & valid. %s %s" % (ssl_via if ssl_via else "", cert_status_details))
|
||||||
|
|
||||||
|
elif cert_status == "SELF-SIGNED":
|
||||||
|
# Offer instructions for purchasing a signed certificate.
|
||||||
|
|
||||||
if cert_status == "SELF-SIGNED":
|
|
||||||
fingerprint = shell('check_output', [
|
fingerprint = shell('check_output', [
|
||||||
"openssl",
|
"openssl",
|
||||||
"x509",
|
"x509",
|
||||||
@@ -332,47 +573,45 @@ def check_ssl_cert(domain, env):
|
|||||||
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
|
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
|
||||||
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
env['out'].print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
output.print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
||||||
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
||||||
static site hosting). You may choose to confirm the security exception, but check that the certificate
|
static site hosting). Use the SSL Certificates page in this control panel to install a signed SSL certificate.
|
||||||
fingerprint matches the following:""")
|
You may choose to leave the self-signed certificate in place and confirm the security exception, but check that
|
||||||
env['out'].print_line("")
|
the certificate fingerprint matches the following:""")
|
||||||
env['out'].print_line(" " + fingerprint, monospace=True)
|
output.print_line("")
|
||||||
|
output.print_line(" " + fingerprint, monospace=True)
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
output.print_warning("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
||||||
this domain will get a security warning. If you are not serving a website on this domain, then it is
|
this domain will get a security warning. If you are not serving a website on this domain, then it is
|
||||||
safe to leave the self-signed certificate in place.""")
|
safe to leave the self-signed certificate in place. Use the SSL Certificates page in this control panel to
|
||||||
env['out'].print_line("")
|
install a signed SSL certificate.""")
|
||||||
env['out'].print_line("""You can purchase a signed certificate from many places. You will need to provide this Certificate Signing Request (CSR)
|
|
||||||
to whoever you purchase the SSL certificate from:""")
|
|
||||||
env['out'].print_line("")
|
|
||||||
env['out'].print_line(open(ssl_csr_path).read().strip(), monospace=True)
|
|
||||||
env['out'].print_line("")
|
|
||||||
env['out'].print_line("""When you purchase an SSL certificate you will receive a certificate in PEM format and possibly a file containing intermediate certificates in PEM format.
|
|
||||||
If you receive intermediate certificates, use a text editor and paste your certificate on top and then the intermediate certificates
|
|
||||||
below it. Save the file and place it onto this machine at %s. Then run "service nginx restart".""" % ssl_certificate)
|
|
||||||
|
|
||||||
elif cert_status == "OK":
|
|
||||||
env['out'].print_ok("SSL certificate is signed & valid.")
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
env['out'].print_error("The SSL certificate has a problem:")
|
output.print_error("The SSL certificate has a problem: " + cert_status)
|
||||||
env['out'].print_line("")
|
if cert_status_details:
|
||||||
env['out'].print_line(cert_status)
|
output.print_line("")
|
||||||
env['out'].print_line("")
|
output.print_line(cert_status_details)
|
||||||
|
output.print_line("")
|
||||||
|
|
||||||
def check_certificate(domain, ssl_certificate, ssl_private_key):
|
def check_certificate(domain, ssl_certificate, ssl_private_key, rounded_time=False):
|
||||||
# Use openssl verify to check the status of a certificate.
|
# Use openssl verify to check the status of a certificate.
|
||||||
|
|
||||||
# First check that the certificate is for the right domain. The domain
|
# First check that the certificate is for the right domain. The domain
|
||||||
# must be found in the Subject Common Name (CN) or be one of the
|
# must be found in the Subject Common Name (CN) or be one of the
|
||||||
# Subject Alternative Names. A wildcard might also appear as the CN
|
# Subject Alternative Names. A wildcard might also appear as the CN
|
||||||
# or in the SAN list, so check for that tool.
|
# or in the SAN list, so check for that tool.
|
||||||
cert_dump = shell('check_output', [
|
retcode, cert_dump = shell('check_output', [
|
||||||
"openssl", "x509",
|
"openssl", "x509",
|
||||||
"-in", ssl_certificate,
|
"-in", ssl_certificate,
|
||||||
"-noout", "-text", "-nameopt", "rfc2253",
|
"-noout", "-text", "-nameopt", "rfc2253",
|
||||||
])
|
], trap=True)
|
||||||
|
|
||||||
|
# If the certificate is catastrophically bad, catch that now and report it.
|
||||||
|
# More information was probably written to stderr (which we aren't capturing),
|
||||||
|
# but it is probably not helpful to the user anyway.
|
||||||
|
if retcode != 0:
|
||||||
|
return ("The SSL certificate appears to be corrupted or not a PEM-formatted SSL certificate file. (%s)" % ssl_certificate, None)
|
||||||
|
|
||||||
cert_dump = cert_dump.split("\n")
|
cert_dump = cert_dump.split("\n")
|
||||||
certificate_names = set()
|
certificate_names = set()
|
||||||
cert_expiration_date = None
|
cert_expiration_date = None
|
||||||
@@ -403,8 +642,8 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
|||||||
|
|
||||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
||||||
if domain is not None and domain not in certificate_names and wildcard_domain not in certificate_names:
|
if domain is not None and domain not in certificate_names and wildcard_domain not in certificate_names:
|
||||||
return "This certificate is for the wrong domain names. It is for %s." % \
|
return ("The certificate is for the wrong domain name. It is for %s."
|
||||||
", ".join(sorted(certificate_names))
|
% ", ".join(sorted(certificate_names)), None)
|
||||||
|
|
||||||
# Second, check that the certificate matches the private key. Get the modulus of the
|
# Second, check that the certificate matches the private key. Get the modulus of the
|
||||||
# private key and of the public key in the certificate. They should match. The output
|
# private key and of the public key in the certificate. They should match. The output
|
||||||
@@ -420,7 +659,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
|||||||
"-in", ssl_certificate,
|
"-in", ssl_certificate,
|
||||||
"-noout", "-modulus"])
|
"-noout", "-modulus"])
|
||||||
if private_key_modulus != cert_key_modulus:
|
if private_key_modulus != cert_key_modulus:
|
||||||
return "The certificate installed at %s does not correspond to the private key at %s." % (ssl_certificate, ssl_private_key)
|
return ("The certificate installed at %s does not correspond to the private key at %s." % (ssl_certificate, ssl_private_key), None)
|
||||||
|
|
||||||
# Next validate that the certificate is valid. This checks whether the certificate
|
# Next validate that the certificate is valid. This checks whether the certificate
|
||||||
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
||||||
@@ -434,7 +673,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
|||||||
cert = open(ssl_certificate).read()
|
cert = open(ssl_certificate).read()
|
||||||
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
|
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
|
||||||
if m == None:
|
if m == None:
|
||||||
return "The certificate file is an invalid PEM certificate."
|
return ("The certificate file is an invalid PEM certificate.", None)
|
||||||
mycert, chaincerts = m.groups()
|
mycert, chaincerts = m.groups()
|
||||||
|
|
||||||
# This command returns a non-zero exit status in most cases, so trap errors.
|
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||||
@@ -450,10 +689,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
|||||||
|
|
||||||
if "self signed" in verifyoutput:
|
if "self signed" in verifyoutput:
|
||||||
# Certificate is self-signed.
|
# Certificate is self-signed.
|
||||||
return "SELF-SIGNED"
|
return ("SELF-SIGNED", None)
|
||||||
elif retcode != 0:
|
elif retcode != 0:
|
||||||
|
if "unable to get local issuer certificate" in verifyoutput:
|
||||||
|
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
|
||||||
|
|
||||||
# There is some unknown problem. Return the `openssl verify` raw output.
|
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||||
return verifyoutput.strip()
|
return ("There is a problem with the SSL certificate.", verifyoutput.strip())
|
||||||
else:
|
else:
|
||||||
# `openssl verify` returned a zero exit status so the cert is currently
|
# `openssl verify` returned a zero exit status so the cert is currently
|
||||||
# good.
|
# good.
|
||||||
@@ -461,11 +703,20 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
|||||||
# But is it expiring soon?
|
# But is it expiring soon?
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
ndays = (cert_expiration_date-now).days
|
ndays = (cert_expiration_date-now).days
|
||||||
|
if not rounded_time or ndays < 7:
|
||||||
|
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
||||||
|
elif ndays <= 14:
|
||||||
|
expiry_info = "The certificate expires in less than two weeks, on %s." % cert_expiration_date.strftime("%x")
|
||||||
|
elif ndays <= 31:
|
||||||
|
expiry_info = "The certificate expires in less than a month, on %s." % cert_expiration_date.strftime("%x")
|
||||||
|
else:
|
||||||
|
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
|
||||||
|
|
||||||
if ndays <= 31:
|
if ndays <= 31:
|
||||||
return "This certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
return ("The certificate is expiring soon: " + expiry_info, None)
|
||||||
|
|
||||||
# Return the special OK code.
|
# Return the special OK code.
|
||||||
return "OK"
|
return ("OK", expiry_info)
|
||||||
|
|
||||||
_apt_updates = None
|
_apt_updates = None
|
||||||
def list_apt_updates(apt_update=True):
|
def list_apt_updates(apt_update=True):
|
||||||
@@ -501,16 +752,109 @@ def list_apt_updates(apt_update=True):
|
|||||||
|
|
||||||
return pkgs
|
return pkgs
|
||||||
|
|
||||||
|
def run_and_output_changes(env, pool, send_via_email):
|
||||||
|
import json
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
if not send_via_email:
|
||||||
|
out = ConsoleOutput()
|
||||||
|
else:
|
||||||
|
import io
|
||||||
|
out = FileOutput(io.StringIO(""), 70)
|
||||||
|
|
||||||
|
# Run status checks.
|
||||||
|
cur = BufferedOutput()
|
||||||
|
run_checks(True, env, cur, pool)
|
||||||
|
|
||||||
|
# Load previously saved status checks.
|
||||||
|
cache_fn = "/var/cache/mailinabox/status_checks.json"
|
||||||
|
if os.path.exists(cache_fn):
|
||||||
|
prev = json.load(open(cache_fn))
|
||||||
|
|
||||||
|
# Group the serial output into categories by the headings.
|
||||||
|
def group_by_heading(lines):
|
||||||
|
from collections import OrderedDict
|
||||||
|
ret = OrderedDict()
|
||||||
|
k = []
|
||||||
|
ret["No Category"] = k
|
||||||
|
for line_type, line_args, line_kwargs in lines:
|
||||||
|
if line_type == "add_heading":
|
||||||
|
k = []
|
||||||
|
ret[line_args[0]] = k
|
||||||
|
else:
|
||||||
|
k.append((line_type, line_args, line_kwargs))
|
||||||
|
return ret
|
||||||
|
prev_status = group_by_heading(prev)
|
||||||
|
cur_status = group_by_heading(cur.buf)
|
||||||
|
|
||||||
|
# Compare the previous to the current status checks
|
||||||
|
# category by category.
|
||||||
|
for category, cur_lines in cur_status.items():
|
||||||
|
if category not in prev_status:
|
||||||
|
out.add_heading(category + " -- Added")
|
||||||
|
BufferedOutput(with_lines=cur_lines).playback(out)
|
||||||
|
else:
|
||||||
|
# Actual comparison starts here...
|
||||||
|
prev_lines = prev_status[category]
|
||||||
|
def stringify(lines):
|
||||||
|
return [json.dumps(line) for line in lines]
|
||||||
|
diff = SequenceMatcher(None, stringify(prev_lines), stringify(cur_lines)).get_opcodes()
|
||||||
|
for op, i1, i2, j1, j2 in diff:
|
||||||
|
if op == "replace":
|
||||||
|
out.add_heading(category + " -- Previously:")
|
||||||
|
elif op == "delete":
|
||||||
|
out.add_heading(category + " -- Removed")
|
||||||
|
if op in ("replace", "delete"):
|
||||||
|
BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)
|
||||||
|
|
||||||
|
if op == "replace":
|
||||||
|
out.add_heading(category + " -- Currently:")
|
||||||
|
elif op == "insert":
|
||||||
|
out.add_heading(category + " -- Added")
|
||||||
|
if op in ("replace", "insert"):
|
||||||
|
BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)
|
||||||
|
|
||||||
|
for category, prev_lines in prev_status.items():
|
||||||
|
if category not in cur_status:
|
||||||
|
out.add_heading(category)
|
||||||
|
out.print_warning("This section was removed.")
|
||||||
|
|
||||||
|
if send_via_email:
|
||||||
|
# If there were changes, send off an email.
|
||||||
|
buf = out.buf.getvalue()
|
||||||
|
if len(buf) > 0:
|
||||||
|
# create MIME message
|
||||||
|
from email.message import Message
|
||||||
|
msg = Message()
|
||||||
|
msg['From'] = "\"%s\" <administrator@%s>" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'])
|
||||||
|
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
|
||||||
|
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
|
||||||
|
msg.set_payload(buf, "UTF-8")
|
||||||
|
|
||||||
|
# send to administrator@
|
||||||
|
import smtplib
|
||||||
|
mailserver = smtplib.SMTP('localhost', 25)
|
||||||
|
mailserver.ehlo()
|
||||||
|
mailserver.sendmail(
|
||||||
|
"administrator@%s" % env['PRIMARY_HOSTNAME'], # MAIL FROM
|
||||||
|
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
|
||||||
|
msg.as_string())
|
||||||
|
mailserver.quit()
|
||||||
|
|
||||||
|
# Store the current status checks output for next time.
|
||||||
|
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
|
||||||
|
with open(cache_fn, "w") as f:
|
||||||
|
json.dump(cur.buf, f, indent=True)
|
||||||
|
|
||||||
|
class FileOutput:
|
||||||
|
def __init__(self, buf, width):
|
||||||
|
self.buf = buf
|
||||||
|
self.width = width
|
||||||
|
|
||||||
try:
|
|
||||||
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
|
|
||||||
except:
|
|
||||||
terminal_columns = 76
|
|
||||||
class ConsoleOutput:
|
|
||||||
def add_heading(self, heading):
|
def add_heading(self, heading):
|
||||||
print()
|
print(file=self.buf)
|
||||||
print(heading)
|
print(heading, file=self.buf)
|
||||||
print("=" * len(heading))
|
print("=" * len(heading), file=self.buf)
|
||||||
|
|
||||||
def print_ok(self, message):
|
def print_ok(self, message):
|
||||||
self.print_block(message, first_line="✓ ")
|
self.print_block(message, first_line="✓ ")
|
||||||
@@ -518,40 +862,73 @@ class ConsoleOutput:
|
|||||||
def print_error(self, message):
|
def print_error(self, message):
|
||||||
self.print_block(message, first_line="✖ ")
|
self.print_block(message, first_line="✖ ")
|
||||||
|
|
||||||
|
def print_warning(self, message):
|
||||||
|
self.print_block(message, first_line="? ")
|
||||||
|
|
||||||
def print_block(self, message, first_line=" "):
|
def print_block(self, message, first_line=" "):
|
||||||
print(first_line, end='')
|
print(first_line, end='', file=self.buf)
|
||||||
message = re.sub("\n\s*", " ", message)
|
message = re.sub("\n\s*", " ", message)
|
||||||
words = re.split("(\s+)", message)
|
words = re.split("(\s+)", message)
|
||||||
linelen = 0
|
linelen = 0
|
||||||
for w in words:
|
for w in words:
|
||||||
if linelen + len(w) > terminal_columns-1-len(first_line):
|
if linelen + len(w) > self.width-1-len(first_line):
|
||||||
print()
|
print(file=self.buf)
|
||||||
print(" ", end="")
|
print(" ", end="", file=self.buf)
|
||||||
linelen = 0
|
linelen = 0
|
||||||
if linelen == 0 and w.strip() == "": continue
|
if linelen == 0 and w.strip() == "": continue
|
||||||
print(w, end="")
|
print(w, end="", file=self.buf)
|
||||||
linelen += len(w)
|
linelen += len(w)
|
||||||
print()
|
print(file=self.buf)
|
||||||
|
|
||||||
def print_line(self, message, monospace=False):
|
def print_line(self, message, monospace=False):
|
||||||
for line in message.split("\n"):
|
for line in message.split("\n"):
|
||||||
self.print_block(line)
|
self.print_block(line)
|
||||||
|
|
||||||
|
class ConsoleOutput(FileOutput):
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = sys.stdout
|
||||||
|
try:
|
||||||
|
self.width = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||||
|
except:
|
||||||
|
self.width = 76
|
||||||
|
|
||||||
|
class BufferedOutput:
|
||||||
|
# Record all of the instance method calls so we can play them back later.
|
||||||
|
def __init__(self, with_lines=None):
|
||||||
|
self.buf = [] if not with_lines else with_lines
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"):
|
||||||
|
raise AttributeError
|
||||||
|
# Return a function that just records the call & arguments to our buffer.
|
||||||
|
def w(*args, **kwargs):
|
||||||
|
self.buf.append((attr, args, kwargs))
|
||||||
|
return w
|
||||||
|
def playback(self, output):
|
||||||
|
for attr, args, kwargs in self.buf:
|
||||||
|
getattr(output, attr)(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
|
||||||
from utils import load_environment
|
from utils import load_environment
|
||||||
|
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
|
pool = multiprocessing.pool.Pool(processes=10)
|
||||||
|
|
||||||
if len(sys.argv) == 1:
|
if len(sys.argv) == 1:
|
||||||
run_checks(env, ConsoleOutput())
|
run_checks(False, env, ConsoleOutput(), pool)
|
||||||
|
|
||||||
|
elif sys.argv[1] == "--show-changes":
|
||||||
|
run_and_output_changes(env, pool, sys.argv[-1] == "--smtp")
|
||||||
|
|
||||||
elif sys.argv[1] == "--check-primary-hostname":
|
elif sys.argv[1] == "--check-primary-hostname":
|
||||||
# See if the primary hostname appears resolvable and has a signed certificate.
|
# See if the primary hostname appears resolvable and has a signed certificate.
|
||||||
domain = env['PRIMARY_HOSTNAME']
|
domain = env['PRIMARY_HOSTNAME']
|
||||||
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
if not os.path.exists(ssl_certificate):
|
if not os.path.exists(ssl_certificate):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
|
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||||
if cert_status != "OK":
|
if cert_status != "OK":
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -11,20 +11,34 @@
|
|||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addaliasEmail" class="col-sm-2 control-label">Email Address</label>
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<div class="col-sm-10">
|
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
||||||
<input type="email" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address">
|
<button type="button" class="btn btn-default active" data-mode="regular">Regular</button>
|
||||||
|
<button type="button" class="btn btn-default" data-mode="catchall">Catch-All</button>
|
||||||
|
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
|
||||||
|
</div>
|
||||||
|
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
||||||
|
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain. Enter just a part of an email address starting with the @-sign.</span>
|
||||||
|
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched mail from one domain to another domain, preserving the part before the @-sign.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addaliasTargets" class="col-sm-2 control-label">Forward To</label>
|
<label for="addaliasEmail" class="col-sm-1 control-label">Alias</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<textarea class="form-control" rows="3" id="addaliasTargets" placeholder="Forward to these email addresses (one per line or separated by commas)"></textarea>
|
<input type="email" class="form-control" id="addaliasEmail">
|
||||||
|
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">You may use international (non-ASCII) characters for the domain part of the email address only.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
<label for="addaliasTargets" class="col-sm-1 control-label">Forward To</label>
|
||||||
<button id="add-alias-button" type="submit" class="btn btn-primary">Add</button>
|
<div class="col-sm-10">
|
||||||
|
<textarea class="form-control" rows="3" id="addaliasTargets"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
|
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
|
||||||
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
|
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +49,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Email Address<br></th>
|
<th>Alias<br></th>
|
||||||
<th>Forwards To</th>
|
<th>Forwards To</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -49,7 +63,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr id="alias-template">
|
<tr id="alias-template">
|
||||||
<td class='actions'>
|
<td class='actions'>
|
||||||
<a href="#" onclick="aliases_edit(this); return false;" class='edit' title="Edit Alias">
|
<a href="#" onclick="aliases_edit(this); scroll_top(); return false;" class='edit' title="Edit Alias">
|
||||||
<span class="glyphicon glyphicon-pencil"></span>
|
<span class="glyphicon glyphicon-pencil"></span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
|
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
|
||||||
@@ -73,16 +87,52 @@ function show_aliases() {
|
|||||||
function(r) {
|
function(r) {
|
||||||
$('#alias_table tbody').html("");
|
$('#alias_table tbody').html("");
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
|
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
|
||||||
|
hdr.find('h4').text(r[i].domain);
|
||||||
|
$('#alias_table tbody').append(hdr);
|
||||||
|
|
||||||
|
for (var k = 0; k < r[i].aliases.length; k++) {
|
||||||
|
var alias = r[i].aliases[k];
|
||||||
|
|
||||||
var n = $("#alias-template").clone();
|
var n = $("#alias-template").clone();
|
||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
|
|
||||||
if (r[i].required) n.addClass('alias-required');
|
if (alias.required) n.addClass('alias-required');
|
||||||
n.attr('data-email', r[i].source);
|
n.attr('data-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
||||||
n.find('td.email').text(r[i].source)
|
n.find('td.email').text(alias.source_display)
|
||||||
for (var j = 0; j < r[i].destination.length; j++)
|
for (var j = 0; j < alias.destination.length; j++)
|
||||||
n.find('td.target').append($("<div></div>").text(r[i].destination[j]))
|
n.find('td.target').append($("<div></div>").text(alias.destination[j]))
|
||||||
$('#alias_table tbody').append(n);
|
$('#alias_table tbody').append(n);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
$('#alias_type_buttons button').off('click').click(function() {
|
||||||
|
$('#alias_type_buttons button').removeClass('active');
|
||||||
|
$(this).addClass('active');
|
||||||
|
if ($(this).attr('data-mode') == "regular") {
|
||||||
|
$('#addaliasEmail').attr('type', 'email');
|
||||||
|
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
|
||||||
|
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
||||||
|
$('#alias_mode_info').slideUp();
|
||||||
|
} else if ($(this).attr('data-mode') == "catchall") {
|
||||||
|
$('#addaliasEmail').attr('type', 'text');
|
||||||
|
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
|
||||||
|
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
||||||
|
$('#alias_mode_info').slideDown();
|
||||||
|
$('#alias_mode_info span').addClass('hidden');
|
||||||
|
$('#alias_mode_info span.catchall').removeClass('hidden');
|
||||||
|
} else if ($(this).attr('data-mode') == "domainalias") {
|
||||||
|
$('#addaliasEmail').attr('type', 'text');
|
||||||
|
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
|
||||||
|
$('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)');
|
||||||
|
$('#alias_mode_info').slideDown();
|
||||||
|
$('#alias_mode_info span').addClass('hidden');
|
||||||
|
$('#alias_mode_info span.domainalias').removeClass('hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +166,7 @@ function aliases_reset_form() {
|
|||||||
$("#addaliasEmail").val('')
|
$("#addaliasEmail").val('')
|
||||||
$("#addaliasTargets").val('')
|
$("#addaliasTargets").val('')
|
||||||
$('#alias-cancel').addClass('hidden');
|
$('#alias-cancel').addClass('hidden');
|
||||||
$('#add-alias-button').text('Add');
|
$('#add-alias-button').text('Add Alias');
|
||||||
is_alias_add_update = false;
|
is_alias_add_update = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +183,12 @@ function aliases_edit(elem) {
|
|||||||
$("#addaliasEmail").val(email);
|
$("#addaliasEmail").val(email);
|
||||||
$("#addaliasTargets").val(targets);
|
$("#addaliasTargets").val(targets);
|
||||||
$('#add-alias-button').text('Update');
|
$('#add-alias-button').text('Update');
|
||||||
|
if (email.charAt(0) == '@' && targets.charAt(0) == '@')
|
||||||
|
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
||||||
|
else if (email.charAt(0) == '@')
|
||||||
|
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
||||||
|
else
|
||||||
|
$('#alias_type_buttons button[data-mode="regular"]').click();
|
||||||
$('body').animate({ scrollTop: 0 })
|
$('body').animate({ scrollTop: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,4 +212,10 @@ function aliases_remove(elem) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scroll_top() {
|
||||||
|
$('html, body').animate({
|
||||||
|
scrollTop: $("#panel_aliases").offset().top
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
253
management/templates/custom-dns.html
Normal file
253
management/templates/custom-dns.html
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<style>
|
||||||
|
#custom-dns-current td.long {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Custom DNS</h2>
|
||||||
|
|
||||||
|
<p class="text-warning">This is an advanced configuration page.</p>
|
||||||
|
|
||||||
|
<p>It is possible to set custom DNS records on domains hosted here.</p>
|
||||||
|
|
||||||
|
<h3>Set Custom DNS Records</h3>
|
||||||
|
|
||||||
|
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>
|
||||||
|
|
||||||
|
<form class="form-horizontal" role="form" onsubmit="do_set_custom_dns(); return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="customdnsQname" class="col-sm-1 control-label">Name</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<table style="max-width: 400px">
|
||||||
|
<tr><td>
|
||||||
|
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
|
||||||
|
</td><td style="padding: 0 1em; font-weight: bold;">.</td><td>
|
||||||
|
<select id="customdnsZone" class="form-control"> </select>
|
||||||
|
</td></tr></table>
|
||||||
|
<div class="text-info" style="margin-top: .5em">Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
|
||||||
|
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012).">A (IPv4 address)</option>
|
||||||
|
<option value="AAAA" data-hint="Enter an IPv6 address.">AAAA (IPv6 address)</option>
|
||||||
|
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
|
||||||
|
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
||||||
|
<option value="MX" data-hint="Enter record in the form of PRIORIY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="customdnsValue" class="col-sm-1 control-label">Value</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" id="customdnsValue" placeholder="">
|
||||||
|
<div id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
|
<button type="submit" class="btn btn-primary">Set Record</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table id="custom-dns-current" class="table" style="width: auto; display: none">
|
||||||
|
<thead>
|
||||||
|
<th>Domain Name</th>
|
||||||
|
<th>Record Type</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th></th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td colspan="4">Loading...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Using a Secondary Nameserver</h3>
|
||||||
|
|
||||||
|
<p>If your TLD requires you to have two separate nameservers, you can either set up a secondary (aka “slave”) nameserver or, alternatively, set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box. If you choose to use a seconday/slave nameserver, you must find a seconday/slave nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the seconday/slave nameserver service, enter the hostname of <em>their</em> secondary nameserver:</p>
|
||||||
|
|
||||||
|
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="secondarydnsHostname" class="col-sm-1 control-label">Hostname</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" id="secondarydnsHostname" placeholder="ns1.cloudprovider.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="secondarydns-clear-instructions" class="form-group" style="display: none">
|
||||||
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
|
<p class="small">Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>Custom DNS API</h3>
|
||||||
|
|
||||||
|
<p>Use your box’s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>
|
||||||
|
|
||||||
|
<p>Usage:</p>
|
||||||
|
|
||||||
|
<pre>curl -X <b>VERB</b> [-d "<b>value</b>"] --user {email}:{password} https://{{hostname}}/admin/dns/custom[/<b>qname</b>[/<b>rtype</b>]]</pre>
|
||||||
|
|
||||||
|
<p>(Brackets denote an optional argument.)</p>
|
||||||
|
|
||||||
|
<h4>Verbs</h4>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead><th>Verb</th> <th>Usage</th></thead>
|
||||||
|
<tr><td>GET</td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d "..."</code>) must be omitted.</td></tr>
|
||||||
|
<tr><td>PUT</td> <td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and <code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and <code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td></tr>
|
||||||
|
<tr><td>POST</td> <td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin <code>A</code> records. (PUT would delete previously added records.)</td></tr>
|
||||||
|
<tr><td>DELETE</td> <td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only the record matching the <code>qname</code>, <code>rtype</code> and value.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>Parameters</h4>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead><th>Parameter</th> <th>Value</th></thead>
|
||||||
|
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
||||||
|
<tr><td>password</td> <td>That user’s password.</td></tr>
|
||||||
|
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
|
||||||
|
<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name — don’t forget the final period), <code>MX</code>, or <code>SRV</code>.</td></tr>
|
||||||
|
<tr><td>value</td> <td>For PUT, POST, and DELETE, the record’s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be added to all custom domains unless you override them.</p>
|
||||||
|
|
||||||
|
<h4>Examples:</h4>
|
||||||
|
|
||||||
|
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
|
||||||
|
|
||||||
|
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
|
||||||
|
curl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
|
||||||
|
|
||||||
|
# deletes that record and all A records for that domain name
|
||||||
|
curl -X DELETE https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
|
||||||
|
|
||||||
|
# sets a CNAME alias
|
||||||
|
curl -X PUT -d "bar.mydomain.com." https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname
|
||||||
|
|
||||||
|
# deletes that CNAME and all CNAME records for that domain name
|
||||||
|
curl -X DELETE https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname
|
||||||
|
|
||||||
|
# adds a TXT record using POST to preserve any previous TXT records
|
||||||
|
curl -X POST -d "some text here" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt
|
||||||
|
|
||||||
|
# deletes that one TXT record while preserving other TXT records
|
||||||
|
curl -X DELETE -d "some text here" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function show_custom_dns() {
|
||||||
|
api(
|
||||||
|
"/dns/secondary-nameserver",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(data) {
|
||||||
|
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
|
||||||
|
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
|
||||||
|
});
|
||||||
|
|
||||||
|
api(
|
||||||
|
"/dns/zones",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(data) {
|
||||||
|
$('#customdnsZone').text('');
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
$('#customdnsZone').append($('<option/>').text(data[i]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
show_current_custom_dns();
|
||||||
|
show_customdns_rtype_hint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_current_custom_dns() {
|
||||||
|
api(
|
||||||
|
"/dns/custom",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(data) {
|
||||||
|
if (data.length > 0)
|
||||||
|
$('#custom-dns-current').fadeIn();
|
||||||
|
else
|
||||||
|
$('#custom-dns-current').fadeOut();
|
||||||
|
|
||||||
|
$('#custom-dns-current').find("tbody").text('');
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
var tr = $("<tr/>");
|
||||||
|
$('#custom-dns-current').find("tbody").append(tr);
|
||||||
|
tr.attr('data-qname', data[i].qname);
|
||||||
|
tr.attr('data-rtype', data[i].rtype);
|
||||||
|
tr.attr('data-value', data[i].value);
|
||||||
|
tr.append($('<td class="long"/>').text(data[i].qname));
|
||||||
|
tr.append($('<td/>').text(data[i].rtype));
|
||||||
|
tr.append($('<td class="long"/>').text(data[i].value));
|
||||||
|
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delete_custom_dns_record(elem) {
|
||||||
|
var qname = $(elem).parents('tr').attr('data-qname');
|
||||||
|
var rtype = $(elem).parents('tr').attr('data-rtype');
|
||||||
|
var value = $(elem).parents('tr').attr('data-value');
|
||||||
|
do_set_custom_dns(qname, rtype, value, "DELETE");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_set_secondary_dns() {
|
||||||
|
api(
|
||||||
|
"/dns/secondary-nameserver",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
hostname: $('#secondarydnsHostname').val()
|
||||||
|
},
|
||||||
|
function(data) {
|
||||||
|
if (data == "") return; // nothing updated
|
||||||
|
show_modal_error("Secondary DNS", $("<pre/>").text(data));
|
||||||
|
$('#secondarydns-clear-instructions').slideDown();
|
||||||
|
},
|
||||||
|
function(err) {
|
||||||
|
show_modal_error("Secondary DNS", $("<pre/>").text(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_set_custom_dns(qname, rtype, value, method) {
|
||||||
|
if (!qname) {
|
||||||
|
if ($('#customdnsQname').val() != '')
|
||||||
|
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
|
||||||
|
else
|
||||||
|
qname = $('#customdnsZone').val();
|
||||||
|
rtype = $('#customdnsType').val();
|
||||||
|
value = $('#customdnsValue').val();
|
||||||
|
method = 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
api(
|
||||||
|
"/dns/custom/" + qname + "/" + rtype,
|
||||||
|
method,
|
||||||
|
value,
|
||||||
|
function(data) {
|
||||||
|
if (data == "") return; // nothing updated
|
||||||
|
show_modal_error("Custom DNS", $("<pre/>").text(data));
|
||||||
|
show_current_custom_dns();
|
||||||
|
},
|
||||||
|
function(err) {
|
||||||
|
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_customdns_rtype_hint() {
|
||||||
|
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
78
management/templates/external-dns.html
Normal file
78
management/templates/external-dns.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<style>
|
||||||
|
#external_dns_settings .heading td {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 120%;
|
||||||
|
padding-top: 1.5em;
|
||||||
|
}
|
||||||
|
#external_dns_settings .heading.first td {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
#external_dns_settings .values td {
|
||||||
|
border: 0;
|
||||||
|
padding-top: .75em;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
#external_dns_settings .value {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
#external_dns_settings .explanation td {
|
||||||
|
border: 0;
|
||||||
|
padding-top: .5em;
|
||||||
|
padding-bottom: .75em;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 95%;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>External DNS</h2>
|
||||||
|
|
||||||
|
<p class="text-warning">This is an advanced configuration page.</p>
|
||||||
|
|
||||||
|
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS zone information shown in the table below into your external DNS server’s control panel.</p>
|
||||||
|
|
||||||
|
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
||||||
|
|
||||||
|
<table id="external_dns_settings" class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>QName</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function show_external_dns() {
|
||||||
|
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
|
api(
|
||||||
|
"/dns/dump",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(zones) {
|
||||||
|
$('#external_dns_settings tbody').html("");
|
||||||
|
for (var j = 0; j < zones.length; j++) {
|
||||||
|
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
|
||||||
|
h.find("td").text(zones[j][0]);
|
||||||
|
$('#external_dns_settings tbody').append(h);
|
||||||
|
|
||||||
|
var r = zones[j][1];
|
||||||
|
for (var i = 0; i < r.length; i++) {
|
||||||
|
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
|
||||||
|
n.find('.qname').text(r[i].qname);
|
||||||
|
n.find('.rtype').text(r[i].rtype);
|
||||||
|
n.find('.value').text(r[i].value);
|
||||||
|
$('#external_dns_settings tbody').append(n);
|
||||||
|
|
||||||
|
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
|
||||||
|
n.find('td').text(r[i].explanation);
|
||||||
|
$('#external_dns_settings tbody').append(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
|
<html lang="en">
|
||||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
|
|
||||||
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
|
|
||||||
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
<title>{{hostname}} - Mail-in-a-Box Control Panel</title>
|
<title>{{hostname}} - Mail-in-a-Box Control Panel</title>
|
||||||
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
|
||||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css">
|
||||||
<style>
|
<style>
|
||||||
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
||||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
||||||
@@ -46,33 +43,46 @@
|
|||||||
margin-bottom: 13px;
|
margin-bottom: 13px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
.panel-heading h3 {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 110%;
|
font-size: 110%;
|
||||||
margin-bottom: 13px;
|
margin-bottom: 13px;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
h4:first-child {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.admin_panel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.table {
|
table.table {
|
||||||
margin: 1.5em 0;
|
margin: 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ol li {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap-theme.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--[if lt IE 7]>
|
|
||||||
<p class="chromeframe">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> or <a href="http://www.google.com/chromeframe/?redirect=true">activate Google Chrome Frame</a> to improve your experience.</p>
|
<!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->
|
||||||
<![endif]-->
|
<!--[if gt IE 7]><!-->
|
||||||
<div class="navbar navbar-inverse navbar-fixed-top">
|
|
||||||
|
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="navbar-header">
|
<div class="navbar-header">
|
||||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
|
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
|
||||||
|
<span class="sr-only">Toggle navigation</span>
|
||||||
<span class="icon-bar"></span>
|
<span class="icon-bar"></span>
|
||||||
<span class="icon-bar"></span>
|
<span class="icon-bar"></span>
|
||||||
<span class="icon-bar"></span>
|
<span class="icon-bar"></span>
|
||||||
@@ -81,26 +91,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
<ul class="nav navbar-nav">
|
<ul class="nav navbar-nav">
|
||||||
<li class="dropdown active">
|
<li class="dropdown">
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
||||||
|
<li><a href="#ssl" onclick="return show_panel(this);">SSL Certificates</a></li>
|
||||||
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
||||||
<li class="divider"></li>
|
<li class="divider"></li>
|
||||||
<li class="dropdown-header">Super Advanced Options</li>
|
<li class="dropdown-header">Advanced Options</li>
|
||||||
<li><a href="#system_dns" onclick="return show_panel(this);">DNS (Custom/External)</a></li>
|
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
||||||
|
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown active">
|
<li class="dropdown">
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail/Sync <b class="caret"></b></a>
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href="#mail-guide" onclick="return show_panel(this);">Mail Instructions</a></li>
|
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
|
||||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
|
||||||
<li class="divider"></li>
|
|
||||||
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
||||||
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
||||||
|
<li><a href="#web" onclick="return show_panel(this);">Web</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="nav navbar-nav navbar-right">
|
<ul class="nav navbar-nav navbar-right">
|
||||||
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
||||||
@@ -109,39 +121,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container">
|
||||||
<div id="panel_system_status" class="container panel">
|
<div id="panel_system_status" class="admin_panel">
|
||||||
{% include "system-status.html" %}
|
{% include "system-status.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_system_backup" class="container panel">
|
<div id="panel_system_backup" class="admin_panel">
|
||||||
{% include "system-backup.html" %}
|
{% include "system-backup.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_system_dns" class="container panel">
|
<div id="panel_external_dns" class="admin_panel">
|
||||||
{% include "system-dns.html" %}
|
{% include "external-dns.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_login" class="panel">
|
<div id="panel_custom_dns" class="admin_panel">
|
||||||
|
{% include "custom-dns.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel_login" class="admin_panel">
|
||||||
{% include "login.html" %}
|
{% include "login.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_mail-guide" class="container panel">
|
<div id="panel_mail-guide" class="admin_panel">
|
||||||
{% include "mail-guide.html" %}
|
{% include "mail-guide.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_users" class="container panel">
|
<div id="panel_users" class="admin_panel">
|
||||||
{% include "users.html" %}
|
{% include "users.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_aliases" class="container panel">
|
<div id="panel_aliases" class="admin_panel">
|
||||||
{% include "aliases.html" %}
|
{% include "aliases.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_sync_guide" class="container panel">
|
<div id="panel_sync_guide" class="admin_panel">
|
||||||
{% include "sync-guide.html" %}
|
{% include "sync-guide.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="panel_web" class="admin_panel">
|
||||||
|
{% include "web.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel_ssl" class="admin_panel">
|
||||||
|
{% include "ssl.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@@ -149,9 +173,9 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div> <!-- /container -->
|
</div> <!-- /container -->
|
||||||
|
|
||||||
<div id="ajax_loading_indicator" style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; text-align: center; background-color: rgba(255,255,255,.75)">
|
<div id="ajax_loading_indicator" style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 100000; text-align: center; background-color: rgba(255,255,255,.75)">
|
||||||
<div style="margin: 20% auto">
|
<div style="margin: 20% auto">
|
||||||
<div><span class="glyphicon glyphicon-time"></span></div>
|
<div><span class="fa fa-spinner fa-pulse"></span></div>
|
||||||
<div>Loading...</div>
|
<div>Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,8 +198,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
|
||||||
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var global_modal_state = null;
|
var global_modal_state = null;
|
||||||
@@ -207,7 +231,7 @@ $(function() {
|
|||||||
function show_modal_error(title, message, callback) {
|
function show_modal_error(title, message, callback) {
|
||||||
$('#global_modal h4').text(title);
|
$('#global_modal h4').text(title);
|
||||||
$('#global_modal .modal-body').html("<p/>");
|
$('#global_modal .modal-body').html("<p/>");
|
||||||
if (typeof question == String) {
|
if (typeof question == 'string') {
|
||||||
$('#global_modal p').text(message);
|
$('#global_modal p').text(message);
|
||||||
$('#global_modal .modal-dialog').addClass("modal-sm");
|
$('#global_modal .modal-dialog').addClass("modal-sm");
|
||||||
} else {
|
} else {
|
||||||
@@ -219,11 +243,12 @@ function show_modal_error(title, message, callback) {
|
|||||||
global_modal_funcs = [callback, callback];
|
global_modal_funcs = [callback, callback];
|
||||||
global_modal_state = null;
|
global_modal_state = null;
|
||||||
$('#global_modal').modal({});
|
$('#global_modal').modal({});
|
||||||
|
return false; // handy when called from onclick
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
|
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
|
||||||
$('#global_modal h4').text(title);
|
$('#global_modal h4').text(title);
|
||||||
if (typeof question == String) {
|
if (typeof question == 'string') {
|
||||||
$('#global_modal .modal-dialog').addClass("modal-sm");
|
$('#global_modal .modal-dialog').addClass("modal-sm");
|
||||||
$('#global_modal .modal-body').html("<p/>");
|
$('#global_modal .modal-body').html("<p/>");
|
||||||
$('#global_modal p').text(question);
|
$('#global_modal p').text(question);
|
||||||
@@ -231,19 +256,26 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
|
|||||||
$('#global_modal .modal-dialog').removeClass("modal-sm");
|
$('#global_modal .modal-dialog').removeClass("modal-sm");
|
||||||
$('#global_modal .modal-body').html("").append(question);
|
$('#global_modal .modal-body').html("").append(question);
|
||||||
}
|
}
|
||||||
|
if (typeof verb == 'string') {
|
||||||
$('#global_modal .btn-default').show().text("Cancel");
|
$('#global_modal .btn-default').show().text("Cancel");
|
||||||
$('#global_modal .btn-danger').show().text(verb);
|
$('#global_modal .btn-danger').show().text(verb);
|
||||||
|
} else {
|
||||||
|
$('#global_modal .btn-default').show().text(verb[1]);
|
||||||
|
$('#global_modal .btn-danger').show().text(verb[0]);
|
||||||
|
}
|
||||||
global_modal_funcs = [yes_callback, cancel_callback];
|
global_modal_funcs = [yes_callback, cancel_callback];
|
||||||
global_modal_state = null;
|
global_modal_state = null;
|
||||||
$('#global_modal').modal({});
|
$('#global_modal').modal({});
|
||||||
|
return false; // handy when called from onclick
|
||||||
}
|
}
|
||||||
|
|
||||||
var is_ajax_loading = false;
|
var ajax_num_executing_requests = 0;
|
||||||
function ajax(options) {
|
function ajax(options) {
|
||||||
setTimeout("if (is_ajax_loading) $('#ajax_loading_indicator').fadeIn()", 100);
|
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||||
function hide_loading_indicator() {
|
function hide_loading_indicator() {
|
||||||
is_ajax_loading = false;
|
ajax_num_executing_requests--;
|
||||||
$('#ajax_loading_indicator').hide();
|
if (ajax_num_executing_requests == 0)
|
||||||
|
$('#ajax_loading_indicator').stop(true).hide(); // stop() prevents an ongoing fade from causing the thing to be shown again after this call
|
||||||
}
|
}
|
||||||
var old_success = options.success;
|
var old_success = options.success;
|
||||||
var old_error = options.error;
|
var old_error = options.error;
|
||||||
@@ -259,10 +291,11 @@ function ajax(options) {
|
|||||||
if (!old_error)
|
if (!old_error)
|
||||||
show_modal_error("Error", "Something went wrong, sorry.")
|
show_modal_error("Error", "Something went wrong, sorry.")
|
||||||
else
|
else
|
||||||
old_error(jqxhr.responseText);
|
old_error(jqxhr.responseText, jqxhr);
|
||||||
};
|
};
|
||||||
is_ajax_loading = true;
|
ajax_num_executing_requests++;
|
||||||
$.ajax(options);
|
$.ajax(options);
|
||||||
|
return false; // handy when called from onclick
|
||||||
}
|
}
|
||||||
|
|
||||||
var api_credentials = ["", ""];
|
var api_credentials = ["", ""];
|
||||||
@@ -294,10 +327,21 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function default_error(text, xhr) {
|
||||||
|
if (xhr.status != 403) // else handled below
|
||||||
|
show_modal_error("Error", "Something went wrong, sorry.")
|
||||||
|
}
|
||||||
|
|
||||||
ajax({
|
ajax({
|
||||||
url: "/admin" + url,
|
url: "/admin" + url,
|
||||||
method: method,
|
method: method,
|
||||||
|
cache: false,
|
||||||
data: data,
|
data: data,
|
||||||
|
|
||||||
|
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
||||||
|
processData: typeof data != "string",
|
||||||
|
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
||||||
|
|
||||||
beforeSend: function(xhr) {
|
beforeSend: function(xhr) {
|
||||||
// We don't store user credentials in a cookie to avoid the hassle of CSRF
|
// We don't store user credentials in a cookie to avoid the hassle of CSRF
|
||||||
// attacks. The Authorization header only gets set in our AJAX calls triggered
|
// attacks. The Authorization header only gets set in our AJAX calls triggered
|
||||||
@@ -307,7 +351,7 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
|
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
|
||||||
},
|
},
|
||||||
success: callback,
|
success: callback,
|
||||||
error: callback_error,
|
error: callback_error || default_error,
|
||||||
statusCode: {
|
statusCode: {
|
||||||
403: function(xhr) {
|
403: function(xhr) {
|
||||||
// Credentials are no longer valid. Try to login again.
|
// Credentials are no longer valid. Try to login again.
|
||||||
@@ -326,7 +370,7 @@ function show_panel(panelid) {
|
|||||||
// we might be passed an HTMLElement <a>.
|
// we might be passed an HTMLElement <a>.
|
||||||
panelid = panelid.getAttribute('href').substring(1);
|
panelid = panelid.getAttribute('href').substring(1);
|
||||||
|
|
||||||
$('.panel').hide();
|
$('.admin_panel').hide();
|
||||||
$('#panel_' + panelid).show();
|
$('#panel_' + panelid).show();
|
||||||
if (typeof localStorage != 'undefined')
|
if (typeof localStorage != 'undefined')
|
||||||
localStorage.setItem("miab-cp-lastpanel", panelid);
|
localStorage.setItem("miab-cp-lastpanel", panelid);
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
||||||
|
|
||||||
{% if no_admins_exist %}
|
{% if no_users_exist or no_admins_exist %}
|
||||||
<div class="container">
|
<div class="row">
|
||||||
<div class="col-md-offset-2 col-md-8">
|
<div class="col-md-offset-2 col-md-8">
|
||||||
|
{% if no_users_exist %}
|
||||||
|
<p class="text-danger">There are no users on this system! To make an administrative user,
|
||||||
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
|
<pre>cd mailinabox
|
||||||
|
sudo tools/mail.py user add me@{{hostname}}
|
||||||
|
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||||
|
{% else %}
|
||||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
<pre>cd mailinabox
|
<pre>cd mailinabox
|
||||||
sudo tools/mail.py user make-admin your@emailaddress.com</pre>
|
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||||
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
|
||||||
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-4 col-lg-4">
|
|
||||||
<center>
|
|
||||||
<p style="margin: 2em">Log in here for your Mail-in-a-Box control panel.</p>
|
|
||||||
</center>
|
|
||||||
|
|
||||||
|
<div style="margin: 0 auto; max-width: 32em;">
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inputEmail3" class="col-sm-2 control-label">Email</label>
|
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-9">
|
||||||
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
|
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inputPassword3" class="col-sm-2 control-label">Password</label>
|
<label for="inputPassword3" class="col-sm-3 control-label">Password</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-9">
|
||||||
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
<div class="col-sm-offset-3 col-sm-9">
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
<input name='remember' type="checkbox" id="loginRemember"> Remember me
|
<input name='remember' type="checkbox" id="loginRemember"> Remember me
|
||||||
@@ -41,12 +46,11 @@ sudo tools/mail.py user make-admin your@emailaddress.com</pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
<div class="col-sm-offset-3 col-sm-9">
|
||||||
<button type="submit" class="btn btn-default">Sign in</button>
|
<button type="submit" class="btn btn-default">Sign in</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -71,18 +75,25 @@ function do_login() {
|
|||||||
function(response){
|
function(response){
|
||||||
// This API call always succeeds. It returns a JSON object indicating
|
// This API call always succeeds. It returns a JSON object indicating
|
||||||
// whether the request was authenticated or not.
|
// whether the request was authenticated or not.
|
||||||
if (response.status != "authorized") {
|
if (response.status != "ok") {
|
||||||
// Show why the login failed.
|
// Show why the login failed.
|
||||||
show_modal_error("Login Failed", response.reason)
|
show_modal_error("Login Failed", response.reason)
|
||||||
|
|
||||||
// Reset any saved credentials.
|
// Reset any saved credentials.
|
||||||
do_logout();
|
do_logout();
|
||||||
|
|
||||||
|
} else if (!("api_key" in response)) {
|
||||||
|
// Login succeeded but user might not be authorized!
|
||||||
|
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||||
|
|
||||||
|
// Reset any saved credentials.
|
||||||
|
do_logout();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Login succeeded.
|
// Login succeeded.
|
||||||
|
|
||||||
// Save the new credentials.
|
// Save the new credentials.
|
||||||
api_credentials = [response.api_key, ""];
|
api_credentials = [response.email, response.api_key];
|
||||||
|
|
||||||
// Try to wipe the username/password information.
|
// Try to wipe the username/password information.
|
||||||
$('#loginEmail').val('');
|
$('#loginEmail').val('');
|
||||||
@@ -99,8 +110,10 @@ function do_login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the next panel the user wants to go to.
|
// Open the next panel the user wants to go to. Do this after the XHR response
|
||||||
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel)
|
// is over so that we don't start a new XHR request while this one is finishing,
|
||||||
|
// which confuses the loading indicator.
|
||||||
|
setTimeout(function() { show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel) }, 300);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,46 @@
|
|||||||
<style>#panel_mail-guide table.table { width: auto; margin-left: 1.5em; }</style>
|
<style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style>
|
||||||
|
|
||||||
<div class="container">
|
<div>
|
||||||
<h2>Checking and Sending Mail</h2>
|
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
|
||||||
|
|
||||||
<p>You can use either webmail, IMAP/SMTP, or Exchange/ActiveSync to access your mail.</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-sm-7">
|
||||||
<h4>How to log in</h4>
|
<h3>Webmail</h3>
|
||||||
|
|
||||||
<p>Your username and password are the same no matter how you check your mail:</p>
|
<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>
|
||||||
|
<p style="margin-left: 2em"><strong><a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
|
||||||
|
<p>Your username is your whole email address.</p>
|
||||||
|
|
||||||
<table class="table" style="max-width: 30em">
|
|
||||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
|
||||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<h3>Mobile/desktop apps</h3>
|
||||||
<div class="col-sm-6">
|
|
||||||
<h4>Webmail</h4>
|
|
||||||
|
|
||||||
<p>Your webmail site is <a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a>.</p>
|
<h4>Automatic configuration</h4>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<p>iOS and OS X only: Open <a style="font-weight: bold" href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p>
|
||||||
<div class="col-sm-6">
|
|
||||||
<h4>IMAP/SMTP settings</h4>
|
|
||||||
|
|
||||||
<p>This method is preferred on Android devices and is not available on iOS devices.</p>
|
<h4>Manual configuration</h4>
|
||||||
|
|
||||||
|
<p>Use the following settings when you set up your email on your phone, desktop, or other device:</p>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th style="padding-bottom: 1em; border-bottom: none">Server:</th> <td colspan="2">{{hostname}}</td></tr>
|
<tr><th>Option</th> <th>Value</th></tr>
|
||||||
<tr><th>Protocol</th> <th>Port</th> <th>Options</th></tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tr><th>IMAP</th> <td>993</td> <td>SSL</td></tr>
|
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
|
||||||
<tr><th>SMTP</th> <td>587</td> <td>STARTTLS</td></tr>
|
<tr><th>Mail server</th> <td>{{hostname}}</td>
|
||||||
|
<tr><th>IMAP Port</th> <td>993</td></tr>
|
||||||
|
<tr><th>IMAP Security</th> <td>SSL</td></tr>
|
||||||
|
<tr><th>SMTP Port</th> <td>587</td></tr>
|
||||||
|
<tr><th>SMTP Security</td> <td>STARTTLS <small>(“always” or “required”, if prompted)</small></td></tr>
|
||||||
|
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||||
|
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a>.</p>
|
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<h4>Exchange/ActiveSync settings</h4>
|
<h4>Exchange/ActiveSync settings</h4>
|
||||||
|
|
||||||
<p>On iOS devices and devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, set up your mail as an Exchange or ActiveSync server:</p>
|
<p>On iOS devices, devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
||||||
@@ -58,5 +49,23 @@
|
|||||||
|
|
||||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
|
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-5">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3>Other information about mail on your box</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<h4>Greylisting</h4>
|
||||||
|
<p>Your box using a technique called greylisting to cut down on spam. Greylisting works by delaying mail from people you haven’t received mail from before for up to about 10 minutes. The vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please give it up to 10-15 minutes to arrive.</p>
|
||||||
|
|
||||||
|
<h4>+tag addresses</h4>
|
||||||
|
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you can also accept mail at <code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to create aliases or to segment incoming mail for your own filtering rules.</p>
|
||||||
|
|
||||||
|
<h4>Use only this box to send as you</h4>
|
||||||
|
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
120
management/templates/ssl.html
Normal file
120
management/templates/ssl.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>SSL Certificates</h2>
|
||||||
|
|
||||||
|
<h3>Certificate Status</h3>
|
||||||
|
|
||||||
|
|
||||||
|
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Certificate Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>Advanced:<br>Install a multi-domain or wildcard certificate for the <code>{{hostname}}</code> domain to have it automatically applied to any domains it is valid for.</p>
|
||||||
|
|
||||||
|
<h3 id="ssl_install_header">Install SSL Certificate</h3>
|
||||||
|
|
||||||
|
<p>There are many places where you can get a free or cheap SSL certificate. We recommend <a href="https://www.namecheap.com/security/ssl-certificates/domain-validation.aspx">Namecheap’s $9 certificate</a> or <a href="https://www.startssl.com/">StartSSL’s free express lane</a>.</p>
|
||||||
|
|
||||||
|
<p>Which domain are you getting an SSL certificate for?</p>
|
||||||
|
|
||||||
|
<p><select id="ssldomain" onchange="show_csr()" class="form-control" style="width: auto"></select></p>
|
||||||
|
|
||||||
|
<div id="csr_info" style="display: none">
|
||||||
|
<p>You will need to provide the SSL certificate provider this Certificate Signing Request (CSR):</p>
|
||||||
|
|
||||||
|
<pre id="ssl_csr"></pre>
|
||||||
|
|
||||||
|
<p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>
|
||||||
|
|
||||||
|
<p>The SSL certificate provider will then provide you with an SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p>
|
||||||
|
|
||||||
|
<p style="margin-bottom: .5em">SSL certificate:</p>
|
||||||
|
<p><textarea id="ssl_paste_cert" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||||
|
|
||||||
|
<p style="margin-bottom: .5em">SSL intermediate chain (if provided):</p>
|
||||||
|
<p><textarea id="ssl_paste_chain" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||||
|
|
||||||
|
<p>After you paste in the information, click the install button.</p>
|
||||||
|
|
||||||
|
<button class="btn-primary" onclick="install_cert()">Install</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function show_ssl() {
|
||||||
|
api(
|
||||||
|
"/web/domains",
|
||||||
|
"GET",
|
||||||
|
{
|
||||||
|
},
|
||||||
|
function(domains) {
|
||||||
|
var tb = $('#ssl_domains tbody');
|
||||||
|
tb.text('');
|
||||||
|
$('#ssldomain').html('<option value="">(select)</option>');
|
||||||
|
|
||||||
|
for (var i = 0; i < domains.length; i++) {
|
||||||
|
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
|
||||||
|
tb.append(row);
|
||||||
|
row.attr('data-domain', domains[i].domain);
|
||||||
|
row.find('.domain a').text(domains[i].domain);
|
||||||
|
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||||
|
row.addClass("text-" + domains[i].ssl_certificate[0]);
|
||||||
|
row.find('.status').text(domains[i].ssl_certificate[1]);
|
||||||
|
if (domains[i].ssl_certificate[0] == "success") {
|
||||||
|
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
||||||
|
} else {
|
||||||
|
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#ssldomain').append($('<option>').text(domains[i].domain));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssl_install(elem) {
|
||||||
|
var domain = $(elem).parents('tr').attr('data-domain');
|
||||||
|
$('#ssldomain').val(domain);
|
||||||
|
$('#csr_info').slideDown();
|
||||||
|
$('#ssl_csr').text('Loading...');
|
||||||
|
show_csr();
|
||||||
|
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_csr() {
|
||||||
|
api(
|
||||||
|
"/ssl/csr/" + $('#ssldomain').val(),
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
},
|
||||||
|
function(data) {
|
||||||
|
$('#ssl_csr').text(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function install_cert() {
|
||||||
|
api(
|
||||||
|
"/ssl/install",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
domain: $('#ssldomain').val(),
|
||||||
|
cert: $('#ssl_paste_cert').val(),
|
||||||
|
chain: $('#ssl_paste_chain').val()
|
||||||
|
},
|
||||||
|
function(status) {
|
||||||
|
if (status == "") {
|
||||||
|
show_modal_error("SSL Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function() { show_ssl(); $('#csr_info').slideUp(); });
|
||||||
|
} else {
|
||||||
|
show_modal_error("SSL Certificate Installation", status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="container">
|
<div>
|
||||||
<h2>Contacts & Calendar Synchronization</h2>
|
<h2>Contacts & Calendar Synchronization</h2>
|
||||||
|
|
||||||
<p>This box can hold your contacts and calendar, just like it holds your email.</p>
|
<p>This box can hold your contacts and calendar, just like it holds your email.</p>
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
|
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
|
||||||
<tr><td>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
<tr><th>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
||||||
<tr><td>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
|
<tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
|
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
|
||||||
@@ -30,8 +30,9 @@
|
|||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
||||||
<tr><td>Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free beta</a> (free)</td></tr>
|
<tr><td>Contacts and Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVdroid</a> ($3.69; free <a href="https://f-droid.org/repository/browse/?fdfilter=dav&fdid=at.bitfire.davdroid">here</a>)</td></tr>
|
||||||
<tr><td>Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.89)</td></tr>
|
<tr><td>Only Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free beta</a> (free)</td></tr>
|
||||||
|
<tr><td>Only Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.89)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>Use the following settings:</p>
|
<p>Use the following settings:</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<h3>Current Backups</h3>
|
<h3>Current Backups</h3>
|
||||||
|
|
||||||
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is <span id="backup-total-size"></span>.</p>
|
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
|
||||||
|
|
||||||
<table id="backup-status" class="table" style="width: auto">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -28,8 +28,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p><small>The size column in the table indicates the size of the encrpyted backup, but the total size on disk shown above includes storage for unencrpyted intermediate files.</small></p>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function nice_size(bytes) {
|
function nice_size(bytes) {
|
||||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
@@ -39,7 +37,12 @@ function nice_size(bytes) {
|
|||||||
bytes /= 1024;
|
bytes /= 1024;
|
||||||
powers.shift();
|
powers.shift();
|
||||||
}
|
}
|
||||||
return (Math.round(bytes*10)/10) + " " + powers[0];
|
// round to have three significant figures but at most one decimal place
|
||||||
|
if (bytes >= 100)
|
||||||
|
bytes = Math.round(bytes)
|
||||||
|
else
|
||||||
|
bytes = Math.round(bytes*10)/10;
|
||||||
|
return bytes + " " + powers[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_system_backup() {
|
function show_system_backup() {
|
||||||
@@ -49,11 +52,17 @@ function show_system_backup() {
|
|||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(r) {
|
function(r) {
|
||||||
$('#backup-location').text(r.encdirectory);
|
$('#backup-location').text(r.directory);
|
||||||
$('#backup-encpassword-file').text(r.encpwfile);
|
$('#backup-encpassword-file').text(r.encpwfile);
|
||||||
|
|
||||||
$('#backup-status tbody').html("");
|
$('#backup-status tbody').html("");
|
||||||
var total_disk_size = 0;
|
var total_disk_size = 0;
|
||||||
|
|
||||||
|
if (r.backups.length == 0) {
|
||||||
|
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
||||||
|
$('#backup-status tbody').append(tr);
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < r.backups.length; i++) {
|
for (var i = 0; i < r.backups.length; i++) {
|
||||||
var b = r.backups[i];
|
var b = r.backups[i];
|
||||||
var tr = $('<tr/>');
|
var tr = $('<tr/>');
|
||||||
@@ -61,15 +70,14 @@ function show_system_backup() {
|
|||||||
tr.append( $('<td/>').text(b.date_str + " " + r.tz) );
|
tr.append( $('<td/>').text(b.date_str + " " + r.tz) );
|
||||||
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
||||||
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
||||||
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.encsize)) );
|
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.size)) );
|
||||||
if (b.deleted_in)
|
if (b.deleted_in)
|
||||||
tr.append( $('<td/>').text(b.deleted_in) );
|
tr.append( $('<td/>').text(b.deleted_in) );
|
||||||
else
|
else
|
||||||
tr.append( $('<td class="text-muted">n/a</td>') );
|
tr.append( $('<td class="text-muted">unknown</td>') );
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
|
|
||||||
total_disk_size += b.size;
|
total_disk_size += b.size;
|
||||||
total_disk_size += b.encsize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
<style>
|
|
||||||
#external_dns_settings .heading td {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 120%;
|
|
||||||
padding-top: 1.5em;
|
|
||||||
}
|
|
||||||
#external_dns_settings .heading.first td {
|
|
||||||
border-top: none;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
#external_dns_settings .values td {
|
|
||||||
border: 0;
|
|
||||||
padding-top: .75em;
|
|
||||||
padding-bottom: 0;
|
|
||||||
max-width: 50vw;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
#external_dns_settings .explanation td {
|
|
||||||
border: 0;
|
|
||||||
padding-top: .5em;
|
|
||||||
padding-bottom: .75em;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 95%;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<h2>DNS (Advanced)</h2>
|
|
||||||
|
|
||||||
<p class="text-danger">Custom and external DNS are for advanced configurations.</p>
|
|
||||||
|
|
||||||
<h3>Custom DNS</h3>
|
|
||||||
|
|
||||||
<p>It is possible to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service. To do so, you will need to call your box’s DNS API.</p>
|
|
||||||
|
|
||||||
<h4>The HTTP POST request</h4>
|
|
||||||
|
|
||||||
<p>Send a POST request like this:</p>
|
|
||||||
|
|
||||||
<pre>curl -d "" --user {email}:{password} https://{{hostname}}/admin/dns/set/<b>qname</b>[/<b>rtype</b>[/<b>value</b>]]</pre>
|
|
||||||
|
|
||||||
<table class="table">
|
|
||||||
<thead><th>Parameter</th> <th>Value</th></thead>
|
|
||||||
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
|
||||||
<tr><td>password</td> <td>That user’s password.</td></tr>
|
|
||||||
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set.</td></tr>
|
|
||||||
<tr><td>rtype</td> <td>The resource type. <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), or <code>CNAME</code> (an alias, which is a fully qualified domain name).</td></tr>
|
|
||||||
<tr><td>value</td> <td>The new record’s value. If omitted, the IPv4 address of the remote host is used. This is handy for dynamic DNS! To delete a record, use “__delete__”.</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p style="margin-top: 1em">Note that <code>-d ""</code> is merely to ensure curl sends a POST request. You do not need to put anything inside the quotes. You can also pass the value using typical form encoding in the POST body.</p>
|
|
||||||
|
|
||||||
<h4>Examples:</h4>
|
|
||||||
|
|
||||||
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
|
|
||||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/laptop.mydomain.com
|
|
||||||
|
|
||||||
# sets an alias
|
|
||||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/cname/bar.mydomain.com
|
|
||||||
|
|
||||||
# clears the alias
|
|
||||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/bar.mydomain.com/cname/__delete__
|
|
||||||
|
|
||||||
# sets a TXT record using the alternate value syntax
|
|
||||||
curl -d "value=something%20here" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/txt
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
|
|
||||||
<h3>External DNS</h3>
|
|
||||||
|
|
||||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere.</p>
|
|
||||||
|
|
||||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
|
||||||
|
|
||||||
<p>Enter the following DNS entries at your DNS provider:</p>
|
|
||||||
|
|
||||||
<table id="external_dns_settings" class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>QName</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function show_system_dns() {
|
|
||||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
|
||||||
api(
|
|
||||||
"/dns/dump",
|
|
||||||
"GET",
|
|
||||||
{ },
|
|
||||||
function(zones) {
|
|
||||||
$('#external_dns_settings tbody').html("");
|
|
||||||
for (var j = 0; j < zones.length; j++) {
|
|
||||||
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
|
|
||||||
h.find("td").text(zones[j][0]);
|
|
||||||
$('#external_dns_settings tbody').append(h);
|
|
||||||
|
|
||||||
var r = zones[j][1];
|
|
||||||
for (var i = 0; i < r.length; i++) {
|
|
||||||
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
|
|
||||||
n.find('.qname').text(r[i].qname);
|
|
||||||
n.find('.rtype').text(r[i].rtype);
|
|
||||||
n.find('.value').text(r[i].value);
|
|
||||||
$('#external_dns_settings tbody').append(n);
|
|
||||||
|
|
||||||
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
|
|
||||||
n.find('td').text(r[i].explanation);
|
|
||||||
$('#external_dns_settings tbody').append(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -10,11 +10,14 @@
|
|||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
#system-checks .error td {
|
#system-checks .status-error td {
|
||||||
color: #733;
|
color: #733;
|
||||||
}
|
}
|
||||||
#system-checks .ok td {
|
#system-checks .status-warning td {
|
||||||
color: #030;
|
color: #770;
|
||||||
|
}
|
||||||
|
#system-checks .status-ok td {
|
||||||
|
color: #040;
|
||||||
}
|
}
|
||||||
#system-checks div.extra {
|
#system-checks div.extra {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -52,9 +55,13 @@ function show_system_status() {
|
|||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
|
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
|
||||||
if (i == 0) n.addClass('first')
|
if (i == 0) n.addClass('first')
|
||||||
|
if (r[i].type == "heading")
|
||||||
n.addClass(r[i].type)
|
n.addClass(r[i].type)
|
||||||
|
else
|
||||||
|
n.addClass("status-" + r[i].type)
|
||||||
if (r[i].type == "ok") n.find('td.status').text("✓")
|
if (r[i].type == "ok") n.find('td.status').text("✓")
|
||||||
if (r[i].type == "error") n.find('td.status').text("✖")
|
if (r[i].type == "error") n.find('td.status').text("✖")
|
||||||
|
if (r[i].type == "warning") n.find('td.status').text("?")
|
||||||
n.find('td.message p').text(r[i].text)
|
n.find('td.message p').text(r[i].text)
|
||||||
$('#system-checks tbody').append(n);
|
$('#system-checks tbody').append(n);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#user_table tr.account_inactive td .address { color: #888; text-decoration: line-through; }
|
#user_table h4 { margin: 1em 0 0 0; }
|
||||||
#user_table .aliases { margin-top: .33em; font-size: 95%; }
|
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
|
||||||
#user_table .aliases div:before { content: "⇖ "; }
|
|
||||||
#user_table .aliases div { }
|
|
||||||
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
||||||
#user_table .account_inactive .if_active { display: none; }
|
#user_table .account_inactive .if_active { display: none; }
|
||||||
#user_table .account_active .if_inactive { display: none; }
|
#user_table .account_active .if_inactive { display: none; }
|
||||||
|
#user_table .account_active.if_inactive { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h3>Add a mail user</h3>
|
<h3>Add a mail user</h3>
|
||||||
|
|
||||||
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p>
|
<p>Add an email address to this system. This will create a new login username/password.</p>
|
||||||
|
|
||||||
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
|
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -31,13 +30,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add User</button>
|
<button type="submit" class="btn btn-primary">Add User</button>
|
||||||
</form>
|
</form>
|
||||||
<p style="margin-top: .5em"><small>
|
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||||
Passwords must be at least four characters and may not contain spaces.
|
<li>Passwords must be at least four characters and may not contain spaces.</li>
|
||||||
Administrators get access to this control panel.
|
<li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
||||||
</small></p>
|
<li>Administrators get access to this control panel.</li>
|
||||||
|
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="javascript:show_panel('aliases')">aliases</a> can.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>Existing mail users</h3>
|
<h3>Existing mail users</h3>
|
||||||
<table id="user_table" class="table" style="width: auto">
|
<table id="user_table" class="table" style="width: auto">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="50%">Email Address</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
<th>Mailbox Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -45,10 +53,9 @@
|
|||||||
<div style="display: none">
|
<div style="display: none">
|
||||||
<table>
|
<table>
|
||||||
<tr id="user-template">
|
<tr id="user-template">
|
||||||
<td class='email'>
|
<td class='address'>
|
||||||
<div class='address'> </div>
|
</td>
|
||||||
|
<td class='actions'>
|
||||||
<div class='actions'>
|
|
||||||
<span class='privs'>
|
<span class='privs'>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -65,11 +72,13 @@
|
|||||||
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
||||||
archive account
|
archive account
|
||||||
</a>
|
</a>
|
||||||
|
</td>
|
||||||
<div class='if_inactive' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address.</div>
|
<td class='mailboxsize'>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
<div class='aliases' style='display: none'> </div>
|
<tr id="user-extra-template" class="if_inactive">
|
||||||
|
<td colspan="3" style="border: 0; padding-top: 0">
|
||||||
|
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -86,24 +95,38 @@ function show_users() {
|
|||||||
function(r) {
|
function(r) {
|
||||||
$('#user_table tbody').html("");
|
$('#user_table tbody').html("");
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
|
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
|
||||||
|
hdr.find('h4').text(r[i].domain);
|
||||||
|
$('#user_table tbody').append(hdr);
|
||||||
|
|
||||||
|
for (var k = 0; k < r[i].users.length; k++) {
|
||||||
|
var user = r[i].users[k];
|
||||||
|
|
||||||
var n = $("#user-template").clone();
|
var n = $("#user-template").clone();
|
||||||
|
var n2 = $("#user-extra-template").clone();
|
||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
|
n2.attr('id', '');
|
||||||
n.addClass("account_" + r[i].status);
|
|
||||||
n.attr('data-email', r[i].email);
|
|
||||||
n.find('td.email .address').text(r[i].email)
|
|
||||||
$('#user_table tbody').append(n);
|
$('#user_table tbody').append(n);
|
||||||
|
$('#user_table tbody').append(n2);
|
||||||
|
|
||||||
if (r[i].status == 'inactive') continue;
|
n.addClass("account_" + user.status);
|
||||||
|
n2.addClass("account_" + user.status);
|
||||||
|
|
||||||
|
n.attr('data-email', user.email);
|
||||||
|
n.find('.address').text(user.email)
|
||||||
|
n.find('.mailboxsize').text(nice_size(user.mailbox_size))
|
||||||
|
n2.find('.restore_info tt').text(user.mailbox);
|
||||||
|
|
||||||
|
if (user.status == 'inactive') continue;
|
||||||
|
|
||||||
var add_privs = ["admin"];
|
var add_privs = ["admin"];
|
||||||
|
|
||||||
for (var j = 0; j < r[i].privileges.length; j++) {
|
for (var j = 0; j < user.privileges.length; j++) {
|
||||||
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
|
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
|
||||||
p.find('span.name').text(r[i].privileges[j]);
|
p.find('span.name').text(user.privileges[j]);
|
||||||
n.find('.privs').append(p);
|
n.find('.privs').append(p);
|
||||||
if (add_privs.indexOf(r[i].privileges[j]) >= 0)
|
if (add_privs.indexOf(user.privileges[j]) >= 0)
|
||||||
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1);
|
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var j = 0; j < add_privs.length; j++) {
|
for (var j = 0; j < add_privs.length; j++) {
|
||||||
@@ -111,15 +134,6 @@ function show_users() {
|
|||||||
p.find('span.name').text(add_privs[j]);
|
p.find('span.name').text(add_privs[j]);
|
||||||
n.find('.add-privs').append(p);
|
n.find('.add-privs').append(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r[i].aliases && r[i].aliases.length > 0) {
|
|
||||||
n.find('.aliases').show();
|
|
||||||
for (var j = 0; j < r[i].aliases.length; j++) {
|
|
||||||
n.find('td.email .aliases').append($("<div/>").text(
|
|
||||||
r[i].aliases[j][0]
|
|
||||||
+ (r[i].aliases[j][1].length > 0 ? " ⇐ " + r[i].aliases[j][1].join(", ") : "")
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -174,6 +188,13 @@ function users_set_password(elem) {
|
|||||||
|
|
||||||
function users_remove(elem) {
|
function users_remove(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
|
|
||||||
|
// can't remove yourself
|
||||||
|
if (api_credentials != null && email == api_credentials[0]) {
|
||||||
|
show_modal_error("Archive User", "You cannot archive your own account.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Archive User",
|
"Archive User",
|
||||||
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
|
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
|
||||||
@@ -209,7 +230,7 @@ function mod_priv(elem, add_remove) {
|
|||||||
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Modify Privileges",
|
"Modify Privileges",
|
||||||
"Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?",
|
$("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
|
||||||
add_remove1,
|
add_remove1,
|
||||||
function() {
|
function() {
|
||||||
api(
|
api(
|
||||||
|
|||||||
89
management/templates/web.html
Normal file
89
management/templates/web.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Static Web Hosting</h2>
|
||||||
|
|
||||||
|
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at all domain names that you set up an email user or alias for.</p>
|
||||||
|
|
||||||
|
<h3>Uploading web files</h3>
|
||||||
|
|
||||||
|
<p>You can replace the default website with your own HTML pages and other static files. This control panel won’t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status" onclick="return show_panel(this);">Status Checks</a> page.</li>
|
||||||
|
|
||||||
|
<li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
|
||||||
|
|
||||||
|
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.</li>
|
||||||
|
|
||||||
|
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li>
|
||||||
|
|
||||||
|
<li>The websites set up on this machine are listed in the table below with where to put the files for each website.</li>
|
||||||
|
|
||||||
|
<table id="web_domains_existing" class="table" style="margin-bottom: 1em; width: auto;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Directory for Files</th>
|
||||||
|
<th/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p>
|
||||||
|
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function show_web() {
|
||||||
|
api(
|
||||||
|
"/web/domains",
|
||||||
|
"GET",
|
||||||
|
{
|
||||||
|
},
|
||||||
|
function(domains) {
|
||||||
|
var tb = $('#web_domains_existing tbody');
|
||||||
|
tb.text('');
|
||||||
|
for (var i = 0; i < domains.length; i++) {
|
||||||
|
if (!domains[i].static_enabled) continue;
|
||||||
|
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td> <td class='change-root hidden'><button class='btn btn-default btn-xs' onclick='show_change_web_root(this)'>Change</button></td></tr>");
|
||||||
|
tb.append(row);
|
||||||
|
row.attr('data-domain', domains[i].domain);
|
||||||
|
row.attr('data-custom-web-root', domains[i].custom_root);
|
||||||
|
row.find('.domain a').text('https://' + domains[i].domain);
|
||||||
|
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||||
|
row.find('.directory tt').text(domains[i].root);
|
||||||
|
if (domains[i].root != domains[i].custom_root)
|
||||||
|
row.find('.change-root').removeClass('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_web_update() {
|
||||||
|
api(
|
||||||
|
"/web/update",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
},
|
||||||
|
function(data) {
|
||||||
|
if (data == "")
|
||||||
|
data = "Nothing changed.";
|
||||||
|
else
|
||||||
|
data = $("<pre/>").text(data);
|
||||||
|
show_modal_error("Web Update", data, function() { show_web() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_change_web_root(elem) {
|
||||||
|
var domain = $(elem).parents('tr').attr('data-domain');
|
||||||
|
var root = $(elem).parents('tr').attr('data-custom-web-root');
|
||||||
|
show_modal_confirm(
|
||||||
|
'Change Root Directory for ' + domain,
|
||||||
|
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.'),
|
||||||
|
'Update',
|
||||||
|
function() { do_web_update(); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -23,10 +23,6 @@ def safe_domain_name(name):
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
return urllib.parse.quote(name, safe='')
|
return urllib.parse.quote(name, safe='')
|
||||||
|
|
||||||
def unsafe_domain_name(name_encoded):
|
|
||||||
import urllib.parse
|
|
||||||
return urllib.parse.unquote(name_encoded)
|
|
||||||
|
|
||||||
def sort_domains(domain_names, env):
|
def sort_domains(domain_names, env):
|
||||||
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
|
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
|
||||||
# must appear first so it becomes the nginx default server.
|
# must appear first so it becomes the nginx default server.
|
||||||
@@ -169,3 +165,38 @@ def create_syslog_handler():
|
|||||||
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
||||||
handler.setLevel(logging.WARNING)
|
handler.setLevel(logging.WARNING)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
def du(path):
|
||||||
|
# Computes the size of all files in the path, like the `du` command.
|
||||||
|
# Based on http://stackoverflow.com/a/17936789. Takes into account
|
||||||
|
# soft and hard links.
|
||||||
|
total_size = 0
|
||||||
|
seen = set()
|
||||||
|
for dirpath, dirnames, filenames in os.walk(path):
|
||||||
|
for f in filenames:
|
||||||
|
fp = os.path.join(dirpath, f)
|
||||||
|
try:
|
||||||
|
stat = os.lstat(fp)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if stat.st_ino in seen:
|
||||||
|
continue
|
||||||
|
seen.add(stat.st_ino)
|
||||||
|
total_size += stat.st_size
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def wait_for_service(port, public, env, timeout):
|
||||||
|
# Block until a service on a given port (bound privately or publicly)
|
||||||
|
# is taking connections, with a maximum timeout.
|
||||||
|
import socket, time
|
||||||
|
start = time.perf_counter()
|
||||||
|
while True:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout/3)
|
||||||
|
try:
|
||||||
|
s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
if time.perf_counter() > start+timeout:
|
||||||
|
return False
|
||||||
|
time.sleep(min(timeout/4, 1))
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
# domains for which a mail account has been set up.
|
# domains for which a mail account has been set up.
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
import os, os.path, re, rtyaml
|
import os, os.path, shutil, re, tempfile, rtyaml
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
from mailconfig import get_mail_domains
|
||||||
from dns_update import get_custom_dns_config
|
from dns_update import get_custom_dns_config, do_dns_update
|
||||||
from utils import shell, safe_domain_name, sort_domains
|
from utils import shell, safe_domain_name, sort_domains
|
||||||
|
|
||||||
def get_web_domains(env):
|
def get_web_domains(env):
|
||||||
@@ -17,19 +17,16 @@ def get_web_domains(env):
|
|||||||
domains.add(env['PRIMARY_HOSTNAME'])
|
domains.add(env['PRIMARY_HOSTNAME'])
|
||||||
|
|
||||||
# Also serve web for all mail domains so that we might at least
|
# Also serve web for all mail domains so that we might at least
|
||||||
# provide Webfinger and ActiveSync auto-discover of email settings
|
# provide auto-discover of email settings, and also a static website
|
||||||
# (though the latter isn't really working). These will require that
|
# if the user wants to make one. These will require an SSL cert.
|
||||||
# an SSL cert be installed.
|
|
||||||
domains |= get_mail_domains(env)
|
domains |= get_mail_domains(env)
|
||||||
|
|
||||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
# ...Unless the domain has an A/AAAA record that maps it to a different
|
||||||
# IP address than this box. Remove those domains from our list.
|
# IP address than this box. Remove those domains from our list.
|
||||||
dns = get_custom_dns_config(env)
|
dns = get_custom_dns_config(env)
|
||||||
for domain, value in dns.items():
|
for domain, rtype, value in dns:
|
||||||
if domain not in domains: continue
|
if domain not in domains: continue
|
||||||
if (isinstance(value, str) and (value != "local")) \
|
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value != "local"):
|
||||||
or (isinstance(value, dict) and ("A" in value) and (value["A"] != "local")) \
|
|
||||||
or (isinstance(value, dict) and ("AAAA" in value) and (value["AAAA"] != "local")):
|
|
||||||
domains.remove(domain)
|
domains.remove(domain)
|
||||||
|
|
||||||
# Sort the list. Put PRIMARY_HOSTNAME first so it becomes the
|
# Sort the list. Put PRIMARY_HOSTNAME first so it becomes the
|
||||||
@@ -38,7 +35,7 @@ def get_web_domains(env):
|
|||||||
|
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
def do_web_update(env):
|
def do_web_update(env, ok_status="web updated\n"):
|
||||||
# Build an nginx configuration file.
|
# Build an nginx configuration file.
|
||||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||||
|
|
||||||
@@ -65,7 +62,7 @@ def do_web_update(env):
|
|||||||
# enough and doesn't break any open connections.
|
# enough and doesn't break any open connections.
|
||||||
shell('check_call', ["/usr/sbin/service", "nginx", "reload"])
|
shell('check_call', ["/usr/sbin/service", "nginx", "reload"])
|
||||||
|
|
||||||
return "web updated\n"
|
return ok_status
|
||||||
|
|
||||||
def make_domain_config(domain, template, template_for_primaryhost, env):
|
def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||||
# How will we configure this domain.
|
# How will we configure this domain.
|
||||||
@@ -75,11 +72,11 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
|||||||
root = get_web_root(domain, env)
|
root = get_web_root(domain, env)
|
||||||
|
|
||||||
# What private key and SSL certificate will we use for this domain?
|
# What private key and SSL certificate will we use for this domain?
|
||||||
ssl_key, ssl_certificate, csr_path = get_domain_ssl_files(domain, env)
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
|
|
||||||
# For hostnames created after the initial setup, ensure we have an SSL certificate
|
# For hostnames created after the initial setup, ensure we have an SSL certificate
|
||||||
# available. Make a self-signed one now if one doesn't exist.
|
# available. Make a self-signed one now if one doesn't exist.
|
||||||
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env)
|
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env)
|
||||||
|
|
||||||
# Put pieces together.
|
# Put pieces together.
|
||||||
nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template)
|
nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template)
|
||||||
@@ -94,6 +91,19 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
|||||||
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
|
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
|
||||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
|
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
|
||||||
|
|
||||||
|
# Because the certificate may change, we should recognize this so we
|
||||||
|
# can trigger an nginx update.
|
||||||
|
def hashfile(filepath):
|
||||||
|
import hashlib
|
||||||
|
sha1 = hashlib.sha1()
|
||||||
|
f = open(filepath, 'rb')
|
||||||
|
try:
|
||||||
|
sha1.update(f.read())
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
return sha1.hexdigest()
|
||||||
|
nginx_conf += "# ssl files sha1: %s / %s\n" % (hashfile(ssl_key), hashfile(ssl_certificate))
|
||||||
|
|
||||||
# Add in any user customizations in YAML format.
|
# Add in any user customizations in YAML format.
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
@@ -102,6 +112,8 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
|||||||
yaml = yaml[domain]
|
yaml = yaml[domain]
|
||||||
for path, url in yaml.get("proxies", {}).items():
|
for path, url in yaml.get("proxies", {}).items():
|
||||||
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
||||||
|
for path, url in yaml.get("redirects", {}).items():
|
||||||
|
nginx_conf += "\trewrite %s %s permanent;\n" % (path, url)
|
||||||
|
|
||||||
# Add in any user customizations in the includes/ folder.
|
# Add in any user customizations in the includes/ folder.
|
||||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||||
@@ -113,14 +125,14 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
|||||||
|
|
||||||
return nginx_conf
|
return nginx_conf
|
||||||
|
|
||||||
def get_web_root(domain, env):
|
def get_web_root(domain, env, test_exists=True):
|
||||||
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
|
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
|
||||||
for test_domain in (domain, 'default'):
|
for test_domain in (domain, 'default'):
|
||||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
|
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
|
||||||
if os.path.exists(root): break
|
if os.path.exists(root) or not test_exists: break
|
||||||
return root
|
return root
|
||||||
|
|
||||||
def get_domain_ssl_files(domain, env):
|
def get_domain_ssl_files(domain, env, allow_shared_cert=True):
|
||||||
# What SSL private key will we use? Allow the user to override this, but
|
# What SSL private key will we use? Allow the user to override this, but
|
||||||
# in many cases using the same private key for all domains would be fine.
|
# in many cases using the same private key for all domains would be fine.
|
||||||
# Don't allow the user to override the key for PRIMARY_HOSTNAME because
|
# Don't allow the user to override the key for PRIMARY_HOSTNAME because
|
||||||
@@ -134,6 +146,7 @@ def get_domain_ssl_files(domain, env):
|
|||||||
|
|
||||||
# What SSL certificate will we use?
|
# What SSL certificate will we use?
|
||||||
ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
|
ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
|
||||||
|
ssl_via = None
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
# For PRIMARY_HOSTNAME, use the one we generated at set-up time.
|
# For PRIMARY_HOSTNAME, use the one we generated at set-up time.
|
||||||
ssl_certificate = ssl_certificate_primary
|
ssl_certificate = ssl_certificate_primary
|
||||||
@@ -144,21 +157,22 @@ def get_domain_ssl_files(domain, env):
|
|||||||
# But we can be smart and reuse the main SSL certificate if is has
|
# But we can be smart and reuse the main SSL certificate if is has
|
||||||
# a Subject Alternative Name matching this domain. Don't do this if
|
# a Subject Alternative Name matching this domain. Don't do this if
|
||||||
# the user has uploaded a different private key for this domain.
|
# the user has uploaded a different private key for this domain.
|
||||||
if not ssl_key_is_alt:
|
if not ssl_key_is_alt and allow_shared_cert:
|
||||||
from status_checks import check_certificate
|
from status_checks import check_certificate
|
||||||
if check_certificate(domain, ssl_certificate_primary, None) == "OK":
|
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
|
||||||
ssl_certificate = ssl_certificate_primary
|
ssl_certificate = ssl_certificate_primary
|
||||||
|
ssl_via = "Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME']
|
||||||
|
|
||||||
# Where would the CSR go? As with the SSL cert itself, the CSR must be
|
# For a 'www.' domain, see if we can reuse the cert of the parent.
|
||||||
# different for each domain name.
|
elif domain.startswith('www.'):
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
ssl_certificate_parent = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain[4:]))
|
||||||
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_cert_sign_req.csr')
|
if os.path.exists(ssl_certificate_parent) and check_certificate(domain, ssl_certificate_parent, None)[0] == "OK":
|
||||||
else:
|
ssl_certificate = ssl_certificate_parent
|
||||||
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/certificate_signing_request.csr' % safe_domain_name(domain))
|
ssl_via = "Using multi/wildcard certificate of %s." % domain[4:]
|
||||||
|
|
||||||
return ssl_key, ssl_certificate, csr_path
|
return ssl_key, ssl_certificate, ssl_via
|
||||||
|
|
||||||
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env):
|
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env):
|
||||||
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
|
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
|
||||||
# a certificate doesn't already exist. See setup/mail.sh for documentation.
|
# a certificate doesn't already exist. See setup/mail.sh for documentation.
|
||||||
|
|
||||||
@@ -177,19 +191,105 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
|
|||||||
|
|
||||||
# Generate a new self-signed certificate using the same private key that we already have.
|
# Generate a new self-signed certificate using the same private key that we already have.
|
||||||
|
|
||||||
# Start with a CSR.
|
# Start with a CSR written to a temporary file.
|
||||||
shell("check_call", [
|
with tempfile.NamedTemporaryFile(mode="w") as csr_fp:
|
||||||
"openssl", "req", "-new",
|
csr_fp.write(create_csr(domain, ssl_key, env))
|
||||||
"-key", ssl_key,
|
csr_fp.flush() # since we won't close until after running 'openssl x509', since close triggers delete.
|
||||||
"-out", csr_path,
|
|
||||||
"-sha256",
|
|
||||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
|
||||||
|
|
||||||
# And then make the certificate.
|
# And then make the certificate.
|
||||||
shell("check_call", [
|
shell("check_call", [
|
||||||
"openssl", "x509", "-req",
|
"openssl", "x509", "-req",
|
||||||
"-days", "365",
|
"-days", "365",
|
||||||
"-in", csr_path,
|
"-in", csr_fp.name,
|
||||||
"-signkey", ssl_key,
|
"-signkey", ssl_key,
|
||||||
"-out", ssl_certificate])
|
"-out", ssl_certificate])
|
||||||
|
|
||||||
|
def create_csr(domain, ssl_key, env):
|
||||||
|
return shell("check_output", [
|
||||||
|
"openssl", "req", "-new",
|
||||||
|
"-key", ssl_key,
|
||||||
|
"-out", "/dev/stdout",
|
||||||
|
"-sha256",
|
||||||
|
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||||
|
|
||||||
|
def install_cert(domain, ssl_cert, ssl_chain, env):
|
||||||
|
if domain not in get_web_domains(env):
|
||||||
|
return "Invalid domain name."
|
||||||
|
|
||||||
|
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
||||||
|
# The certificate always goes above the chain.
|
||||||
|
import tempfile, os
|
||||||
|
fd, fn = tempfile.mkstemp('.pem')
|
||||||
|
os.write(fd, (ssl_cert + '\n' + ssl_chain).encode("ascii"))
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
# Do validation on the certificate before installing it.
|
||||||
|
from status_checks import check_certificate
|
||||||
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env, allow_shared_cert=False)
|
||||||
|
cert_status, cert_status_details = check_certificate(domain, fn, ssl_key)
|
||||||
|
if cert_status != "OK":
|
||||||
|
if cert_status == "SELF-SIGNED":
|
||||||
|
cert_status = "This is a self-signed certificate. I can't install that."
|
||||||
|
os.unlink(fn)
|
||||||
|
if cert_status_details is not None:
|
||||||
|
cert_status += " " + cert_status_details
|
||||||
|
return cert_status
|
||||||
|
|
||||||
|
# Copy the certificate to its expected location.
|
||||||
|
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||||
|
shutil.move(fn, ssl_certificate)
|
||||||
|
|
||||||
|
ret = []
|
||||||
|
|
||||||
|
# When updating the cert for PRIMARY_HOSTNAME, also update DNS because it is
|
||||||
|
# used in the DANE TLSA record and restart postfix and dovecot which use
|
||||||
|
# that certificate.
|
||||||
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
|
ret.append( do_dns_update(env) )
|
||||||
|
|
||||||
|
shell('check_call', ["/usr/sbin/service", "postfix", "restart"])
|
||||||
|
shell('check_call', ["/usr/sbin/service", "dovecot", "restart"])
|
||||||
|
ret.append("mail services restarted")
|
||||||
|
|
||||||
|
# Kick nginx so it sees the cert.
|
||||||
|
ret.append( do_web_update(env, ok_status="") )
|
||||||
|
return "\n".join(r for r in ret if r.strip() != "")
|
||||||
|
|
||||||
|
def get_web_domains_info(env):
|
||||||
|
# load custom settings so we can tell what domains have a redirect or proxy set up on '/',
|
||||||
|
# which means static hosting is not happening
|
||||||
|
custom_settings = { }
|
||||||
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
|
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
||||||
|
def has_root_proxy_or_redirect(domain):
|
||||||
|
return custom_settings.get(domain, {}).get('redirects', {}).get('/') or custom_settings.get(domain, {}).get('proxies', {}).get('/')
|
||||||
|
|
||||||
|
# for the SSL config panel, get cert status
|
||||||
|
def check_cert(domain):
|
||||||
|
from status_checks import check_certificate
|
||||||
|
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||||
|
if not os.path.exists(ssl_certificate):
|
||||||
|
return ("danger", "No Certificate Installed")
|
||||||
|
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||||
|
if cert_status == "OK":
|
||||||
|
if not ssl_via:
|
||||||
|
return ("success", "Signed & valid. " + cert_status_details)
|
||||||
|
else:
|
||||||
|
# This is an alternate domain but using the same cert as the primary domain.
|
||||||
|
return ("success", "Signed & valid. " + ssl_via)
|
||||||
|
elif cert_status == "SELF-SIGNED":
|
||||||
|
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
||||||
|
else:
|
||||||
|
return ("danger", "Certificate has a problem: " + cert_status)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"domain": domain,
|
||||||
|
"root": get_web_root(domain, env),
|
||||||
|
"custom_root": get_web_root(domain, env, test_exists=False),
|
||||||
|
"ssl_certificate": check_cert(domain),
|
||||||
|
"static_enabled": not has_root_proxy_or_redirect(domain),
|
||||||
|
}
|
||||||
|
for domain in get_web_domains(env)
|
||||||
|
]
|
||||||
|
|||||||
33
setup/bootstrap.sh
Executable file → Normal file
33
setup/bootstrap.sh
Executable file → Normal file
@@ -7,7 +7,7 @@
|
|||||||
#########################################################
|
#########################################################
|
||||||
|
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG=v0.03
|
TAG=v0.09
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Are we running as root?
|
# Are we running as root?
|
||||||
@@ -16,27 +16,34 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Go to root's home directory.
|
|
||||||
cd
|
|
||||||
|
|
||||||
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
||||||
if [ ! -d mailinabox ]; then
|
if [ ! -d $HOME/mailinabox ]; then
|
||||||
echo Installing git . . .
|
echo Installing git . . .
|
||||||
apt-get -q -q install -y git
|
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
|
||||||
|
echo
|
||||||
|
|
||||||
echo Downloading Mail-in-a-Box . . .
|
echo Downloading Mail-in-a-Box $TAG. . .
|
||||||
git clone -b $TAG --depth 1 https://github.com/mail-in-a-box/mailinabox 2> /dev/null
|
git clone \
|
||||||
cd mailinabox
|
-b $TAG --depth 1 \
|
||||||
|
https://github.com/mail-in-a-box/mailinabox \
|
||||||
|
$HOME/mailinabox \
|
||||||
|
< /dev/null 2> /dev/null
|
||||||
|
|
||||||
# If it does exist, update it.
|
echo
|
||||||
else
|
fi
|
||||||
|
|
||||||
|
# Change directory to it.
|
||||||
|
cd $HOME/mailinabox
|
||||||
|
|
||||||
|
# Update it.
|
||||||
|
if [ "$TAG" != `git describe` ]; then
|
||||||
echo Updating Mail-in-a-Box to $TAG . . .
|
echo Updating Mail-in-a-Box to $TAG . . .
|
||||||
cd mailinabox
|
git fetch --depth 1 --force --prune origin tag $TAG
|
||||||
git fetch
|
|
||||||
if ! git checkout -q $TAG; then
|
if ! git checkout -q $TAG; then
|
||||||
echo "Update failed. Did you modify something in `pwd`?"
|
echo "Update failed. Did you modify something in `pwd`?"
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start setup script.
|
# Start setup script.
|
||||||
|
|||||||
45
setup/dkim.sh
Normal file → Executable file
45
setup/dkim.sh
Normal file → Executable file
@@ -1,13 +1,16 @@
|
|||||||
# OpenDKIM: Sign outgoing mail with DKIM
|
#!/bin/bash
|
||||||
########################################
|
# OpenDKIM
|
||||||
|
# --------
|
||||||
# After this, you'll still need to run dns_update.sh to get the DKIM
|
#
|
||||||
# signature in the DNS zones.
|
# OpenDKIM provides a service that puts a DKIM signature on outbound mail.
|
||||||
|
#
|
||||||
|
# The DNS configuration for DKIM is done in the management daemon.
|
||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# Install DKIM
|
# Install DKIM...
|
||||||
apt_install opendkim opendkim-tools
|
apt_install opendkim opendkim-tools opendmarc
|
||||||
|
|
||||||
# Make sure configuration directories exist.
|
# Make sure configuration directories exist.
|
||||||
mkdir -p /etc/opendkim;
|
mkdir -p /etc/opendkim;
|
||||||
@@ -18,9 +21,9 @@ mkdir -p $STORAGE_ROOT/mail/dkim
|
|||||||
echo "127.0.0.1" > /etc/opendkim/TrustedHosts
|
echo "127.0.0.1" > /etc/opendkim/TrustedHosts
|
||||||
|
|
||||||
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
|
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
|
||||||
true; # already done
|
true # already done #NODOC
|
||||||
else
|
else
|
||||||
# Add various configuration options to the end.
|
# Add various configuration options to the end of `opendkim.conf`.
|
||||||
cat >> /etc/opendkim.conf << EOF;
|
cat >> /etc/opendkim.conf << EOF;
|
||||||
MinimumKeyBits 1024
|
MinimumKeyBits 1024
|
||||||
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
||||||
@@ -32,7 +35,7 @@ RequireSafeKeys false
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create a new DKIM key if we don't have one already. This creates
|
# Create a new DKIM key. This creates
|
||||||
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
|
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
|
||||||
# is the actual private key and the latter is the suggested DNS TXT
|
# is the actual private key and the latter is the suggested DNS TXT
|
||||||
# entry which we'll want to include in our DNS setup.
|
# entry which we'll want to include in our DNS setup.
|
||||||
@@ -45,15 +48,29 @@ fi
|
|||||||
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
|
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
|
||||||
chmod go-rwx $STORAGE_ROOT/mail/dkim
|
chmod go-rwx $STORAGE_ROOT/mail/dkim
|
||||||
|
|
||||||
# Add OpenDKIM as a milter to postfix, which is how it intercepts outgoing
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
# mail to perform the signing (by adding a mail header).
|
"Syslog=true" \
|
||||||
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line.
|
"Socket=inet:8893@[127.0.0.1]"
|
||||||
|
|
||||||
|
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
|
||||||
|
# intercepts outgoing mail to perform the signing (by adding a mail header)
|
||||||
|
# and how they both intercept incoming mail to add Authentication-Results
|
||||||
|
# headers. The order possibly/probably matters: OpenDMARC relies on the
|
||||||
|
# OpenDKIM Authentication-Results header already being present.
|
||||||
|
#
|
||||||
|
# Be careful. If we add other milters later, this needs to be concatenated
|
||||||
|
# on the smtpd_milters line.
|
||||||
|
#
|
||||||
|
# The OpenDMARC milter is skipped in the SMTP submission listener by
|
||||||
|
# configuring smtpd_milters there to only list the OpenDKIM milter
|
||||||
|
# (see mail-postfix.sh).
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_milters=inet:127.0.0.1:8891 \
|
"smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\
|
||||||
non_smtpd_milters=\$smtpd_milters \
|
non_smtpd_milters=\$smtpd_milters \
|
||||||
milter_default_action=accept
|
milter_default_action=accept
|
||||||
|
|
||||||
# Restart services.
|
# Restart services.
|
||||||
restart_service opendkim
|
restart_service opendkim
|
||||||
|
restart_service opendmarc
|
||||||
restart_service postfix
|
restart_service postfix
|
||||||
|
|
||||||
|
|||||||
107
setup/dns.sh
107
setup/dns.sh
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# DNS: Configure a DNS server to host our own DNS
|
# DNS
|
||||||
# -----------------------------------------------
|
# -----------------------------------------------
|
||||||
|
|
||||||
# This script installs packages, but the DNS zone files are only
|
# This script installs packages, but the DNS zone files are only
|
||||||
@@ -8,20 +8,9 @@
|
|||||||
# mail users & aliases created by the user later.
|
# mail users & aliases created by the user later.
|
||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# Install `nsd`, our DNS server software, and `ldnsutils` which helps
|
# Install the packages.
|
||||||
# us sign zones for DNSSEC.
|
|
||||||
|
|
||||||
# ...but first, we have to create the user because the
|
|
||||||
# current Ubuntu forgets to do so in the .deb
|
|
||||||
# see issue #25 and https://bugs.launchpad.net/ubuntu/+source/nsd/+bug/1311886
|
|
||||||
if id nsd > /dev/null 2>&1; then
|
|
||||||
true; #echo "nsd user exists... good"; #NODOC
|
|
||||||
else
|
|
||||||
useradd nsd;
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Okay now install the packages.
|
|
||||||
#
|
#
|
||||||
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
||||||
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
||||||
@@ -31,44 +20,104 @@ apt_install nsd ldnsutils openssh-client
|
|||||||
|
|
||||||
# Prepare nsd's configuration.
|
# Prepare nsd's configuration.
|
||||||
|
|
||||||
sudo mkdir -p /var/run/nsd
|
mkdir -p /var/run/nsd
|
||||||
|
|
||||||
|
cat > /etc/nsd/nsd.conf << EOF;
|
||||||
|
# No not edit. Overwritten by Mail-in-a-Box setup.
|
||||||
|
server:
|
||||||
|
hide-version: yes
|
||||||
|
|
||||||
|
# identify the server (CH TXT ID.SERVER entry).
|
||||||
|
identity: ""
|
||||||
|
|
||||||
|
# The directory for zonefile: files.
|
||||||
|
zonesdir: "/etc/nsd/zones"
|
||||||
|
|
||||||
|
# Allows NSD to bind to IP addresses that are not (yet) added to the
|
||||||
|
# network interface. This allows nsd to start even if the network stack
|
||||||
|
# isn't fully ready, which apparently happens in some cases.
|
||||||
|
# See https://www.nlnetlabs.nl/projects/nsd/nsd.conf.5.html.
|
||||||
|
ip-transparent: yes
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Since we have bind9 listening on localhost for locally-generated
|
||||||
|
# DNS queries that require a recursive nameserver, and the system
|
||||||
|
# might have other network interfaces for e.g. tunnelling, we have
|
||||||
|
# to be specific about the network interfaces that nsd binds to.
|
||||||
|
for ip in $PRIVATE_IP $PRIVATE_IPV6; do
|
||||||
|
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "include: /etc/nsd/zones.conf" >> /etc/nsd/nsd.conf;
|
||||||
|
|
||||||
# Create DNSSEC signing keys.
|
# Create DNSSEC signing keys.
|
||||||
|
|
||||||
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
|
|
||||||
echo "Generating DNSSEC signing keys. This may take a few minutes..."
|
|
||||||
|
|
||||||
# Create the Key-Signing Key (KSK) (-k) which is the so-called
|
# TLDs don't all support the same algorithms, so we'll generate keys using a few
|
||||||
# Secure Entry Point. Use a NSEC3-compatible algorithm (best
|
# different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used
|
||||||
# practice), and a nice and long keylength. The domain name we
|
# algorithm that supported NSEC3, which is a security best practice. However TLDs
|
||||||
# provide ("_domain_") doesn't matter -- we'll use the same
|
# will probably be moving away from it to a a SHA256-based algorithm.
|
||||||
# keys for all our domains.
|
#
|
||||||
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 2048 -k _domain_);
|
# Supports `RSASHA1-NSEC3-SHA1` (didn't test with `RSASHA256`):
|
||||||
|
#
|
||||||
|
# * .info
|
||||||
|
# * .me
|
||||||
|
#
|
||||||
|
# Requires `RSASHA256`
|
||||||
|
#
|
||||||
|
# * .email
|
||||||
|
# * .guide
|
||||||
|
#
|
||||||
|
# Supports `RSASHA256` (and defaulting to this)
|
||||||
|
#
|
||||||
|
# * .fund
|
||||||
|
|
||||||
|
FIRST=1 #NODOC
|
||||||
|
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
|
||||||
|
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
||||||
|
if [ $FIRST == 1 ]; then
|
||||||
|
echo "Generating DNSSEC signing keys. This may take a few minutes..."
|
||||||
|
FIRST=0 #NODOC
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
|
||||||
|
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
|
||||||
|
# matter -- we'll use the same keys for all our domains.
|
||||||
|
#
|
||||||
|
# `ldns-keygen` outputs the new key's filename to stdout, which
|
||||||
|
# we're capturing into the `KSK` variable.
|
||||||
|
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 2048 -k _domain_);
|
||||||
|
|
||||||
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
||||||
# rotated more often than a KSK, although we have no plans to
|
# rotated more often than a KSK, although we have no plans to
|
||||||
# rotate it (and doing so would be difficult to do without
|
# rotate it (and doing so would be difficult to do without
|
||||||
# disturbing DNS availability.) Omit '-k' and use a shorter key.
|
# disturbing DNS availability.) Omit `-k` and use a shorter key length.
|
||||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 _domain_);
|
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 1024 _domain_);
|
||||||
|
|
||||||
# These generate two sets of files like:
|
# These generate two sets of files like:
|
||||||
#
|
#
|
||||||
# * `K_domain_.+007+08882.ds`: DS record to provide to domain name registrar
|
# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_`)
|
||||||
# * `K_domain_.+007+08882.key`: public key (goes into DS record & upstream DNS provider like your registrar)
|
# * `K_domain_.+007+08882.key`: public key
|
||||||
# * `K_domain_.+007+08882.private`: private key (secret!)
|
# * `K_domain_.+007+08882.private`: private key (secret!)
|
||||||
|
|
||||||
# The filenames are unpredictable and encode the key generation
|
# The filenames are unpredictable and encode the key generation
|
||||||
# options. So we'll store the names of the files we just generated.
|
# options. So we'll store the names of the files we just generated.
|
||||||
# We might have multiple keys down the road. This will identify
|
# We might have multiple keys down the road. This will identify
|
||||||
# what keys are the current keys.
|
# what keys are the current keys.
|
||||||
cat > $STORAGE_ROOT/dns/dnssec/keys.conf << EOF;
|
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
|
||||||
KSK=$KSK
|
KSK=$KSK
|
||||||
ZSK=$ZSK
|
ZSK=$ZSK
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force the dns_update script to be run every day to re-sign zones for DNSSEC.
|
# And loop to do the next algorithm...
|
||||||
|
done
|
||||||
|
|
||||||
|
# Force the dns_update script to be run every day to re-sign zones for DNSSEC
|
||||||
|
# before they expire. When we sign zones (in `dns_update.py`) we specify a
|
||||||
|
# 30-day validation window, so we had better re-sign before then.
|
||||||
cat > /etc/cron.daily/mailinabox-dnssec << EOF;
|
cat > /etc/cron.daily/mailinabox-dnssec << EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mail-in-a-Box
|
# Mail-in-a-Box
|
||||||
|
|||||||
@@ -9,19 +9,35 @@ function hide_output {
|
|||||||
$@ &> $OUTPUT
|
$@ &> $OUTPUT
|
||||||
|
|
||||||
# If the command failed, show the output that was captured in the temporary file.
|
# If the command failed, show the output that was captured in the temporary file.
|
||||||
if [ $? != 0 ]; then
|
E=$?
|
||||||
|
if [ $E != 0 ]; then
|
||||||
# Something failed.
|
# Something failed.
|
||||||
echo
|
echo
|
||||||
echo FAILED: $@
|
echo FAILED: $@
|
||||||
echo -----------------------------------------
|
echo -----------------------------------------
|
||||||
cat $OUTPUT
|
cat $OUTPUT
|
||||||
echo -----------------------------------------
|
echo -----------------------------------------
|
||||||
|
exit $E
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove temporary file.
|
# Remove temporary file.
|
||||||
rm -f $OUTPUT
|
rm -f $OUTPUT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apt_get_quiet {
|
||||||
|
# Run apt-get in a totally non-interactive mode.
|
||||||
|
#
|
||||||
|
# Somehow all of these options are needed to get it to not ask the user
|
||||||
|
# questions about a) whether to proceed (-y), b) package options (noninteractive),
|
||||||
|
# and c) what to do about files changed locally (we don't cause that to happen but
|
||||||
|
# some VM providers muck with their images; -o).
|
||||||
|
#
|
||||||
|
# Although we could pass -qq to apt-get to make output quieter, many packages write to stdout
|
||||||
|
# and stderr things that aren't really important. Use our hide_output function to capture
|
||||||
|
# all of that and only show it if there is a problem (i.e. if apt_get returns a failure exit status).
|
||||||
|
DEBIAN_FRONTEND=noninteractive hide_output apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confnew" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
function apt_install {
|
function apt_install {
|
||||||
# Report any packages already installed.
|
# Report any packages already installed.
|
||||||
PACKAGES=$@
|
PACKAGES=$@
|
||||||
@@ -46,18 +62,10 @@ function apt_install {
|
|||||||
echo installing $TO_INSTALL...
|
echo installing $TO_INSTALL...
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 'DEBIAN_FRONTEND=noninteractive' is to prevent dbconfig-common from asking you questions.
|
# We still include the whole original package list in the apt-get command in
|
||||||
#
|
|
||||||
# Although we could pass -qq to apt-get to make output quieter, many packages write to stdout
|
|
||||||
# and stderr things that aren't really important. Use our hide_output function to capture
|
|
||||||
# all of that and only show it if there is a problem (i.e. if apt_get returns a failure exit status).
|
|
||||||
#
|
|
||||||
# Also note that we still include the whole original package list in the apt-get command in
|
|
||||||
# case it wants to upgrade anything, I guess? Maybe we can remove it. Doesn't normally make
|
# case it wants to upgrade anything, I guess? Maybe we can remove it. Doesn't normally make
|
||||||
# a difference.
|
# a difference.
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
apt_get_quiet install $PACKAGES
|
||||||
hide_output \
|
|
||||||
apt-get -y install $PACKAGES
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_default_hostname {
|
function get_default_hostname {
|
||||||
@@ -173,3 +181,43 @@ function input_menu {
|
|||||||
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
|
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
|
||||||
result_code=$?
|
result_code=$?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wget_verify {
|
||||||
|
# Downloads a file from the web and checks that it matches
|
||||||
|
# a provided hash. If the comparison fails, exit immediately.
|
||||||
|
URL=$1
|
||||||
|
HASH=$2
|
||||||
|
DEST=$3
|
||||||
|
CHECKSUM="$HASH $DEST"
|
||||||
|
rm -f $DEST
|
||||||
|
wget -q -O $DEST $URL || exit 1
|
||||||
|
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "Download of $URL did not match expected checksum."
|
||||||
|
echo "Found:"
|
||||||
|
sha1sum $DEST
|
||||||
|
echo
|
||||||
|
echo "Expected:"
|
||||||
|
echo "$CHECKSUM"
|
||||||
|
rm -f $DEST
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function git_clone {
|
||||||
|
# Clones a git repository, checks out a particular commit or tag,
|
||||||
|
# and moves the repository (or a subdirectory in it) to some path.
|
||||||
|
# We use separate clone and checkout because -b only supports tags
|
||||||
|
# and branches, but we sometimes want to reference a commit hash
|
||||||
|
# directly when the repo doesn't provide a tag.
|
||||||
|
REPO=$1
|
||||||
|
TREEISH=$2
|
||||||
|
SUBDIR=$3
|
||||||
|
TARGETPATH=$4
|
||||||
|
TMPPATH=/tmp/git-clone-$$
|
||||||
|
rm -rf $TMPPATH $TARGETPATH
|
||||||
|
git clone -q $REPO $TMPPATH || exit 1
|
||||||
|
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1
|
||||||
|
mv $TMPPATH/$SUBDIR $TARGETPATH
|
||||||
|
rm -rf $TMPPATH
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Dovecot (IMAP and LDA)
|
# Dovecot (IMAP/POP and LDA)
|
||||||
# ----------------------
|
# ----------------------
|
||||||
#
|
#
|
||||||
# Dovecot is *both* the IMAP server (the protocol that email applications
|
# Dovecot is *both* the IMAP/POP server (the protocol that email applications
|
||||||
# use to query a mailbox) as well as the local delivery agent (LDA),
|
# use to query a mailbox) as well as the local delivery agent (LDA),
|
||||||
# meaning it is responsible for writing emails to mailbox storage on disk.
|
# meaning it is responsible for writing emails to mailbox storage on disk.
|
||||||
# You could imagine why these things would be bundled together.
|
# You could imagine why these things would be bundled together.
|
||||||
@@ -18,21 +18,41 @@
|
|||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# ### Install packages and basic setup
|
# Install packages...
|
||||||
|
|
||||||
apt_install \
|
apt_install \
|
||||||
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite sqlite3 \
|
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
|
||||||
dovecot-sieve dovecot-managesieved
|
dovecot-sieve dovecot-managesieved
|
||||||
|
|
||||||
# The dovecot-imapd and dovecot-lmtpd packages automatically enable IMAP and LMTP protocols.
|
# The `dovecot-imapd`, `dovecot-pop3d`, and `dovecot-lmtpd` packages automatically
|
||||||
|
# enable IMAP, POP and LMTP protocols.
|
||||||
|
|
||||||
# Set the location where we'll store user mailboxes.
|
# Set basic daemon options.
|
||||||
|
|
||||||
|
# The `default_process_limit` is 100, which constrains the total number
|
||||||
|
# of active IMAP connections (at, say, 5 open connections per user that
|
||||||
|
# would be 20 users). Set it to 250 times the number of cores this
|
||||||
|
# machine has, so on a two-core machine that's 500 processes/100 users).
|
||||||
|
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
|
||||||
|
default_process_limit=$(echo "`nproc` * 250" | bc)
|
||||||
|
|
||||||
|
# The inotify `max_user_instances` default is 128, which constrains
|
||||||
|
# the total number of watched (IMAP IDLE push) folders by open connections.
|
||||||
|
# See http://www.dovecot.org/pipermail/dovecot/2013-March/088834.html.
|
||||||
|
# A reboot is required for this to take effect (which we don't do as
|
||||||
|
# as a part of setup). Test with `cat /proc/sys/fs/inotify/max_user_instances`.
|
||||||
|
tools/editconf.py /etc/sysctl.conf \
|
||||||
|
fs.inotify.max_user_instances=1024
|
||||||
|
|
||||||
|
# Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the
|
||||||
|
# username part of the user's email address. We'll ensure that no bad domains or email addresses
|
||||||
|
# are created within the management daemon.
|
||||||
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||||
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
|
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
|
||||||
mail_privileged_group=mail \
|
mail_privileged_group=mail \
|
||||||
first_valid_uid=0
|
first_valid_uid=0
|
||||||
|
|
||||||
# ### IMAP
|
# ### IMAP/POP
|
||||||
|
|
||||||
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
||||||
# The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess
|
# The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess
|
||||||
@@ -41,16 +61,18 @@ tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
|
|||||||
disable_plaintext_auth=yes \
|
disable_plaintext_auth=yes \
|
||||||
"auth_mechanisms=plain login"
|
"auth_mechanisms=plain login"
|
||||||
|
|
||||||
# Enable SSL, specify the location of the SSL certificate and private key files,
|
# Enable SSL, specify the location of the SSL certificate and private key files.
|
||||||
# and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
# Disable obsolete SSL protocols and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||||
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||||
ssl=required \
|
ssl=required \
|
||||||
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
||||||
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
||||||
|
"ssl_protocols=!SSLv3 !SSLv2" \
|
||||||
"ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH"
|
"ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH"
|
||||||
|
|
||||||
# Disable in-the-clear IMAP and POP because we're paranoid (we haven't even
|
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
||||||
# enabled POP).
|
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
||||||
|
# are made available (IMAPS on port 993; POP3S on port 995).
|
||||||
sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||||
sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||||
|
|
||||||
@@ -63,10 +85,17 @@ sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
|||||||
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
||||||
imap_idle_notify_interval="4 mins"
|
imap_idle_notify_interval="4 mins"
|
||||||
|
|
||||||
|
# Set POP3 UIDL
|
||||||
|
# UIDLs are used by POP3 clients to keep track of what messages they've downloaded.
|
||||||
|
# For new POP3 servers, the easiest way to set up UIDLs is to use IMAP's UIDVALIDITY
|
||||||
|
# and UID values, the default in Dovecot.
|
||||||
|
tools/editconf.py /etc/dovecot/conf.d/20-pop3.conf \
|
||||||
|
pop3_uidl_format="%08Xu%08Xv"
|
||||||
|
|
||||||
# ### LDA (LMTP)
|
# ### LDA (LMTP)
|
||||||
|
|
||||||
# Enable Dovecot's LDA service with the LMTP protocol. It will listen
|
# Enable Dovecot's LDA service with the LMTP protocol. It will listen
|
||||||
# in port 10026, and Spamassassin will be configured to pass mail there.
|
# on port 10026, and Spamassassin will be configured to pass mail there.
|
||||||
#
|
#
|
||||||
# The disabled unix socket listener is normally how Postfix and Dovecot
|
# The disabled unix socket listener is normally how Postfix and Dovecot
|
||||||
# would communicate (see the Postfix setup script for the corresponding
|
# would communicate (see the Postfix setup script for the corresponding
|
||||||
@@ -91,30 +120,32 @@ protocol imap {
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Setting a postmaster_address seems to be required or LMTP won't start.
|
# Setting a `postmaster_address` is required or LMTP won't start. An alias
|
||||||
|
# will be created automatically by our management daemon.
|
||||||
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
|
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
|
||||||
postmaster_address=postmaster@$PRIMARY_HOSTNAME
|
postmaster_address=postmaster@$PRIMARY_HOSTNAME
|
||||||
|
|
||||||
# ### Sieve
|
# ### Sieve
|
||||||
|
|
||||||
# Enable the Dovecot sieve plugin which let's users run scripts that process
|
# Enable the Dovecot sieve plugin which let's users run scripts that process
|
||||||
# mail as it comes in. We'll also set a global script that moves mail marked
|
# mail as it comes in.
|
||||||
# as spam by Spamassassin into the user's Spam folder.
|
|
||||||
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
||||||
|
|
||||||
|
# Configure sieve. We'll create a global script that moves mail marked
|
||||||
|
# as spam by Spamassassin into the user's Spam folder.
|
||||||
|
#
|
||||||
|
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
|
||||||
|
#
|
||||||
|
# * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic
|
||||||
|
# link here to the actual sieve script. It should not be in the mailbox directory
|
||||||
|
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
||||||
|
# (because then I suppose it might appear to the user as one of their scripts).
|
||||||
|
# * `sieve_dir`: Directory for :personal include scripts for the include extension. This
|
||||||
|
# is also where the ManageSieve service stores the user's scripts.
|
||||||
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
||||||
plugin {
|
plugin {
|
||||||
# The path to our global sieve which handles moving spam to the Spam folder.
|
|
||||||
sieve_before = /etc/dovecot/sieve-spam.sieve
|
sieve_before = /etc/dovecot/sieve-spam.sieve
|
||||||
|
|
||||||
# The path to the user's main active script. ManageSieve will create a symbolic
|
|
||||||
# link here to the actual sieve script. It should not be in the mailbox directory
|
|
||||||
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
|
||||||
# (because then I suppose it might appear to the user as one of their scripts).
|
|
||||||
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
||||||
|
|
||||||
# Directory for :personal include scripts for the include extension. This
|
|
||||||
# is also where the ManageSieve service stores the user's scripts.
|
|
||||||
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -122,7 +153,7 @@ EOF
|
|||||||
# Copy the global sieve script into where we've told Dovecot to look for it. Then
|
# Copy the global sieve script into where we've told Dovecot to look for it. Then
|
||||||
# compile it. Global scripts must be compiled now because Dovecot won't have
|
# compile it. Global scripts must be compiled now because Dovecot won't have
|
||||||
# permission later.
|
# permission later.
|
||||||
cp `pwd`/conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
|
cp conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
|
||||||
sievec /etc/dovecot/sieve-spam.sieve
|
sievec /etc/dovecot/sieve-spam.sieve
|
||||||
|
|
||||||
# PERMISSIONS
|
# PERMISSIONS
|
||||||
@@ -139,8 +170,9 @@ chown -R mail.mail $STORAGE_ROOT/mail/mailboxes
|
|||||||
mkdir -p $STORAGE_ROOT/mail/sieve
|
mkdir -p $STORAGE_ROOT/mail/sieve
|
||||||
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
||||||
|
|
||||||
# Allow the IMAP port in the firewall.
|
# Allow the IMAP/POP ports in the firewall.
|
||||||
ufw_allow imaps
|
ufw_allow imaps
|
||||||
|
ufw_allow pop3s
|
||||||
|
|
||||||
# Restart services.
|
# Restart services.
|
||||||
restart_service dovecot
|
restart_service dovecot
|
||||||
|
|||||||
@@ -32,13 +32,26 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
|
|
||||||
# ### Install packages.
|
# ### Install packages.
|
||||||
|
|
||||||
apt_install postfix postgrey postfix-pcre ca-certificates
|
# Install postfix's packages.
|
||||||
|
#
|
||||||
|
# * `postfix`: The SMTP server.
|
||||||
|
# * `postfix-pcre`: Enables header filtering.
|
||||||
|
# * `postgrey`: A mail policy service that soft-rejects mail the first time
|
||||||
|
# it is received. Spammers don't usually try agian. Legitimate mail
|
||||||
|
# always will.
|
||||||
|
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
||||||
|
# untrusted opportunistically-encrypted connections.
|
||||||
|
|
||||||
|
apt_install postfix postfix-pcre postgrey ca-certificates
|
||||||
|
|
||||||
# ### Basic Settings
|
# ### Basic Settings
|
||||||
|
|
||||||
# Have postfix listen on all network interfaces, set our name (the Debian default seems to be localhost),
|
# Set some basic settings...
|
||||||
# and set the name of the local machine to localhost for xxx@localhost mail (but I don't think this will have any effect because
|
#
|
||||||
# there is no true local mail delivery). Also set the banner (must have the hostname first, then anything).
|
# * Have postfix listen on all network interfaces.
|
||||||
|
# * Set our name (the Debian default seems to be "localhost" but make it our hostname).
|
||||||
|
# * Set the name of the local machine to localhost, which means xxx@localhost is delivered locally, although we don't use it.
|
||||||
|
# * Set the SMTP banner (which must have the hostname first, then anything).
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
inet_interfaces=all \
|
inet_interfaces=all \
|
||||||
myhostname=$PRIMARY_HOSTNAME\
|
myhostname=$PRIMARY_HOSTNAME\
|
||||||
@@ -49,8 +62,13 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
|
|
||||||
# Enable the 'submission' port 587 smtpd server and tweak its settings.
|
# Enable the 'submission' port 587 smtpd server and tweak its settings.
|
||||||
#
|
#
|
||||||
|
# * Do not add the OpenDMAC Authentication-Results header. That should only be added
|
||||||
|
# on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
|
||||||
|
# OpenDKIM milter only. See dkim.sh.
|
||||||
|
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
|
||||||
|
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
|
||||||
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||||
# but without affecting opportunistic TLS on incoming mail, which will allow any cipher (it's better than none).
|
# By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none).
|
||||||
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||||
# * Add a new cleanup service specific to the submission service ('authclean')
|
# * Add a new cleanup service specific to the submission service ('authclean')
|
||||||
# that filters out privacy-sensitive headers on mail being sent out by
|
# that filters out privacy-sensitive headers on mail being sent out by
|
||||||
@@ -58,7 +76,9 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
tools/editconf.py /etc/postfix/master.cf -s -w \
|
tools/editconf.py /etc/postfix/master.cf -s -w \
|
||||||
"submission=inet n - - - - smtpd
|
"submission=inet n - - - - smtpd
|
||||||
-o syslog_name=postfix/submission
|
-o syslog_name=postfix/submission
|
||||||
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3
|
-o smtpd_milters=inet:127.0.0.1:8891
|
||||||
|
-o smtpd_tls_security_level=encrypt
|
||||||
|
-o smtpd_tls_ciphers=high -o smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 -o smtpd_tls_protocols=!SSLv2,!SSLv3
|
||||||
-o cleanup_service_name=authclean" \
|
-o cleanup_service_name=authclean" \
|
||||||
"authclean=unix n - - - 0 cleanup
|
"authclean=unix n - - - 0 cleanup
|
||||||
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters"
|
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters"
|
||||||
@@ -69,44 +89,49 @@ cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_f
|
|||||||
# Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
|
# Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
|
||||||
# require TLS before a user is allowed to authenticate. This also makes
|
# require TLS before a user is allowed to authenticate. This also makes
|
||||||
# opportunistic TLS available on *incoming* mail.
|
# opportunistic TLS available on *incoming* mail.
|
||||||
|
# Set stronger DH parameters, which via openssl tend to default to 1024 bits
|
||||||
|
# (see ssl.sh).
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_tls_security_level=may\
|
smtpd_tls_security_level=may\
|
||||||
smtpd_tls_auth_only=yes \
|
smtpd_tls_auth_only=yes \
|
||||||
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
||||||
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
||||||
|
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
||||||
|
smtpd_tls_ciphers=medium \
|
||||||
|
smtpd_tls_exclude_ciphers=aNULL \
|
||||||
smtpd_tls_received_header=yes
|
smtpd_tls_received_header=yes
|
||||||
|
|
||||||
# Prevent non-authenticated users from sending mail that requires being
|
# Prevent non-authenticated users from sending mail that requires being
|
||||||
# relayed elsewhere. We don't want to be an "open relay". On outbound
|
# relayed elsewhere. We don't want to be an "open relay". On outbound
|
||||||
# mail, require one of:
|
# mail, require one of:
|
||||||
#
|
#
|
||||||
# * permit_sasl_authenticated: Authenticated users (i.e. on port 587).
|
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587).
|
||||||
# * permit_mynetworks: Mail that originates locally.
|
# * `permit_mynetworks`: Mail that originates locally.
|
||||||
# * reject_unauth_destination: No one else. (Permits mail whose destination is local and rejects other mail.)
|
# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination
|
smtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination
|
||||||
|
|
||||||
|
|
||||||
# ### DANE
|
# ### DANE
|
||||||
#
|
|
||||||
# When connecting to remote SMTP servers, prefer TLS and use DANE if available.
|
# When connecting to remote SMTP servers, prefer TLS and use DANE if available.
|
||||||
#
|
#
|
||||||
# Prefering ("opportunistic") TLS means Postfix will accept whatever SSL certificate the remote
|
# Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end
|
||||||
# end provides, if the remote end offers STARTTLS during the connection. DANE takes this a
|
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
||||||
# step further:
|
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
||||||
|
# protects against passive easvesdropping (but not man-in-the-middle attacks).
|
||||||
|
# DANE takes this a step further:
|
||||||
#
|
#
|
||||||
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
||||||
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
||||||
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
||||||
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
|
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
|
||||||
# relies on our local bind9 server being present and smtp_dns_support_level being set to dnssec
|
# relies on our local bind9 server being present and `smtp_dns_support_level=dnssec`.
|
||||||
# to use it.
|
|
||||||
#
|
#
|
||||||
# The smtp_tls_CAfile is superflous, but it turns warnings in the logs about untrusted certs
|
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
|
||||||
# into notices about trusted certs. Since in these cases Postfix is doing opportunistic TLS,
|
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
|
||||||
# it does not care about whether the remote certificate is trusted. But, looking at the logs,
|
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
|
||||||
# it's nice to be able to see that the connection was in fact encrypted for the right party.
|
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
|
||||||
# The CA file is provided by the package ca-certificates.
|
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtp_tls_security_level=dane \
|
smtp_tls_security_level=dane \
|
||||||
smtp_dns_support_level=dnssec \
|
smtp_dns_support_level=dnssec \
|
||||||
@@ -126,25 +151,31 @@ tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
|
|||||||
|
|
||||||
# Who can send mail to us? Some basic filters.
|
# Who can send mail to us? Some basic filters.
|
||||||
#
|
#
|
||||||
# * reject_non_fqdn_sender: Reject not-nice-looking return paths.
|
# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
|
||||||
# * reject_unknown_sender_domain: Reject return paths with invalid domains.
|
# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
|
||||||
# * reject_rhsbl_sender: Reject return paths that use blacklisted domains.
|
# * `reject_rhsbl_sender`: Reject return paths that use blacklisted domains.
|
||||||
# * permit_sasl_authenticated: Authenticated users (i.e. on port 587) can skip further checks.
|
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.
|
||||||
# * permit_mynetworks: Mail that originates locally can skip further checks.
|
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
|
||||||
# * reject_rbl_client: Reject connections from IP addresses blacklisted in zen.spamhaus.org
|
# * `reject_rbl_client`: Reject connections from IP addresses blacklisted in zen.spamhaus.org
|
||||||
# * reject_unlisted_recipient: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
|
# * `reject_unlisted_recipient`: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
|
||||||
# * check_policy_service: Apply greylisting using postgrey.
|
# * `check_policy_service`: Apply greylisting using postgrey.
|
||||||
#
|
#
|
||||||
# Notes:
|
# Notes: #NODOC
|
||||||
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting
|
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC
|
||||||
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not
|
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #NODOC
|
||||||
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into
|
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC
|
||||||
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce.
|
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \
|
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \
|
||||||
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
|
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
|
||||||
|
|
||||||
|
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
|
||||||
|
# Postgrey listens on the same interface (and not IPv6, for instance).
|
||||||
|
tools/editconf.py /etc/default/postgrey \
|
||||||
|
POSTGREY_OPTS=\"--inet=127.0.0.1:10023\"
|
||||||
|
|
||||||
# Increase the message size limit from 10MB to 128MB.
|
# Increase the message size limit from 10MB to 128MB.
|
||||||
|
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
message_size_limit=134217728
|
message_size_limit=134217728
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ EOF
|
|||||||
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
||||||
|
|
||||||
# Have Dovecot provide an authorization service that Postfix can access & use.
|
# Have Dovecot provide an authorization service that Postfix can access & use.
|
||||||
# Drew Crawford sets the auth-worker process to run as the mail user, but we don't care if it runs as root.
|
|
||||||
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
|
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
|
||||||
service auth {
|
service auth {
|
||||||
unix_listener /var/spool/postfix/private/auth {
|
unix_listener /var/spool/postfix/private/auth {
|
||||||
@@ -93,9 +92,16 @@ query = SELECT 1 FROM users WHERE email='%s'
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# SQL statement to rewrite an email address if an alias is present.
|
# SQL statement to rewrite an email address if an alias is present.
|
||||||
|
# Aliases have precedence over users, but that's counter-intuitive for
|
||||||
|
# catch-all aliases ("@domain.com") which should *not* catch mail users.
|
||||||
|
# To fix this, not only query the aliases table but also the users
|
||||||
|
# table, i.e. turn users into aliases from themselves to themselves.
|
||||||
|
# If there is both an alias and a user for the same address either
|
||||||
|
# might be returned by the UNION, so the whole query is wrapped in
|
||||||
|
# another select that prioritizes the alias definition.
|
||||||
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
query = SELECT destination FROM aliases WHERE source='%s'
|
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Restart Services
|
# Restart Services
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
source setup/functions.sh
|
source setup/functions.sh
|
||||||
|
|
||||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
||||||
hide_output pip3 install rtyaml
|
hide_output pip3 install rtyaml "email_validator==0.1.0-rc4"
|
||||||
|
# email_validator is repeated in setup/questions.sh
|
||||||
|
|
||||||
# Create a backup directory and a random key for encrypting backups.
|
# Create a backup directory and a random key for encrypting backups.
|
||||||
mkdir -p $STORAGE_ROOT/backup
|
mkdir -p $STORAGE_ROOT/backup
|
||||||
@@ -30,5 +31,19 @@ $(pwd)/management/backup.py
|
|||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.daily/mailinabox-backup
|
chmod +x /etc/cron.daily/mailinabox-backup
|
||||||
|
|
||||||
# Start it.
|
# Perform daily status checks. Compare each day to the previous
|
||||||
|
# for changes and mail the changes to the administrator.
|
||||||
|
cat > /etc/cron.daily/mailinabox-statuschecks << EOF;
|
||||||
|
#!/bin/bash
|
||||||
|
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
||||||
|
# Run status checks.
|
||||||
|
$(pwd)/management/status_checks.py --show-changes --smtp
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/cron.daily/mailinabox-statuschecks
|
||||||
|
|
||||||
|
|
||||||
|
# Start it. Remove the api key file first so that start.sh
|
||||||
|
# can wait for it to be created to know that the management
|
||||||
|
# server is ready.
|
||||||
|
rm -f /var/lib/mailinabox/api.key
|
||||||
restart_service mailinabox
|
restart_service mailinabox
|
||||||
|
|||||||
@@ -60,6 +60,42 @@ def migration_5(env):
|
|||||||
# The secret key for encrypting backups was world readable. Fix here.
|
# The secret key for encrypting backups was world readable. Fix here.
|
||||||
os.chmod(os.path.join(env["STORAGE_ROOT"], 'backup/secret_key.txt'), 0o600)
|
os.chmod(os.path.join(env["STORAGE_ROOT"], 'backup/secret_key.txt'), 0o600)
|
||||||
|
|
||||||
|
def migration_6(env):
|
||||||
|
# We now will generate multiple DNSSEC keys for different algorithms, since TLDs may
|
||||||
|
# not support them all. .email only supports RSA/SHA-256. Rename the keys.conf file
|
||||||
|
# to be algorithm-specific.
|
||||||
|
basepath = os.path.join(env["STORAGE_ROOT"], 'dns/dnssec')
|
||||||
|
shutil.move(os.path.join(basepath, 'keys.conf'), os.path.join(basepath, 'RSASHA1-NSEC3-SHA1.conf'))
|
||||||
|
|
||||||
|
def migration_7(env):
|
||||||
|
# I previously wanted domain names to be stored in Unicode in the database. Now I want them
|
||||||
|
# to be in IDNA. Affects aliases only.
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/users.sqlite"))
|
||||||
|
|
||||||
|
# Get existing alias source addresses.
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT source FROM aliases')
|
||||||
|
aliases = [ row[0] for row in c.fetchall() ]
|
||||||
|
|
||||||
|
# Update to IDNA-encoded domains.
|
||||||
|
for email in aliases:
|
||||||
|
try:
|
||||||
|
localpart, domainpart = email.split("@")
|
||||||
|
domainpart = domainpart.encode("idna").decode("ascii")
|
||||||
|
newemail = localpart + "@" + domainpart
|
||||||
|
if newemail != email:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("UPDATE aliases SET source=? WHERE source=?", (newemail, email))
|
||||||
|
if c.rowcount != 1: raise ValueError("Alias not found.")
|
||||||
|
print("Updated alias", email, "to", newemail)
|
||||||
|
except Exception as e:
|
||||||
|
print("Error updating IDNA alias", email, e)
|
||||||
|
|
||||||
|
# Save.
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def get_current_migration():
|
def get_current_migration():
|
||||||
ver = 0
|
ver = 0
|
||||||
while True:
|
while True:
|
||||||
@@ -77,13 +113,22 @@ def run_migrations():
|
|||||||
env = load_environment()
|
env = load_environment()
|
||||||
|
|
||||||
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
|
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
|
||||||
|
migration_id = None
|
||||||
if os.path.exists(migration_id_file):
|
if os.path.exists(migration_id_file):
|
||||||
with open(migration_id_file) as f:
|
with open(migration_id_file) as f:
|
||||||
ourver = int(f.read().strip())
|
migration_id = f.read().strip();
|
||||||
else:
|
|
||||||
|
if migration_id is None:
|
||||||
# Load the legacy location of the migration ID. We'll drop support
|
# Load the legacy location of the migration ID. We'll drop support
|
||||||
# for this eventually.
|
# for this eventually.
|
||||||
ourver = int(env.get("MIGRATIONID", "0"))
|
migration_id = env.get("MIGRATIONID")
|
||||||
|
|
||||||
|
if migration_id is None:
|
||||||
|
print()
|
||||||
|
print("%s file doesn't exists. Skipping migration..." % (migration_id_file,))
|
||||||
|
return
|
||||||
|
|
||||||
|
ourver = int(migration_id)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
next_ver = (ourver + 1)
|
next_ver = (ourver + 1)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Install the 'host', 'sed', and and 'nc' tools. This script is run before
|
# Install the 'host', 'sed', and and 'nc' tools. This script is run before
|
||||||
# the rest of the system setup so we may not yet have things installed.
|
# the rest of the system setup so we may not yet have things installed.
|
||||||
hide_output apt-get -y install bind9-host sed netcat-openbsd
|
apt_get_quiet install bind9-host sed netcat-openbsd
|
||||||
|
|
||||||
# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
|
# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
|
||||||
# The user might have chosen a name that was previously in use by a spammer
|
# The user might have chosen a name that was previously in use by a spammer
|
||||||
|
|||||||
@@ -15,18 +15,49 @@ apt_install \
|
|||||||
apt-get purge -qq -y owncloud*
|
apt-get purge -qq -y owncloud*
|
||||||
|
|
||||||
# Install ownCloud from source of this version:
|
# Install ownCloud from source of this version:
|
||||||
owncloud_ver=7.0.2
|
owncloud_ver=8.0.2
|
||||||
|
owncloud_hash=a4d1fc44bc40af87948458ae8f60ee427ecd9560
|
||||||
|
|
||||||
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
||||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
if [ ! -d /usr/local/lib/owncloud/ ] \
|
||||||
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
||||||
|
|
||||||
|
# Clear out the existing ownCloud.
|
||||||
|
rm -f /tmp/owncloud-config.php
|
||||||
|
if [ ! -d /usr/local/lib/owncloud/ ]; then
|
||||||
echo installing ownCloud...
|
echo installing ownCloud...
|
||||||
rm -f /tmp/owncloud.zip
|
else
|
||||||
wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-$owncloud_ver.zip
|
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
|
||||||
|
cp /usr/local/lib/owncloud/config/config.php /tmp/owncloud-config.php
|
||||||
|
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download and extract ownCloud.
|
||||||
|
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
|
||||||
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
||||||
hide_output php /usr/local/lib/owncloud/occ upgrade #if OC is up-to-date it wont matter
|
|
||||||
rm -f /tmp/owncloud.zip
|
rm -f /tmp/owncloud.zip
|
||||||
|
|
||||||
|
# The two apps we actually want are not in ownCloud core. Clone them from
|
||||||
|
# their github repositories.
|
||||||
|
mkdir -p /usr/local/lib/owncloud/apps
|
||||||
|
git_clone https://github.com/owncloud/contacts v$owncloud_ver '' /usr/local/lib/owncloud/apps/contacts
|
||||||
|
git_clone https://github.com/owncloud/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
|
||||||
|
|
||||||
|
# Fix weird permissions.
|
||||||
|
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
||||||
|
|
||||||
|
# Restore configuration file if we're doing an upgrade.
|
||||||
|
if [ -f /tmp/owncloud-config.php ]; then
|
||||||
|
mv /tmp/owncloud-config.php /usr/local/lib/owncloud/config/config.php
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure permissions are correct or the upgrade step won't run.
|
||||||
|
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
||||||
|
# that error.
|
||||||
|
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||||
|
|
||||||
|
# Run the upgrade script (if ownCloud is already up-to-date it wont matter).
|
||||||
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Configuring ownCloud
|
# ### Configuring ownCloud
|
||||||
@@ -105,9 +136,12 @@ fi
|
|||||||
|
|
||||||
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
||||||
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
||||||
# user_external is what allows ownCloud to use IMAP for login.
|
# user_external is what allows ownCloud to use IMAP for login. The contacts
|
||||||
hide_output php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
|
# and calendar apps are the extensions we really care about here.
|
||||||
hide_output php /usr/local/lib/owncloud/console.php app:enable user_external
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
|
||||||
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable user_external
|
||||||
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable contacts
|
||||||
|
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar
|
||||||
|
|
||||||
# Set PHP FPM values to support large file uploads
|
# Set PHP FPM values to support large file uploads
|
||||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "U
|
|||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we have enough memory. Skip the check if we appear to be
|
# Check that we have enough memory.
|
||||||
# running inside of Vagrant, because that's really just for testing.
|
#
|
||||||
|
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 KB,
|
||||||
|
# which is 750000 kibibytes.
|
||||||
|
#
|
||||||
|
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
||||||
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
||||||
if [ $TOTAL_PHYSICAL_MEM -lt 786432 ]; then
|
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
|
||||||
if [ ! -d /vagrant ]; then
|
if [ ! -d /vagrant ]; then
|
||||||
echo "Your Mail-in-a-Box needs more than $TOTAL_PHYSICAL_MEM MB RAM."
|
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
|
||||||
|
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
||||||
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
||||||
|
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ if [ -z "$NONINTERACTIVE" ]; then
|
|||||||
# e.g. if we piped a bootstrapping install script to bash to get started. In that
|
# e.g. if we piped a bootstrapping install script to bash to get started. In that
|
||||||
# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we
|
# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we
|
||||||
# use a shell flag instead. Really supress any output from installing dialog.
|
# use a shell flag instead. Really supress any output from installing dialog.
|
||||||
hide_output apt-get -y install dialog
|
#
|
||||||
|
# Also install depencies needed to validate the email address.
|
||||||
|
echo Installing packages needed for setup...
|
||||||
|
apt_get_quiet install dialog python3 python3-pip || exit 1
|
||||||
|
|
||||||
|
# email_validator is repeated in setup/management.sh
|
||||||
|
hide_output pip3 install "email_validator==0.1.0-rc4" || exit 1
|
||||||
|
|
||||||
message_box "Mail-in-a-Box Installation" \
|
message_box "Mail-in-a-Box Installation" \
|
||||||
"Hello and thanks for deploying a Mail-in-a-Box!
|
"Hello and thanks for deploying a Mail-in-a-Box!
|
||||||
\n\nI'm going to ask you a few questions.
|
\n\nI'm going to ask you a few questions.
|
||||||
|
|||||||
108
setup/spamassassin.sh
Normal file → Executable file
108
setup/spamassassin.sh
Normal file → Executable file
@@ -1,16 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
# Spam filtering with spamassassin via spampd
|
# Spam filtering with spamassassin via spampd
|
||||||
#############################################
|
# -------------------------------------------
|
||||||
|
#
|
||||||
# spampd sits between postfix and dovecot. It takes mail from postfix
|
# spampd sits between postfix and dovecot. It takes mail from postfix
|
||||||
# over the LMTP protocol, runs spamassassin on it, and then passes the
|
# over the LMTP protocol, runs spamassassin on it, and then passes the
|
||||||
# message over LMTP to dovecot for local delivery.
|
# message over LMTP to dovecot for local delivery.
|
||||||
|
#
|
||||||
# In order to move spam automatically into the Spam folder we use the dovecot sieve
|
# In order to move spam automatically into the Spam folder we use the dovecot sieve
|
||||||
# plugin. The tools/mail.py tool creates the necessary sieve script for each mail
|
# plugin.
|
||||||
# user when the mail user is created.
|
|
||||||
|
|
||||||
|
source /etc/mailinabox.conf # get global vars
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
|
||||||
|
# Install packages and basic configuration
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
# Install packages.
|
# Install packages.
|
||||||
apt_install spampd razor pyzor dovecot-antispam
|
apt_install spampd razor pyzor dovecot-antispam
|
||||||
|
|
||||||
@@ -21,25 +25,57 @@ tools/editconf.py /etc/default/spamassassin \
|
|||||||
# Configure pyzor.
|
# Configure pyzor.
|
||||||
hide_output pyzor discover
|
hide_output pyzor discover
|
||||||
|
|
||||||
# Pass messages on to docevot on port 10026.
|
# Configure spampd:
|
||||||
# This is actually the default setting but we don't want to lose track of it.
|
# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't
|
||||||
# We've already configured Dovecot to listen on this port.
|
# want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)
|
||||||
tools/editconf.py /etc/default/spampd DESTPORT=10026
|
# * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which
|
||||||
|
# is Spamassassin (spamc)'s own default. Specified in KBytes.
|
||||||
|
tools/editconf.py /etc/default/spampd \
|
||||||
|
DESTPORT=10026 \
|
||||||
|
ADDOPTS="\"--maxsize=500\""
|
||||||
|
|
||||||
# Enable the Dovecot antispam plugin to detect when a message moves between folders so we can
|
# Spamassassin normally wraps spam as an attachment inside a fresh
|
||||||
# pass it to sa-learn for training. (Be careful if we use multiple plugins later.)
|
# email with a report about the message. This also protects the user
|
||||||
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
|
# from accidentally openening a message with embedded malware.
|
||||||
|
#
|
||||||
|
# It's nice to see what rules caused the message to be marked as spam,
|
||||||
|
# but it's also annoying to get to the original message when it is an
|
||||||
|
# attachment, modern mail clients are safer now and don't load remote
|
||||||
|
# content or execute scripts, and it is probably confusing to most users.
|
||||||
|
#
|
||||||
|
# Tell Spamassassin not to modify the original message except for adding
|
||||||
|
# the X-Spam-Status mail header and related headers.
|
||||||
|
tools/editconf.py /etc/spamassassin/local.cf -s \
|
||||||
|
report_safe=0
|
||||||
|
|
||||||
# When mail is moved in or out of the Dovecot Spam folder, re-train using this script
|
# Bayesean learning
|
||||||
# that sends the mail to spamassassin.
|
# -----------------
|
||||||
# from http://wiki2.dovecot.org/Plugins/Antispam
|
#
|
||||||
cat > /usr/bin/sa-learn-pipe.sh << EOF;
|
# Spamassassin can learn from mail marked as spam or ham, but it needs to be
|
||||||
cat<&0 >> /tmp/sendmail-msg-\$\$.txt
|
# configured. We'll store the learning data in our storage area.
|
||||||
/usr/bin/sa-learn \$* /tmp/sendmail-msg-\$\$.txt > /dev/null
|
#
|
||||||
rm -f /tmp/sendmail-msg-\$\$.txt
|
# These files must be:
|
||||||
exit 0
|
#
|
||||||
EOF
|
# * Writable by sa-learn-pipe script below, which run as the 'mail' user, for manual tagging of mail as spam/ham.
|
||||||
chmod a+x /usr/bin/sa-learn-pipe.sh
|
# * Readable by the spampd process ('spampd' user) during mail filtering.
|
||||||
|
# * Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.
|
||||||
|
#
|
||||||
|
# We'll have these files owned by spampd and grant access to the other two processes.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/spamassassin/local.cf -s \
|
||||||
|
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes
|
||||||
|
|
||||||
|
mkdir -p $STORAGE_ROOT/mail/spamassassin
|
||||||
|
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
|
||||||
|
|
||||||
|
# To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll
|
||||||
|
# use the Dovecot antispam plugin to detect the message move operation and execute
|
||||||
|
# a shell script that invokes learning.
|
||||||
|
|
||||||
|
# Enable the Dovecot antispam plugin.
|
||||||
|
# (Be careful if we use multiple plugins later.) #NODOC
|
||||||
|
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
|
||||||
|
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-pop3.conf
|
||||||
|
|
||||||
# Configure the antispam plugin to call sa-learn-pipe.sh.
|
# Configure the antispam plugin to call sa-learn-pipe.sh.
|
||||||
cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
|
cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
|
||||||
@@ -47,12 +83,36 @@ plugin {
|
|||||||
antispam_backend = pipe
|
antispam_backend = pipe
|
||||||
antispam_spam_pattern_ignorecase = SPAM
|
antispam_spam_pattern_ignorecase = SPAM
|
||||||
antispam_allow_append_to_spam = yes
|
antispam_allow_append_to_spam = yes
|
||||||
antispam_pipe_program_spam_args = /usr/bin/sa-learn-pipe.sh;--spam
|
antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
|
||||||
antispam_pipe_program_notspam_args = /usr/bin/sa-learn-pipe.sh;--ham
|
antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
|
||||||
antispam_pipe_program = /bin/bash
|
antispam_pipe_program = /bin/bash
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Have Dovecot run its mail process with a supplementary group (the spampd group)
|
||||||
|
# so that it can access the learning files.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||||
|
mail_access_groups=spampd
|
||||||
|
|
||||||
|
# Here's the script that the antispam plugin executes. It spools the message into
|
||||||
|
# a temporary file and then runs sa-learn on it.
|
||||||
|
# from http://wiki2.dovecot.org/Plugins/Antispam
|
||||||
|
rm -f /usr/bin/sa-learn-pipe.sh # legacy location #NODOC
|
||||||
|
cat > /usr/local/bin/sa-learn-pipe.sh << EOF;
|
||||||
|
cat<&0 >> /tmp/sendmail-msg-\$\$.txt
|
||||||
|
/usr/bin/sa-learn \$* /tmp/sendmail-msg-\$\$.txt > /dev/null
|
||||||
|
rm -f /tmp/sendmail-msg-\$\$.txt
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod a+x /usr/local/bin/sa-learn-pipe.sh
|
||||||
|
|
||||||
|
# Create empty bayes training data (if it doesn't exist). Once the files exist,
|
||||||
|
# ensure they are group-writable so that the Dovecot process has access.
|
||||||
|
sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
|
||||||
|
chmod -R 660 $STORAGE_ROOT/mail/spamassassin
|
||||||
|
chmod 770 $STORAGE_ROOT/mail/spamassassin
|
||||||
|
|
||||||
# Initial training?
|
# Initial training?
|
||||||
# sa-learn --ham storage/mail/mailboxes/*/*/cur/
|
# sa-learn --ham storage/mail/mailboxes/*/*/cur/
|
||||||
# sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/
|
# sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/
|
||||||
|
|||||||
15
setup/ssl.sh
15
setup/ssl.sh
@@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# SSL Certificate
|
# SSL Certificate
|
||||||
# ---------------
|
# ---------------
|
||||||
#
|
|
||||||
# Create a self-signed SSL certificate if one has not yet been created.
|
# Create a self-signed SSL certificate if one has not yet been created.
|
||||||
#
|
#
|
||||||
# The certificate is for PRIMARY_HOSTNAME specifically and is used for:
|
# The certificate is for PRIMARY_HOSTNAME specifically and is used for:
|
||||||
@@ -22,24 +22,31 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
apt_install openssl
|
apt_install openssl
|
||||||
|
|
||||||
mkdir -p $STORAGE_ROOT/ssl
|
mkdir -p $STORAGE_ROOT/ssl
|
||||||
# Generate a new private key if one doesn't already exist.
|
# Generate a new private key.
|
||||||
# Set the umask so the key file is not world-readable.
|
# Set the umask so the key file is not world-readable.
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
|
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
|
||||||
(umask 077; hide_output \
|
(umask 077; hide_output \
|
||||||
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
|
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate a certificate signing request if one doesn't already exist.
|
# Generate a certificate signing request.
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
if [ ! -f $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
||||||
hide_output \
|
hide_output \
|
||||||
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr \
|
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr \
|
||||||
-sha256 -subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
|
-sha256 -subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate a SSL certificate by self-signing if a SSL certificate doesn't yet exist.
|
# Generate a SSL certificate by self-signing.
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
|
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
|
||||||
hide_output \
|
hide_output \
|
||||||
openssl x509 -req -days 365 \
|
openssl x509 -req -days 365 \
|
||||||
-in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem
|
-in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# For nginx and postfix, pre-generate some Diffie-Hellman cipher bits which is
|
||||||
|
# used when a Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
|
||||||
|
# provides Perfect Forward Secrecy. openssl's default is 1024 bits, but we'll
|
||||||
|
# create 2048.
|
||||||
|
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|
||||||
|
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
|
||||||
|
fi
|
||||||
|
|||||||
@@ -6,13 +6,28 @@ source setup/functions.sh # load our functions
|
|||||||
|
|
||||||
# Check system setup: Are we running as root on Ubuntu 14.04 on a
|
# Check system setup: Are we running as root on Ubuntu 14.04 on a
|
||||||
# machine with enough memory? If not, this shows an error and exits.
|
# machine with enough memory? If not, this shows an error and exits.
|
||||||
. setup/preflight.sh
|
source setup/preflight.sh
|
||||||
|
|
||||||
|
# Ensure Python reads/writes files in UTF-8. If the machine
|
||||||
|
# triggers some other locale in Python, like ASCII encoding,
|
||||||
|
# Python may not be able to read/write files. Here and in
|
||||||
|
# the management daemon startup script.
|
||||||
|
|
||||||
|
if [ -z `locale -a | grep en_US.utf8` ]; then
|
||||||
|
# Generate locale if not exists
|
||||||
|
hide_output locale-gen en_US.UTF-8
|
||||||
|
fi
|
||||||
|
|
||||||
|
export LANGUAGE=en_US.UTF-8
|
||||||
|
export LC_ALL=en_US.UTF-8
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
# Recall the last settings used if we're running this a second time.
|
# Recall the last settings used if we're running this a second time.
|
||||||
if [ -f /etc/mailinabox.conf ]; then
|
if [ -f /etc/mailinabox.conf ]; then
|
||||||
# Run any system migrations before proceeding. Since this is a second run,
|
# Run any system migrations before proceeding. Since this is a second run,
|
||||||
# we assume we have Python already installed.
|
# we assume we have Python already installed.
|
||||||
setup/migrate.py --migrate
|
setup/migrate.py --migrate || exit 1
|
||||||
|
|
||||||
# Load the old .conf file to get existing configuration options loaded
|
# Load the old .conf file to get existing configuration options loaded
|
||||||
# into variables with a DEFAULT_ prefix.
|
# into variables with a DEFAULT_ prefix.
|
||||||
@@ -33,7 +48,7 @@ chmod +x /usr/local/bin/mailinabox
|
|||||||
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, PUBLIC_IPV6, and CSR_COUNTRY
|
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, PUBLIC_IPV6, and CSR_COUNTRY
|
||||||
# if values have not already been set in environment variables. When running
|
# if values have not already been set in environment variables. When running
|
||||||
# non-interactively, be sure to set values for all!
|
# non-interactively, be sure to set values for all!
|
||||||
. setup/questions.sh
|
source setup/questions.sh
|
||||||
|
|
||||||
# Automatic configuration, e.g. as used in our Vagrant configuration.
|
# Automatic configuration, e.g. as used in our Vagrant configuration.
|
||||||
if [ "$PUBLIC_IP" = "auto" ]; then
|
if [ "$PUBLIC_IP" = "auto" ]; then
|
||||||
@@ -62,7 +77,7 @@ fi
|
|||||||
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
|
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
|
||||||
echo "Private IPv6 Address: $PRIVATE_IPV6"
|
echo "Private IPv6 Address: $PRIVATE_IPV6"
|
||||||
fi
|
fi
|
||||||
if [ -f /usr/bin/git ]; then
|
if [ -f .git ]; then
|
||||||
echo "Mail-in-a-Box Version: " $(git describe)
|
echo "Mail-in-a-Box Version: " $(git describe)
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
@@ -72,17 +87,37 @@ if [ -z "$SKIP_NETWORK_CHECKS" ]; then
|
|||||||
. setup/network-checks.sh
|
. setup/network-checks.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# For the first time (if the config file (/etc/mailinabox.conf) not exists):
|
||||||
# Create the user named "user-data" and store all persistent user
|
# Create the user named "user-data" and store all persistent user
|
||||||
# data (mailboxes, etc.) in that user's home directory.
|
# data (mailboxes, etc.) in that user's home directory.
|
||||||
|
#
|
||||||
|
# If the config file exists:
|
||||||
|
# Apply the existing configuration options for STORAGE_USER/ROOT
|
||||||
|
if [ -z "$STORAGE_USER" ]; then
|
||||||
|
STORAGE_USER=$([[ -z "$DEFAULT_STORAGE_USER" ]] && echo "user-data" || echo "$DEFAULT_STORAGE_USER")
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$STORAGE_ROOT" ]; then
|
if [ -z "$STORAGE_ROOT" ]; then
|
||||||
STORAGE_USER=user-data
|
STORAGE_ROOT=$([[ -z "$DEFAULT_STORAGE_ROOT" ]] && echo "/home/$STORAGE_USER" || echo "$DEFAULT_STORAGE_ROOT")
|
||||||
if [ ! -d /home/$STORAGE_USER ]; then useradd -m $STORAGE_USER; fi
|
fi
|
||||||
STORAGE_ROOT=/home/$STORAGE_USER
|
|
||||||
|
# Create the STORAGE_USER if it not exists
|
||||||
|
if ! id -u $STORAGE_USER >/dev/null 2>&1; then
|
||||||
|
useradd -m $STORAGE_USER
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the STORAGE_ROOT if it not exists
|
||||||
|
if [ ! -d $STORAGE_ROOT ]; then
|
||||||
mkdir -p $STORAGE_ROOT
|
mkdir -p $STORAGE_ROOT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create mailinabox.version file if not exists
|
||||||
|
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
||||||
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Save the global options in /etc/mailinabox.conf so that standalone
|
# Save the global options in /etc/mailinabox.conf so that standalone
|
||||||
# tools know where to look for data.
|
# tools know where to look for data.
|
||||||
cat > /etc/mailinabox.conf << EOF;
|
cat > /etc/mailinabox.conf << EOF;
|
||||||
@@ -97,27 +132,30 @@ CSR_COUNTRY=$CSR_COUNTRY
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Start service configuration.
|
# Start service configuration.
|
||||||
. setup/system.sh
|
source setup/system.sh
|
||||||
. setup/ssl.sh
|
source setup/ssl.sh
|
||||||
. setup/dns.sh
|
source setup/dns.sh
|
||||||
. setup/mail-postfix.sh
|
source setup/mail-postfix.sh
|
||||||
. setup/mail-dovecot.sh
|
source setup/mail-dovecot.sh
|
||||||
. setup/mail-users.sh
|
source setup/mail-users.sh
|
||||||
. setup/dkim.sh
|
source setup/dkim.sh
|
||||||
. setup/spamassassin.sh
|
source setup/spamassassin.sh
|
||||||
. setup/web.sh
|
source setup/web.sh
|
||||||
. setup/webmail.sh
|
source setup/webmail.sh
|
||||||
. setup/owncloud.sh
|
source setup/owncloud.sh
|
||||||
. setup/zpush.sh
|
source setup/zpush.sh
|
||||||
. setup/management.sh
|
source setup/management.sh
|
||||||
|
|
||||||
# Write the DNS and nginx configuration files.
|
# Ping the management daemon to write the DNS and nginx configuration files.
|
||||||
sleep 5 # wait for the daemon to start
|
while [ ! -f /var/lib/mailinabox/api.key ]; do
|
||||||
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update
|
echo Waiting for the Mail-in-a-Box management daemon to start...
|
||||||
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update
|
sleep 2
|
||||||
|
done
|
||||||
|
tools/dns_update
|
||||||
|
tools/web_update
|
||||||
|
|
||||||
# If there aren't any mail users yet, create one.
|
# If there aren't any mail users yet, create one.
|
||||||
. setup/firstuser.sh
|
source setup/firstuser.sh
|
||||||
|
|
||||||
# Done.
|
# Done.
|
||||||
echo
|
echo
|
||||||
@@ -145,4 +183,3 @@ openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
|||||||
echo
|
echo
|
||||||
echo Then you can confirm the security exception and continue.
|
echo Then you can confirm the security exception and continue.
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
|
||||||
# Base system configuration
|
# Basic System Configuration
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
# ### Base packages
|
# ### Install Packages
|
||||||
|
|
||||||
# Update system packages:
|
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
||||||
|
|
||||||
echo Updating system packages...
|
echo Updating system packages...
|
||||||
hide_output apt-get update
|
hide_output apt-get update
|
||||||
hide_output apt-get -y upgrade
|
apt_get_quiet upgrade
|
||||||
|
|
||||||
# Install basic utilities.
|
# Install basic utilities.
|
||||||
#
|
#
|
||||||
@@ -17,12 +17,17 @@ hide_output apt-get -y upgrade
|
|||||||
# when generating random numbers for private keys (e.g. during
|
# when generating random numbers for private keys (e.g. during
|
||||||
# ldns-keygen).
|
# ldns-keygen).
|
||||||
# * unattended-upgrades: Apt tool to install security updates automatically.
|
# * unattended-upgrades: Apt tool to install security updates automatically.
|
||||||
|
# * cron: Runs background processes periodically.
|
||||||
# * ntp: keeps the system time correct
|
# * ntp: keeps the system time correct
|
||||||
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
||||||
|
# * git: we install some things directly from github
|
||||||
|
# * sudo: allows privileged users to execute commands as root without being root
|
||||||
|
# * coreutils: includes `nproc` tool to report number of processors
|
||||||
|
# * bc: allows us to do math to compute sane defaults
|
||||||
|
|
||||||
apt_install python3 python3-dev python3-pip \
|
apt_install python3 python3-dev python3-pip \
|
||||||
wget curl \
|
wget curl git sudo coreutils bc \
|
||||||
haveged unattended-upgrades ntp fail2ban
|
haveged unattended-upgrades cron ntp fail2ban
|
||||||
|
|
||||||
# Allow apt to install system updates automatically every day.
|
# Allow apt to install system updates automatically every day.
|
||||||
|
|
||||||
@@ -35,8 +40,6 @@ EOF
|
|||||||
|
|
||||||
# ### Firewall
|
# ### Firewall
|
||||||
|
|
||||||
# Turn on the firewall.
|
|
||||||
#
|
|
||||||
# Various virtualized environments like Docker and some VPSs don't provide #NODOC
|
# Various virtualized environments like Docker and some VPSs don't provide #NODOC
|
||||||
# a kernel that supports iptables. To avoid error-like output in these cases, #NODOC
|
# a kernel that supports iptables. To avoid error-like output in these cases, #NODOC
|
||||||
# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC
|
# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC
|
||||||
@@ -47,15 +50,15 @@ if [ -z "$DISABLE_FIREWALL" ]; then
|
|||||||
# Allow incoming connections to SSH.
|
# Allow incoming connections to SSH.
|
||||||
ufw_allow ssh;
|
ufw_allow ssh;
|
||||||
|
|
||||||
# ssh might be running on an alternate port. Use sshd -T to dump sshd's
|
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
||||||
# settings, find the port it is supposedly running on, and open that port
|
# settings, find the port it is supposedly running on, and open that port #NODOC
|
||||||
# too.
|
# too. #NODOC
|
||||||
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //")
|
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
|
||||||
if [ ! -z "$SSH_PORT" ]; then
|
if [ ! -z "$SSH_PORT" ]; then
|
||||||
if [ "$SSH_PORT" != "22" ]; then
|
if [ "$SSH_PORT" != "22" ]; then
|
||||||
|
|
||||||
echo Opening alternate SSH port $SSH_PORT.
|
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
||||||
ufw_allow $SSH_PORT;
|
ufw_allow $SSH_PORT #NODOC
|
||||||
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -104,3 +107,11 @@ fi
|
|||||||
|
|
||||||
restart_service bind9
|
restart_service bind9
|
||||||
restart_service resolvconf
|
restart_service resolvconf
|
||||||
|
|
||||||
|
# ### Fail2Ban Service
|
||||||
|
|
||||||
|
# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh
|
||||||
|
cp conf/fail2ban/jail.local /etc/fail2ban/jail.local
|
||||||
|
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf
|
||||||
|
|
||||||
|
restart_service fail2ban
|
||||||
|
|||||||
79
setup/web.sh
79
setup/web.sh
@@ -5,20 +5,34 @@
|
|||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
|
# Some Ubuntu images start off with Apache. Remove it since we
|
||||||
|
# will use nginx. Use autoremove to remove any Apache depenencies.
|
||||||
|
if [ -f /usr/sbin/apache2 ]; then
|
||||||
|
echo Removing apache...
|
||||||
|
hide_output apt-get -y purge apache2 apache2-*
|
||||||
|
hide_output apt-get -y --purge autoremove
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install nginx and a PHP FastCGI daemon.
|
||||||
|
#
|
||||||
|
# Turn off nginx's default website.
|
||||||
|
|
||||||
apt_install nginx php5-fpm
|
apt_install nginx php5-fpm
|
||||||
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
# copy in a nginx configuration file for common and best-practices
|
# Copy in a nginx configuration file for common and best-practices
|
||||||
# SSL settings from @konklone
|
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
|
||||||
cp conf/nginx-ssl.conf /etc/nginx/nginx-ssl.conf
|
# the DH params.
|
||||||
|
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
||||||
|
conf/nginx-ssl.conf > /etc/nginx/nginx-ssl.conf
|
||||||
|
|
||||||
# Fix some nginx defaults.
|
# Fix some nginx defaults.
|
||||||
# The server_names_hash_bucket_size seems to prevent long domain names?
|
# The server_names_hash_bucket_size seems to prevent long domain names?
|
||||||
tools/editconf.py /etc/nginx/nginx.conf -s \
|
tools/editconf.py /etc/nginx/nginx.conf -s \
|
||||||
server_names_hash_bucket_size="64;"
|
server_names_hash_bucket_size="64;"
|
||||||
|
|
||||||
# Bump up max_children to support more concurrent connections
|
# Bump up PHP's max_children to support more concurrent connections
|
||||||
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
||||||
pm.max_children=8
|
pm.max_children=8
|
||||||
|
|
||||||
@@ -26,38 +40,51 @@ tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
|||||||
# since it depends on what domains we're serving, which we don't know
|
# since it depends on what domains we're serving, which we don't know
|
||||||
# until mail accounts have been created.
|
# until mail accounts have been created.
|
||||||
|
|
||||||
|
# Create the iOS/OS X Mobile Configuration file which is exposed via the
|
||||||
|
# nginx configuration at /mailinabox-mobileconfig.
|
||||||
|
mkdir -p /var/lib/mailinabox
|
||||||
|
chmod a+rx /var/lib/mailinabox
|
||||||
|
cat conf/ios-profile.xml \
|
||||||
|
| sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \
|
||||||
|
| sed "s/UUID1/$(cat /proc/sys/kernel/random/uuid)/" \
|
||||||
|
| sed "s/UUID2/$(cat /proc/sys/kernel/random/uuid)/" \
|
||||||
|
| sed "s/UUID3/$(cat /proc/sys/kernel/random/uuid)/" \
|
||||||
|
| sed "s/UUID4/$(cat /proc/sys/kernel/random/uuid)/" \
|
||||||
|
> /var/lib/mailinabox/mobileconfig.xml
|
||||||
|
chmod a+r /var/lib/mailinabox/mobileconfig.xml
|
||||||
|
|
||||||
|
# Create the Mozilla Auto-configuration file which is exposed via the
|
||||||
|
# nginx configuration at /.well-known/autoconfig/mail/config-v1.1.xml.
|
||||||
|
# The format of the file is documented at:
|
||||||
|
# https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
|
||||||
|
# and https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration/FileFormat/HowTo.
|
||||||
|
cat conf/mozilla-autoconfig.xml \
|
||||||
|
| sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \
|
||||||
|
> /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
|
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
|
|
||||||
# make a default homepage
|
# make a default homepage
|
||||||
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration
|
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
|
||||||
mkdir -p $STORAGE_ROOT/www/default
|
mkdir -p $STORAGE_ROOT/www/default
|
||||||
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
|
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
|
||||||
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
|
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
|
||||||
fi
|
fi
|
||||||
chown -R $STORAGE_USER $STORAGE_ROOT/www
|
chown -R $STORAGE_USER $STORAGE_ROOT/www
|
||||||
|
|
||||||
# We previously installed a custom init script to start the PHP FastCGI daemon.
|
# We previously installed a custom init script to start the PHP FastCGI daemon. #NODOC
|
||||||
# Remove it now that we're using php5-fpm.
|
# Remove it now that we're using php5-fpm. #NODOC
|
||||||
if [ -L /etc/init.d/php-fastcgi ]; then
|
if [ -L /etc/init.d/php-fastcgi ]; then
|
||||||
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..."
|
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..." #NODOC
|
||||||
rm -f /etc/init.d/php-fastcgi
|
rm -f /etc/init.d/php-fastcgi #NODOC
|
||||||
hide_output update-rc.d php-fastcgi remove
|
hide_output update-rc.d php-fastcgi remove #NODOC
|
||||||
apt-get -y purge php5-cgi
|
apt-get -y purge php5-cgi #NODOC
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Put our webfinger script into a well-known location.
|
# Remove obsoleted scripts. #NODOC
|
||||||
for f in webfinger; do
|
# exchange-autodiscover is now handled by Z-Push. #NODOC
|
||||||
cp tools/$f.php /usr/local/bin/mailinabox-$f.php
|
for f in webfinger exchange-autodiscover; do #NODOC
|
||||||
chown www-data.www-data /usr/local/bin/mailinabox-$f.php
|
rm -f /usr/local/bin/mailinabox-$f.php #NODOC
|
||||||
done
|
done #NODOC
|
||||||
|
|
||||||
# Remove obsoleted scripts.
|
|
||||||
# exchange-autodiscover is now handled by Z-Push.
|
|
||||||
for f in exchange-autodiscover; do
|
|
||||||
rm -f /usr/local/bin/mailinabox-$f.php
|
|
||||||
done
|
|
||||||
|
|
||||||
# Make some space for users to customize their webfinger responses.
|
|
||||||
mkdir -p $STORAGE_ROOT/webfinger/acct;
|
|
||||||
chown -R $STORAGE_USER $STORAGE_ROOT/webfinger
|
|
||||||
|
|
||||||
# Start services.
|
# Start services.
|
||||||
restart_service nginx
|
restart_service nginx
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
# Webmail with Roundcube
|
# Webmail with Roundcube
|
||||||
# ----------------------
|
# ----------------------
|
||||||
|
|
||||||
@@ -23,29 +24,42 @@ apt_install \
|
|||||||
php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-crypt-gpg php5-gd php5-pspell \
|
php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-crypt-gpg php5-gd php5-pspell \
|
||||||
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
||||||
|
|
||||||
# We used to install Roundcube from Ubuntu, without triggering the dependencies
|
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
|
||||||
# on Apache and MySQL, by downloading the debs and installing them manually.
|
# on Apache and MySQL, by downloading the debs and installing them manually. #NODOC
|
||||||
# Now that we're beyond that, get rid of those debs before installing from source.
|
# Now that we're beyond that, get rid of those debs before installing from source. #NODOC
|
||||||
apt-get purge -qq -y roundcube*
|
apt-get purge -qq -y roundcube* #NODOC
|
||||||
|
|
||||||
# Install Roundcube from source if it is not already present or if it is out of date.
|
# Install Roundcube from source if it is not already present or if it is out of date.
|
||||||
VERSION=1.0.2
|
# Combine the Roundcube version number with the commit hash of vacation_sieve to track
|
||||||
|
# whether we have the latest version.
|
||||||
|
VERSION=1.1.1
|
||||||
|
HASH=08222f382a8dd89bba7dbbad595f48443bec0aa2
|
||||||
|
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
|
||||||
needs_update=0 #NODOC
|
needs_update=0 #NODOC
|
||||||
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
||||||
# not installed yet
|
# not installed yet #NODOC
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
elif [[ $VERSION != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
elif [[ "$VERSION:$VACATION_SIEVE_VERSION" != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
||||||
# checks if the version is what we want
|
# checks if the version is what we want
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
echo installing roudcube webmail $VERSION...
|
# install roundcube
|
||||||
rm -f /tmp/roundcube.tgz
|
echo installing Roundcube webmail $VERSION...
|
||||||
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/1.0.2/roundcubemail-$VERSION.tar.gz
|
wget_verify \
|
||||||
|
http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz \
|
||||||
|
$HASH \
|
||||||
|
/tmp/roundcube.tgz
|
||||||
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
|
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
|
||||||
|
rm -rf /usr/local/lib/roundcubemail
|
||||||
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
|
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
|
||||||
rm -f /tmp/roundcube.tgz
|
rm -f /tmp/roundcube.tgz
|
||||||
echo $VERSION > /usr/local/lib/roundcubemail/version
|
|
||||||
|
# install roundcube autoreply/vacation plugin
|
||||||
|
git_clone https://github.com/arodier/Roundcube-Plugins.git $VACATION_SIEVE_VERSION plugins/vacation_sieve /usr/local/lib/roundcubemail/plugins/vacation_sieve
|
||||||
|
|
||||||
|
# record the version we've installed
|
||||||
|
echo $VERSION:$VACATION_SIEVE_VERSION > /usr/local/lib/roundcubemail/version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Configuring Roundcube
|
# ### Configuring Roundcube
|
||||||
@@ -77,7 +91,7 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
|||||||
\$config['support_url'] = 'https://mailinabox.email/';
|
\$config['support_url'] = 'https://mailinabox.email/';
|
||||||
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
||||||
\$config['des_key'] = '$SECRET_KEY';
|
\$config['des_key'] = '$SECRET_KEY';
|
||||||
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve');
|
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve');
|
||||||
\$config['skin'] = 'classic';
|
\$config['skin'] = 'classic';
|
||||||
\$config['login_autocomplete'] = 2;
|
\$config['login_autocomplete'] = 2;
|
||||||
\$config['password_charset'] = 'UTF-8';
|
\$config['password_charset'] = 'UTF-8';
|
||||||
@@ -85,6 +99,26 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
|||||||
?>
|
?>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Configure vaction_sieve.
|
||||||
|
cat > /usr/local/lib/roundcubemail/plugins/vacation_sieve/config.inc.php <<EOF;
|
||||||
|
<?php
|
||||||
|
/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */
|
||||||
|
\$rcmail_config['vacation_sieve'] = array(
|
||||||
|
'date_format' => 'd/m/Y',
|
||||||
|
'working_hours' => array(8,18),
|
||||||
|
'msg_format' => 'text',
|
||||||
|
'logon_transform' => array('#([a-z])[a-z]+(\.|\s)([a-z])#i', '\$1\$3'),
|
||||||
|
'transfer' => array(
|
||||||
|
'mode' => 'managesieve',
|
||||||
|
'ms_activate_script' => true,
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => '4190',
|
||||||
|
'usetls' => false,
|
||||||
|
'path' => 'vacation',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
EOF
|
||||||
|
|
||||||
# Create writable directories.
|
# Create writable directories.
|
||||||
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Z-Push: The Microsoft Exchange protocol server
|
# Z-Push: The Microsoft Exchange protocol server
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
#
|
#
|
||||||
# Mostly for use on iOS which doesn't support IMAP.
|
# Mostly for use on iOS which doesn't support IMAP IDLE.
|
||||||
#
|
#
|
||||||
# Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache
|
# Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache
|
||||||
# so we won't install it that way.
|
# so we won't install it that way.
|
||||||
@@ -30,17 +30,11 @@ elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
|
|||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
rm -rf /usr/local/lib/z-push
|
|
||||||
rm -f /tmp/zpush-repo
|
|
||||||
echo installing z-push \(fmbiete fork\)...
|
echo installing z-push \(fmbiete fork\)...
|
||||||
git clone -q https://github.com/fmbiete/Z-Push-contrib /tmp/zpush-repo
|
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
|
||||||
(cd /tmp/zpush-repo/; git checkout -q $TARGETHASH;)
|
|
||||||
rm -rf /tmp/zpush-repo/.git
|
|
||||||
mv /tmp/zpush-repo /usr/local/lib/z-push
|
|
||||||
rm -f /usr/sbin/z-push-{admin,top}
|
rm -f /usr/sbin/z-push-{admin,top}
|
||||||
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
|
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
|
||||||
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
|
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
|
||||||
rm -f /tmp/zpush-repo
|
|
||||||
echo $TARGETHASH > /usr/local/lib/z-push/version
|
echo $TARGETHASH > /usr/local/lib/z-push/version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ while settings[0][0] == "-" and settings[0] != "--":
|
|||||||
print("Invalid option.")
|
print("Invalid option.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# sanity check command line
|
||||||
|
for setting in settings:
|
||||||
|
try:
|
||||||
|
name, value = setting.split("=", 1)
|
||||||
|
except:
|
||||||
|
import subprocess
|
||||||
|
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
|
||||||
|
|
||||||
# create the new config file in memory
|
# create the new config file in memory
|
||||||
|
|
||||||
found = set()
|
found = set()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import sys, getpass, urllib.request, urllib.error, json
|
import sys, getpass, urllib.request, urllib.error, json, re
|
||||||
|
|
||||||
def mgmt(cmd, data=None, is_json=False):
|
def mgmt(cmd, data=None, is_json=False):
|
||||||
mgmt_uri = 'http://localhost:10222'
|
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||||
|
mgmt_uri = 'http://127.0.0.1:10222'
|
||||||
|
|
||||||
setup_key_auth(mgmt_uri)
|
setup_key_auth(mgmt_uri)
|
||||||
|
|
||||||
@@ -27,12 +28,19 @@ def mgmt(cmd, data=None, is_json=False):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
def read_password():
|
def read_password():
|
||||||
|
while True:
|
||||||
first = getpass.getpass('password: ')
|
first = getpass.getpass('password: ')
|
||||||
|
if len(first) < 4:
|
||||||
|
print("Passwords must be at least four characters.")
|
||||||
|
continue
|
||||||
|
if re.search(r'[\s]', first):
|
||||||
|
print("Passwords cannot contain spaces.")
|
||||||
|
continue
|
||||||
second = getpass.getpass(' (again): ')
|
second = getpass.getpass(' (again): ')
|
||||||
while first != second:
|
if first != second:
|
||||||
print('Passwords not the same. Try again.')
|
print("Passwords not the same. Try again.")
|
||||||
first = getpass.getpass('password: ')
|
continue
|
||||||
second = getpass.getpass(' (again): ')
|
break
|
||||||
return first
|
return first
|
||||||
|
|
||||||
def setup_key_auth(mgmt_uri):
|
def setup_key_auth(mgmt_uri):
|
||||||
@@ -67,7 +75,8 @@ if len(sys.argv) < 2:
|
|||||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||||
users = mgmt("/mail/users?format=json", is_json=True)
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
for user in users:
|
for domain in users:
|
||||||
|
for user in domain["users"]:
|
||||||
if user['status'] == 'inactive': continue
|
if user['status'] == 'inactive': continue
|
||||||
print(user['email'], end='')
|
print(user['email'], end='')
|
||||||
if "admin" in user['privileges']:
|
if "admin" in user['privileges']:
|
||||||
@@ -102,7 +111,8 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and
|
|||||||
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
||||||
# Dump a list of admin users.
|
# Dump a list of admin users.
|
||||||
users = mgmt("/mail/users?format=json", is_json=True)
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
for user in users:
|
for domain in users:
|
||||||
|
for user in domain["users"]:
|
||||||
if "admin" in user['privileges']:
|
if "admin" in user['privileges']:
|
||||||
print(user['email'])
|
print(user['email'])
|
||||||
|
|
||||||
|
|||||||
56
tools/parse-nginx-log-bootstrap-accesses.py
Executable file
56
tools/parse-nginx-log-bootstrap-accesses.py
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
#
|
||||||
|
# This is a tool Josh uses on his box serving mailinabox.email to parse the nginx
|
||||||
|
# access log to see how many people are installing Mail-in-a-Box each day, by
|
||||||
|
# looking at accesses to the bootstrap.sh script.
|
||||||
|
|
||||||
|
import re, glob, gzip, os.path, json
|
||||||
|
import dateutil.parser
|
||||||
|
|
||||||
|
outfn = "/home/user-data/www/mailinabox.email/install-stats.json"
|
||||||
|
|
||||||
|
# Make a unique list of (date, ip address) pairs so we don't double-count
|
||||||
|
# accesses that are for the same install.
|
||||||
|
accesses = set()
|
||||||
|
|
||||||
|
# Scan the current and rotated access logs.
|
||||||
|
for fn in glob.glob("/var/log/nginx/access.log*"):
|
||||||
|
# Gunzip if necessary.
|
||||||
|
if fn.endswith(".gz"):
|
||||||
|
f = gzip.open(fn)
|
||||||
|
else:
|
||||||
|
f = open(fn, "rb")
|
||||||
|
|
||||||
|
# Loop through the lines in the access log.
|
||||||
|
with f:
|
||||||
|
for line in f:
|
||||||
|
# Find lines that are GETs on /bootstrap.sh by either curl or wget.
|
||||||
|
m = re.match(rb"(?P<ip>\S+) - - \[(?P<date>.*?)\] \"GET /bootstrap.sh HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
|
||||||
|
if m:
|
||||||
|
date, time = m.group("date").decode("ascii").split(":", 1)
|
||||||
|
date = dateutil.parser.parse(date).date().isoformat()
|
||||||
|
ip = m.group("ip").decode("ascii")
|
||||||
|
accesses.add( (date, ip) )
|
||||||
|
|
||||||
|
# Aggregate by date.
|
||||||
|
by_date = { }
|
||||||
|
for date, ip in accesses:
|
||||||
|
by_date[date] = by_date.get(date, 0) + 1
|
||||||
|
|
||||||
|
# Since logs are rotated, store the statistics permanently in a JSON file.
|
||||||
|
# Load in the stats from an existing file.
|
||||||
|
if os.path.exists(outfn):
|
||||||
|
existing_data = json.load(open(outfn))
|
||||||
|
for date, count in existing_data:
|
||||||
|
if date not in by_date:
|
||||||
|
by_date[date] = count
|
||||||
|
|
||||||
|
# Turn into a list rather than a dict structure to make it ordered.
|
||||||
|
by_date = sorted(by_date.items())
|
||||||
|
|
||||||
|
# Pop the last one because today's stats are incomplete.
|
||||||
|
by_date.pop(-1)
|
||||||
|
|
||||||
|
# Write out.
|
||||||
|
with open(outfn, "w") as f:
|
||||||
|
json.dump(by_date, f, sort_keys=True, indent=True)
|
||||||
@@ -30,33 +30,78 @@ def generate_documentation():
|
|||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
h2, h3 {
|
h2, h3 {
|
||||||
margin-bottom: 1em;
|
margin-top: .25em;
|
||||||
|
margin-bottom: .75em;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
.intro p {
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin-bottom: .33em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourcefile {
|
||||||
|
padding-top: 1.5em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
font-size: 90%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sourcefile a {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions .row.contd {
|
||||||
|
border-top: 1px solid #E0E0E0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
padding-top: 1em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
.terminal {
|
||||||
|
background-color: #EEE;
|
||||||
|
padding-top: 1em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 1em 1em 1.5em 1em;
|
|
||||||
color: black;
|
color: black;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.write-to {
|
div.write-to {
|
||||||
margin: 1em;
|
margin: 0 0 1em .5em;
|
||||||
border: 1px solid #999;
|
|
||||||
}
|
}
|
||||||
div.write-to p {
|
div.write-to p {
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
div.write-to .filename {
|
div.write-to .filename {
|
||||||
background-color: #EEE;
|
padding: .25em .5em;
|
||||||
padding: .5em;
|
background-color: #666;
|
||||||
|
color: white;
|
||||||
|
font-family: monospace;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
div.write-to .filename span {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
div.write-to pre {
|
div.write-to pre {
|
||||||
padding: .5em;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: .5em;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.shell > div:before {
|
pre.shell > div:before {
|
||||||
@@ -67,11 +112,15 @@ def generate_documentation():
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row intro">
|
||||||
<div class="col-xs-12">
|
<div class="col-xs-12">
|
||||||
<h1>Build Your Own Mail Server From Scratch</h1>
|
<h1>Build Your Own Mail Server From Scratch</h1>
|
||||||
<p>Here’s how you can build your own mail server from scratch. This document is generated automatically from our setup script.</p>
|
<p>Here’s how you can build your own mail server from scratch.</p>
|
||||||
|
<p>This document is generated automatically from <a href="https://mailinabox.email">Mail-in-a-Box</a>’s setup script <a href="https://github.com/mail-in-a-box/mailinabox">source code</a>.</p>
|
||||||
<hr>
|
<hr>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container instructions">
|
||||||
""")
|
""")
|
||||||
|
|
||||||
parser = Source.parser()
|
parser = Source.parser()
|
||||||
@@ -80,7 +129,7 @@ def generate_documentation():
|
|||||||
fn = parser.parse_string(line).filename()
|
fn = parser.parse_string(line).filename()
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
if fn in ("setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
|
if fn in ("setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -91,6 +140,13 @@ def generate_documentation():
|
|||||||
print("""
|
print("""
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
|
||||||
|
<script>
|
||||||
|
$(function() {
|
||||||
|
$('.terminal').each(function() {
|
||||||
|
$(this).outerHeight( $(this).parent().innerHeight() );
|
||||||
|
});
|
||||||
|
})
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""")
|
""")
|
||||||
@@ -101,8 +157,13 @@ class HashBang(Grammar):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def strip_indent(s):
|
def strip_indent(s):
|
||||||
|
s = s.replace("\t", " ")
|
||||||
lines = s.split("\n")
|
lines = s.split("\n")
|
||||||
|
try:
|
||||||
min_indent = min(len(re.match(r"\s*", line).group(0)) for line in lines if len(line) > 0)
|
min_indent = min(len(re.match(r"\s*", line).group(0)) for line in lines if len(line) > 0)
|
||||||
|
except ValueError:
|
||||||
|
# No non-empty lines.
|
||||||
|
min_indent = 0
|
||||||
lines = [line[min_indent:] for line in lines]
|
lines = [line[min_indent:] for line in lines]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -126,22 +187,25 @@ class Source(Grammar):
|
|||||||
return BashScript.parse(self.filename())
|
return BashScript.parse(self.filename())
|
||||||
|
|
||||||
class CatEOF(Grammar):
|
class CatEOF(Grammar):
|
||||||
grammar = (ZERO_OR_MORE(SPACE), L('cat > '), ANY_EXCEPT(WHITESPACE), L(" <<"), OPTIONAL(SPACE), L("EOF;"), EOL, REPEAT(ANY, greedy=False), EOL, L("EOF"), EOL)
|
grammar = (ZERO_OR_MORE(SPACE), L('cat '), L('>') | L('>>'), L(' '), ANY_EXCEPT(WHITESPACE), L(" <<"), OPTIONAL(SPACE), L("EOF"), EOL, REPEAT(ANY, greedy=False), EOL, L("EOF"), EOL)
|
||||||
def value(self):
|
def value(self):
|
||||||
content = self[7].string
|
content = self[9].string
|
||||||
content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
|
content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
|
||||||
return "<div class='write-to'><div class='filename'>overwrite<br>" + self[2].string + "</div><pre>" + cgi.escape(content) + "</pre></div>\n"
|
return "<div class='write-to'><div class='filename'>%s <span>(%s)</span></div><pre>%s</pre></div>\n" \
|
||||||
|
% (self[4].string,
|
||||||
|
"overwrite" if ">>" not in self[2].string else "append to",
|
||||||
|
cgi.escape(content))
|
||||||
|
|
||||||
class HideOutput(Grammar):
|
class HideOutput(Grammar):
|
||||||
grammar = (L("hide_output "), REF("BashElement"))
|
grammar = (L("hide_output "), REF("BashElement"))
|
||||||
def value(self):
|
def value(self):
|
||||||
return self[1].value()
|
return self[1].value()
|
||||||
|
|
||||||
class SuppressedLine(Grammar):
|
class EchoLine(Grammar):
|
||||||
grammar = (OPTIONAL(SPACE), L("echo "), REST_OF_LINE, EOL)
|
grammar = (OPTIONAL(SPACE), L("echo "), REST_OF_LINE, EOL)
|
||||||
def value(self):
|
def value(self):
|
||||||
if "|" in self.string or ">" in self.string:
|
if "|" in self.string or ">" in self.string:
|
||||||
return "<pre class='shell'><div>" + cgi.escape(self.string.strip()) + "</div></pre>\n"
|
return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
class EditConf(Grammar):
|
class EditConf(Grammar):
|
||||||
@@ -150,7 +214,7 @@ class EditConf(Grammar):
|
|||||||
FILENAME,
|
FILENAME,
|
||||||
SPACE,
|
SPACE,
|
||||||
OPTIONAL((LIST_OF(
|
OPTIONAL((LIST_OF(
|
||||||
L("-w") | L("-s") | L("-c ';'"),
|
L("-w") | L("-s") | L("-c ;"),
|
||||||
sep=SPACE,
|
sep=SPACE,
|
||||||
), SPACE)),
|
), SPACE)),
|
||||||
REST_OF_LINE,
|
REST_OF_LINE,
|
||||||
@@ -159,27 +223,14 @@ class EditConf(Grammar):
|
|||||||
)
|
)
|
||||||
def value(self):
|
def value(self):
|
||||||
conffile = self[1]
|
conffile = self[1]
|
||||||
options = [""]
|
options = []
|
||||||
mode = 1
|
eq = "="
|
||||||
for c in self[4].string:
|
if self[3] and "-s" in self[3].string: eq = " "
|
||||||
if mode == 1 and c in (" ", "\t") and options[-1] != "":
|
for opt in re.split("\s+", self[4].string):
|
||||||
# new word
|
k, v = opt.split("=", 1)
|
||||||
options.append("")
|
v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled
|
||||||
elif mode < 0:
|
options.append("%s%s%s" % (k, eq, v))
|
||||||
# escaped character
|
return "<div class='write-to'><div class='filename'>" + self[1].string + " <span>(change settings)</span></div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n"
|
||||||
options[-1] += c
|
|
||||||
mode = -mode
|
|
||||||
elif c == "\\":
|
|
||||||
# escape next character
|
|
||||||
mode = -mode
|
|
||||||
elif mode == 1 and c == '"':
|
|
||||||
mode = 2
|
|
||||||
elif mode == 2 and c == '"':
|
|
||||||
mode = 1
|
|
||||||
else:
|
|
||||||
options[-1] += c
|
|
||||||
if options[-1] == "": options.pop(-1)
|
|
||||||
return "<div class='write-to'><div class='filename'>additional settings for<br>" + self[1].string + "</div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n"
|
|
||||||
|
|
||||||
class CaptureOutput(Grammar):
|
class CaptureOutput(Grammar):
|
||||||
grammar = OPTIONAL(SPACE), WORD("A-Za-z_"), L('=$('), REST_OF_LINE, L(")"), OPTIONAL(L(';')), EOL
|
grammar = OPTIONAL(SPACE), WORD("A-Za-z_"), L('=$('), REST_OF_LINE, L(")"), OPTIONAL(L(';')), EOL
|
||||||
@@ -193,8 +244,14 @@ class SedReplace(Grammar):
|
|||||||
def value(self):
|
def value(self):
|
||||||
return "<div class='write-to'><div class='filename'>edit<br>" + self[8].string + "</div><p>replace</p><pre>" + cgi.escape(self[3].string.replace(".*", ". . .")) + "</pre><p>with</p><pre>" + cgi.escape(self[5].string.replace("\\n", "\n").replace("\\t", "\t")) + "</pre></div>\n"
|
return "<div class='write-to'><div class='filename'>edit<br>" + self[8].string + "</div><p>replace</p><pre>" + cgi.escape(self[3].string.replace(".*", ". . .")) + "</pre><p>with</p><pre>" + cgi.escape(self[5].string.replace("\\n", "\n").replace("\\t", "\t")) + "</pre></div>\n"
|
||||||
|
|
||||||
|
class EchoPipe(Grammar):
|
||||||
|
grammar = OPTIONAL(SPACE), L("echo "), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL
|
||||||
|
def value(self):
|
||||||
|
text = " ".join("\"%s\"" % s for s in self[2].string.split(" "))
|
||||||
|
return "<pre class='shell'><div>echo " + recode_bash(text) + " \<br> | " + recode_bash(self[4].string) + "</div></pre>\n"
|
||||||
|
|
||||||
def shell_line(bash):
|
def shell_line(bash):
|
||||||
return "<pre class='shell'><div>" + cgi.escape(wrap_lines(bash.strip())) + "</div></pre>\n"
|
return "<pre class='shell'><div>" + recode_bash(bash.strip()) + "</div></pre>\n"
|
||||||
|
|
||||||
class AptGet(Grammar):
|
class AptGet(Grammar):
|
||||||
grammar = (ZERO_OR_MORE(SPACE), L("apt_install "), REST_OF_LINE, EOL)
|
grammar = (ZERO_OR_MORE(SPACE), L("apt_install "), REST_OF_LINE, EOL)
|
||||||
@@ -213,13 +270,125 @@ class OtherLine(Grammar):
|
|||||||
grammar = (REST_OF_LINE, EOL)
|
grammar = (REST_OF_LINE, EOL)
|
||||||
def value(self):
|
def value(self):
|
||||||
if self.string.strip() == "": return ""
|
if self.string.strip() == "": return ""
|
||||||
return "<pre class='shell'><div>" + cgi.escape(self.string.rstrip()) + "</div></pre>\n"
|
if "source setup/functions.sh" in self.string: return ""
|
||||||
|
if "source /etc/mailinabox.conf" in self.string: return ""
|
||||||
|
return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
|
||||||
|
|
||||||
class BashElement(Grammar):
|
class BashElement(Grammar):
|
||||||
grammar = Comment | Source | CatEOF | SuppressedLine | HideOutput | EditConf | CaptureOutput | SedReplace | AptGet | UfwAllow | RestartService | OtherLine
|
grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | RestartService | OtherLine
|
||||||
def value(self):
|
def value(self):
|
||||||
return self[0].value()
|
return self[0].value()
|
||||||
|
|
||||||
|
# Make some special characters to private use Unicode code points.
|
||||||
|
bash_special_characters1 = {
|
||||||
|
"\n": "\uE000",
|
||||||
|
" ": "\uE001",
|
||||||
|
}
|
||||||
|
bash_special_characters2 = {
|
||||||
|
"$": "\uE010",
|
||||||
|
}
|
||||||
|
bash_escapes = {
|
||||||
|
"n": "\uE020",
|
||||||
|
"t": "\uE021",
|
||||||
|
}
|
||||||
|
|
||||||
|
def quasitokenize(bashscript):
|
||||||
|
# Make a parse of bash easier by making the tokenization easy.
|
||||||
|
newscript = ""
|
||||||
|
quote_mode = None
|
||||||
|
escape_next = False
|
||||||
|
line_comment = False
|
||||||
|
subshell = 0
|
||||||
|
for c in bashscript:
|
||||||
|
if line_comment:
|
||||||
|
# We're in a comment until the end of the line.
|
||||||
|
newscript += c
|
||||||
|
if c == '\n':
|
||||||
|
line_comment = False
|
||||||
|
elif escape_next:
|
||||||
|
# Previous character was a \. Normally the next character
|
||||||
|
# comes through literally, but escaped newlines are line
|
||||||
|
# continuations and some escapes are for special characters
|
||||||
|
# which we'll recode and then turn back into escapes later.
|
||||||
|
if c == "\n":
|
||||||
|
c = " "
|
||||||
|
elif c in bash_escapes:
|
||||||
|
c = bash_escapes[c]
|
||||||
|
newscript += c
|
||||||
|
escape_next = False
|
||||||
|
elif c == "\\":
|
||||||
|
# Escaping next character.
|
||||||
|
escape_next = True
|
||||||
|
elif quote_mode is None and c in ('"', "'"):
|
||||||
|
# Starting a quoted word.
|
||||||
|
quote_mode = c
|
||||||
|
elif c == quote_mode:
|
||||||
|
# Ending a quoted word.
|
||||||
|
quote_mode = None
|
||||||
|
elif quote_mode is not None and quote_mode != "EOF" and c in bash_special_characters1:
|
||||||
|
# Replace special tokens within quoted words so that they
|
||||||
|
# don't interfere with tokenization later.
|
||||||
|
newscript += bash_special_characters1[c]
|
||||||
|
elif quote_mode is None and c == '#':
|
||||||
|
# Start of a line comment.
|
||||||
|
newscript += c
|
||||||
|
line_comment = True
|
||||||
|
elif quote_mode is None and c == ';' and subshell == 0:
|
||||||
|
# End of a statement.
|
||||||
|
newscript += "\n"
|
||||||
|
elif quote_mode is None and c == '(':
|
||||||
|
# Start of a subshell.
|
||||||
|
newscript += c
|
||||||
|
subshell += 1
|
||||||
|
elif quote_mode is None and c == ')':
|
||||||
|
# End of a subshell.
|
||||||
|
newscript += c
|
||||||
|
subshell -= 1
|
||||||
|
elif quote_mode is None and c == '\t':
|
||||||
|
# Make these just spaces.
|
||||||
|
if newscript[-1] != " ":
|
||||||
|
newscript += " "
|
||||||
|
elif quote_mode is None and c == ' ':
|
||||||
|
# Collapse consecutive spaces.
|
||||||
|
if newscript[-1] != " ":
|
||||||
|
newscript += " "
|
||||||
|
elif c in bash_special_characters2:
|
||||||
|
newscript += bash_special_characters2[c]
|
||||||
|
else:
|
||||||
|
# All other characters.
|
||||||
|
newscript += c
|
||||||
|
|
||||||
|
# "<< EOF" escaping.
|
||||||
|
if quote_mode is None and re.search("<<\s*EOF\n$", newscript):
|
||||||
|
quote_mode = "EOF"
|
||||||
|
elif quote_mode == "EOF" and re.search("\nEOF\n$", newscript):
|
||||||
|
quote_mode = None
|
||||||
|
|
||||||
|
return newscript
|
||||||
|
|
||||||
|
def recode_bash(s):
|
||||||
|
def requote(tok):
|
||||||
|
tok = tok.replace("\\", "\\\\")
|
||||||
|
for c in bash_special_characters2:
|
||||||
|
tok = tok.replace(c, "\\" + c)
|
||||||
|
tok = fixup_tokens(tok)
|
||||||
|
if " " in tok or '"' in tok:
|
||||||
|
tok = tok.replace("\"", "\\\"")
|
||||||
|
tok = '"' + tok +'"'
|
||||||
|
else:
|
||||||
|
tok = tok.replace("'", "\\'")
|
||||||
|
return tok
|
||||||
|
return cgi.escape(" ".join(requote(tok) for tok in s.split(" ")))
|
||||||
|
|
||||||
|
def fixup_tokens(s):
|
||||||
|
for c, enc in bash_special_characters1.items():
|
||||||
|
s = s.replace(enc, c)
|
||||||
|
for c, enc in bash_special_characters2.items():
|
||||||
|
s = s.replace(enc, c)
|
||||||
|
for esc, c in bash_escapes.items():
|
||||||
|
s = s.replace(c, "\\" + esc)
|
||||||
|
return s
|
||||||
|
|
||||||
class BashScript(Grammar):
|
class BashScript(Grammar):
|
||||||
grammar = (OPTIONAL(HashBang), REPEAT(BashElement))
|
grammar = (OPTIONAL(HashBang), REPEAT(BashElement))
|
||||||
def value(self):
|
def value(self):
|
||||||
@@ -228,22 +397,68 @@ class BashScript(Grammar):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(fn):
|
def parse(fn):
|
||||||
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
|
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
|
||||||
parser = BashScript.parser()
|
|
||||||
string = open(fn).read()
|
string = open(fn).read()
|
||||||
string = re.sub(r"\s*\\\n\s*", " ", string)
|
|
||||||
|
# tokenize
|
||||||
string = re.sub(".* #NODOC\n", "", string)
|
string = re.sub(".* #NODOC\n", "", string)
|
||||||
string = re.sub("\n\s*if .*|\n\s*fi|\n\s*else|\n\s*elif .*", "", string)
|
string = re.sub("\n\s*if .*then.*|\n\s*fi|\n\s*else|\n\s*elif .*", "", string)
|
||||||
|
string = quasitokenize(string)
|
||||||
string = re.sub("hide_output ", "", string)
|
string = re.sub("hide_output ", "", string)
|
||||||
|
|
||||||
|
parser = BashScript.parser()
|
||||||
result = parser.parse_string(string)
|
result = parser.parse_string(string)
|
||||||
|
|
||||||
v = "<div class='sourcefile'><a href=\"%s\">%s</a></div>\n" % ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
|
v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \
|
||||||
v += "".join(result.value())
|
% ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
|
||||||
|
|
||||||
|
mode = 0
|
||||||
|
for item in result.value():
|
||||||
|
if item.strip() == "":
|
||||||
|
pass
|
||||||
|
elif item.startswith("<p") and not item.startswith("<pre"):
|
||||||
|
clz = ""
|
||||||
|
if mode == 2:
|
||||||
|
v += "</div>\n" # col
|
||||||
|
v += "</div>\n" # row
|
||||||
|
mode = 0
|
||||||
|
clz = "contd"
|
||||||
|
if mode == 0:
|
||||||
|
v += "<div class='row %s'>\n" % clz
|
||||||
|
v += "<div class='col-md-6 prose'>\n"
|
||||||
|
v += item
|
||||||
|
mode = 1
|
||||||
|
elif item.startswith("<h"):
|
||||||
|
if mode != 0:
|
||||||
|
v += "</div>\n" # col
|
||||||
|
v += "</div>\n" # row
|
||||||
|
v += "<div class='row'>\n"
|
||||||
|
v += "<div class='col-md-6 header'>\n"
|
||||||
|
v += item
|
||||||
|
v += "</div>\n" # col
|
||||||
|
v += "<div class='col-md-6 terminal'> </div>\n"
|
||||||
|
v += "</div>\n" # row
|
||||||
|
mode = 0
|
||||||
|
else:
|
||||||
|
if mode == 0:
|
||||||
|
v += "<div class='row'>\n"
|
||||||
|
v += "<div class='col-md-offset-6 col-md-6 terminal'>\n"
|
||||||
|
elif mode == 1:
|
||||||
|
v += "</div>\n"
|
||||||
|
v += "<div class='col-md-6 terminal'>\n"
|
||||||
|
mode = 2
|
||||||
|
v += item
|
||||||
|
|
||||||
|
v += "</div>\n" # col
|
||||||
|
v += "</div>\n" # row
|
||||||
|
|
||||||
|
v = fixup_tokens(v)
|
||||||
|
|
||||||
v = v.replace("</pre>\n<pre class='shell'>", "")
|
v = v.replace("</pre>\n<pre class='shell'>", "")
|
||||||
v = re.sub("<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v)
|
v = re.sub("<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v)
|
||||||
|
|
||||||
v = re.sub(r"\$?PRIMARY_HOSTNAME", "<b>box.yourdomain.com</b>", v)
|
v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v)
|
||||||
v = re.sub(r"\$?STORAGE_ROOT", "<code><b>/path/to/user-data</b></code>", v)
|
v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v)
|
||||||
|
v = re.sub(r"\$CSR_COUNTRY", r"<b>US</b>", v)
|
||||||
v = v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>")
|
v = v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
$resource = '';
|
|
||||||
|
|
||||||
if(isset($_GET['resource'])){
|
|
||||||
$resource = $_GET['resource'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse our configuration file to get the STORAGE_ROOT.
|
|
||||||
$STORAGE_ROOT = NULL;
|
|
||||||
foreach (file("/etc/mailinabox.conf") as $line) {
|
|
||||||
$line = explode("=", rtrim($line), 2);
|
|
||||||
if ($line[0] == "STORAGE_ROOT") {
|
|
||||||
$STORAGE_ROOT = $line[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($STORAGE_ROOT == NULL) exit("no STORAGE_ROOT");
|
|
||||||
|
|
||||||
// Turn the resource into a file path. First URL-encode the resource
|
|
||||||
// so that it is filepath-safe.
|
|
||||||
$fn = urlencode($resource);
|
|
||||||
|
|
||||||
// Replace the first colon (it's URL-encoded) with a slash since we'll
|
|
||||||
// break off the files into scheme subdirectories.
|
|
||||||
$fn = preg_replace("/%3A/", "/", $fn, 1);
|
|
||||||
|
|
||||||
// Since this is often for email addresses, un-escape @-signs so they
|
|
||||||
// are not odd-looking. It's filename-safe anyway.
|
|
||||||
$fn = preg_replace("/%40/", "@", $fn);
|
|
||||||
|
|
||||||
// Combine with root path.
|
|
||||||
$fn = $STORAGE_ROOT . "/webfinger/" . $fn . ".json";
|
|
||||||
|
|
||||||
// See if the file exists.
|
|
||||||
if (!file_exists($fn)) {
|
|
||||||
header("HTTP/1.0 404 Not Found");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
header("Content-type: application/json");
|
|
||||||
echo file_get_contents($fn);
|
|
||||||
|
|
||||||
//json_encode(array(
|
|
||||||
// subject => $resource,
|
|
||||||
//), JSON_PRETTY_PRINT);
|
|
||||||
?>
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user