1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2026-03-12 17:07:23 +01:00

Compare commits

...

136 Commits
v0.02 ... v0.06

Author SHA1 Message Date
Joshua Tauberer
0aa3941832 release v0.06 2015-01-04 15:18:13 -05:00
Joshua Tauberer
fea77e41df Owncloud doesnt't support CARDDAV_SUPPORTS_SYNC
partialy reverts 93a722f; closes #287
2015-01-04 16:04:32 +00:00
Joshua Tauberer
74ef9ab7c5 Merge pull request #288 from fjuan/srv_doc
explain how to add SRV records to DNS zonefile using the API
2015-01-04 09:19:24 -05:00
Francisco de Juan
6499c82d7f explain how to add SRV records to DNS zonefile using the API 2015-01-04 10:23:34 +01:00
Joshua Tauberer
80e97feee2 update CHANGELOG 2015-01-02 23:47:19 +00:00
Joshua Tauberer
fddab5d432 allow the dns api to set srv records
see https://discourse.mailinabox.email/t/create-srv-record-at-the-dns-server/225
2015-01-02 23:39:09 +00:00
Joshua Tauberer
c4e4805160 ensure postfix/postgrey agree on whether to communicate with ipv4 or ipv6
see https://discourse.mailinabox.email/t/postgrey-and-ipv6/227
2015-01-02 23:37:16 +00:00
Joshua Tauberer
c75950125d set dovecot default_process_limit and fs.inotify.max_user_instances to better defaults
See https://discourse.mailinabox.email/t/mailserver-limits/228.
2015-01-02 23:25:52 +00:00
Joshua Tauberer
f141af4b61 status checks: dont die if openssh-server isn't installed
see https://discourse.mailinabox.email/t/local-dns-is-not-working-was-unable-to-check-system-status/165/39
2015-01-02 22:59:29 +00:00
Joshua Tauberer
3d8ea0e6ed mail log scanner: dont assume lines are utf8 2015-01-02 22:49:25 +00:00
H8H
6efeff6fce [Z-Push] Owncloud doesnt't support CARDDAV_SUPPORTS_SYNC, so set it to false 2014-12-29 16:35:47 +01:00
Joshua Tauberer
399f9d9bdf in status checks, clear bind9 cache using rndc rather than restarting bind9 2014-12-26 13:22:14 +00:00
Joshua Tauberer
2b76fd299e admin: ensure multiple concurrent api calls dont confuse the ajax loading indicator (track number of open requets, stop fade animation when it is time to hide) 2014-12-21 22:47:11 +00:00
Joshua Tauberer
90592bb157 add a control panel for setting custom dns records so that we dont have to use the api manually 2014-12-21 11:31:24 -05:00
Joshua Tauberer
5cf38b950a bump ownCloud to 7.0.4; fixes #283 2014-12-12 01:00:35 +00:00
Joshua Tauberer
3bc5361491 Merge pull request #282 from m4rcs/master
Fix NS status check - should sort on both ends of the comparison
2014-12-09 11:17:30 -05:00
Marc Schiller
c3a7e3413b Fixed a small status check bug, where secondary dns server check fails misleadingly. 2014-12-09 12:40:32 +01:00
Joshua Tauberer
d390bfb215 indicate in the admin when a multi-domain or wildcard certificate is in use 2014-12-05 14:43:52 -05:00
Joshua Tauberer
ceba53f1c4 explain how to install a multi-domain or wildcard ssl cert; if one is installed, the Replace Cert button in the admin for non-primary domains should not replace the cert on the primary domain 2014-12-05 14:25:14 -05:00
Joshua Tauberer
be59bcd47d for .fund domains use RSASHA256 DNSSEC keys 2014-12-05 12:03:21 -05:00
Joshua Tauberer
cfe0fa912a add a 'redirects' feature in web/custom.yaml 2014-12-05 12:03:21 -05:00
Joshua Tauberer
31d6128a2b nginx: explicitly listen on both ipv4 and ipv6 (works even if ipv6 isn't present) 2014-11-30 14:41:30 +00:00
Joshua Tauberer
82cf5b72e4 simplify some output in the work-in-progress mail log scanner 2014-11-30 14:41:30 +00:00
Joshua Tauberer
8ec8c42441 Merge pull request #275 from grepwood/master
add `apt-get install sudo`
2014-11-25 10:39:13 -05:00
Michael Dec
7e36e1fd90 added sudo to the list
not all setups have it and the miab installer depends on it
2014-11-25 15:36:34 +00:00
Joshua Tauberer
a7710e9058 dns.resolver.query treats hostnames as relative names if they don't end in a period
Relative hostnames have a fall-back lookup with the machine's hostname appended, which makes no sense. Add a period, e.g. "my.hostname.com" => "my.hostname.com.", to prevent that.

This caused false positive Spamhaus checks. Fixes #185.
2014-11-21 15:16:59 +00:00
Joshua Tauberer
3133dcd5a3 release 0.05 2014-11-18 16:52:02 +00:00
Joshua Tauberer
057c1dd913 recommend IMAP/SMTP for everyone 2014-11-18 16:47:42 +00:00
Joshua Tauberer
06f2477cfd the new iOS configuration profile also is used on OS X 10.10.1, see #261 2014-11-18 16:32:37 +00:00
Joshua Tauberer
1abc8ed469 add CHANGELOG entries for recent work 2014-11-14 14:13:24 +00:00
Joshua Tauberer
cdaa2c847d [merge] iOS Mobile Configuration Profile 2014-11-14 13:56:18 +00:00
Joshua Tauberer
b04addda9a move the mobileconfig into the conf directory as a plain XML file and handle substitutions and copying to /var in web.sh 2014-11-14 13:52:29 +00:00
Joshua Tauberer
7e7abf3b53 support "domain aliases" (@domain => @domain aliases)
This seemed to already be technically supported but the validation is now stricter and the admin is more helpful:

* Postfix seems to allow @domain.tld as an alias destination address but only if it is the only destination address (see the virtual man page).
 * Allow @domain.tld if it is the whole destination address string.
 * Otherwise, do not allow email addresses without local parts in the destination.
* In the admin, add a third tab for making it clear how to add a domain alias.

closes #265
2014-11-14 13:35:58 +00:00
Joshua Tauberer
9b9f5abf8f update to ownCloud 7.0.3 2014-11-14 13:35:58 +00:00
Norman
7db80458dd fix description 2014-11-06 15:42:22 +01:00
Norman
5775cab175 various fixes 2014-11-06 15:33:08 +01:00
Norman
c872e6a9f0 iOS Configuration Profile
change name

removed .vagrant

fix guide layout
2014-11-05 18:42:04 +01:00
Joshua Tauberer
995b7c4d2b Merge pull request #260 from fpgaminer/master
Fix typo in zpush.sh comment
2014-11-05 06:43:46 -05:00
fpgaminer
f797eecaca Fix typo in zpush.sh comment 2014-11-04 19:53:24 -08:00
Joshua Tauberer
de0ccd0632 [merge] Disable encapsulation of spam and marking of it as seen
is #254 plus a longer comment, fixes #243
2014-10-31 12:15:58 +00:00
David Piggott
be9d97902f Disable encapsulation of spam and marking of it as seen 2014-10-28 15:15:21 +00:00
Joshua Tauberer
20c5471a89 expose the ownCloud API, fixes #240, fixes #242 2014-10-28 12:05:07 +00:00
Joshua Tauberer
ec73c171c7 when installing a ssl cert for the primary hostname, dns, postfix, and dovecot all need to be updated/kicked
see https://discourse.mailinabox.email/t/there-is-a-problem-with-the-ssl-certificate/144/4
2014-10-28 11:38:04 +00:00
Joshua Tauberer
f9acf0adec better errors for ssl certificates 2014-10-24 21:30:33 +00:00
Joshua Tauberer
8b65c11cdf the namecheap link was bad 2014-10-23 17:17:26 +00:00
Joshua Tauberer
34fca29dd3 fix the animated scroll target on the ssl panel to scroll so that the header is actually visible and not covered by the nav bar 2014-10-23 17:10:21 +00:00
Joshua Tauberer
b75fbf22ca clear the local dns cache each time the status checks are run by restarting bind9 2014-10-23 17:06:33 +00:00
Joshua Tauberer
d790cae0e2 DNSSEC: use RSASHA256 for the .guide tld too 2014-10-23 17:03:23 +00:00
Joshua Tauberer
a68703dfb3 add a tool for me to see how many installations are happening by scanning for accesses to bootstrap.sh 2014-10-21 14:02:19 +00:00
Joshua Tauberer
f35b2081a1 s/os.rename/shutil.move/ so that the file can be moved across filesystem boundaries, fxies #246 2014-10-21 11:45:14 +00:00
David Piggott
f0508d8cc9 Improve wrapping of external DNS value column to prevent layout overflow
see #244

Conflicts:
	management/templates/external-dns.html
2014-10-21 11:33:42 +00:00
Joshua Tauberer
47dd59c2a7 admin mail guide: use bootstrap .panel to style the tips
also give more space for the login settings and less space to the tips
2014-10-21 11:17:49 +00:00
Joshua Tauberer
c2fe1bc2e3 document +tag addresses in the mail guide 2014-10-21 11:17:49 +00:00
Joshua Tauberer
cce1184090 admin: change the css class name around the panels to not invoke the bootstrap 'panel' css 2014-10-21 11:17:49 +00:00
Joshua Tauberer
1adb1d8307 admin: there is no need to make each panel a separate bootstrap container
* also fixes the footer alignment to be within a container rather than a container-fluid
* this changed the width of the login form slightly, so am cleaning that up too

see #244
2014-10-21 11:17:28 +00:00
Joshua Tauberer
c2174e10a6 some admin pages had a container within a container
see #244
2014-10-21 11:17:15 +00:00
Joshua Tauberer
18283c7df0 Merge pull request #248 from dhpiggott/bash-fix
Add shebangs to enable running dkim and webmail scripts separately
2014-10-20 16:35:57 -04:00
David Piggott
3ff74c8dc5 Add source line so dkim should actually work when run separately 2014-10-20 21:33:20 +01:00
David Piggott
e997114d6e Add shebangs to enable running dkim and webmail scripts separately 2014-10-20 21:26:14 +01:00
Joshua Tauberer
e9aecba4df update to roundcube 1.0.3, and really update
Updating existing installed was broken. The new roundcube would be copied into a subdirectory of /usr/local/lib/roundcubemail.

Also fixes the image thumbnail issue raised on the forum (https://discourse.mailinabox.email/t/roundcube-thumbnails-not-showing/136), see http://trac.roundcube.net/wiki/Changelog.
2014-10-20 12:48:15 +00:00
Joshua Tauberer
6585384daa bring the max outgoing mail size via webmail and z-push in line with the limit set in postfix: 128 MB
The limit was previously the nginx default (2MB?).

fixes #236
2014-10-16 22:11:10 +00:00
Joshua Tauberer
6bc821676c add the sa-learn permissions changes to the CHANGELOG 2014-10-16 22:11:10 +00:00
Joshua Tauberer
f38ef0223d update version number in CHANGELOG 2014-10-15 12:36:34 -04:00
Joshua Tauberer
8902e9d1fc bump bootstrap to incoming v0.04 tag 2014-10-15 12:33:20 -04:00
Joshua Tauberer
86a5394f07 fix control panel when no backup has been made yet 2014-10-15 12:31:08 -04:00
Joshua Tauberer
df5df18820 fixes for bootstrap.sh for upgrading
* `git fetch` wasn't done right for shallow clones
* the test for whether mailinabox has already been cloned wasn't looking at the right directory if the script was not run from $HOME
2014-10-15 12:22:48 -04:00
Joshua Tauberer
0b5bf602aa various improvements in bash comments 2014-10-15 11:46:20 -04:00
Joshua Tauberer
06e074bd32 disable SSLv3 in dovecot now that it is known to be insecure (POODLE)
SSLv3 is already disabled in Postfix (45e93f7dcc) and Nginx (51dd2ed70b).
2014-10-15 15:39:05 +00:00
Joshua Tauberer
b5b3fca137 report free disk space in the admin 2014-10-13 14:12:16 +00:00
Joshua Tauberer
048e35a80f fix display of backups that are past due to be reaped 2014-10-13 14:12:16 +00:00
Joshua Tauberer
fb3045f456 retain backups only for 3 days; beyond that the user is responsible for copying files off of the machine 2014-10-13 14:12:11 +00:00
Joshua Tauberer
495790d81d still didn't get the permissions right, chmod must follow sa-learn's initial creation of files
see #231, #201, b26abc947e, 7ca54a2bfb, dfe0a9f187
2014-10-12 18:05:04 +00:00
Joshua Tauberer
dfe0a9f187 clean up setup/spamassassin.sh 2014-10-12 17:57:04 +00:00
Joshua Tauberer
7ca54a2bfb give dovecot antispam plugin's sa-learn-pipe script permission to write to the bayes files
see #231, #201, b26abc947e.
2014-10-12 17:57:04 +00:00
Joshua Tauberer
a81bf93fdc preparing changelog for next release 2014-10-12 08:47:54 -04:00
Joshua Tauberer
54d01746fe Merge pull request #233 from h8h/patch-3
Jump to the panel_aliases anchor (top) to directly edit the selected alias
2014-10-11 19:30:40 -04:00
h8h
57f8ee0b09 Smoothly scroll to alias edit form. 2014-10-11 21:52:00 +02:00
h8h
64220292f1 Jump to the panel_aliases anchor (top) to directly edit the selected alias 2014-10-11 19:56:36 +02:00
Joshua Tauberer
82851d6d2d suppress "Something went wrong, sorry." when the management daemon's api key has changed 2014-10-11 17:06:22 +00:00
Joshua Tauberer
2f952a7915 delay an ajax call to see if this fixes the problem of the loading indicator not going away after showing the user a panel after login 2014-10-11 17:06:22 +00:00
Joshua Tauberer
f9eceec746 Merge pull request #232 from dhpiggott/spamassassin-permissions-fix
Change owner of spamassassin directory from mail to spampd, closes #231
2014-10-11 13:06:12 -04:00
David Piggott
b26abc947e Change owner of spamassassin directory from mail to spampd, closes #231 2014-10-11 18:00:22 +01:00
Joshua Tauberer
b78eae1351 Merge pull request #230 from dhpiggott/dns-update-fix
Pass additional_records to recursive build_zone calls, closes #229
2014-10-11 12:27:30 -04:00
David Piggott
ca57560f11 Pass additional_records to recursive build_zone calls, closes #229
The problem was that custom records defined for a subdomain where implicit
records are otherwise defined (e.g. A/AAAA records for the root) were ignored.

Though additional_records for a subdomain are processed in the base call to
build_zone (the call for the parent domain), and so custom records that don't
override implicits were working fine, those that overrode implicits were
ignored.

This was because the recursive call to build_zone for the subdomain creates the
implicit records (including A/AAAA records for the root), and so by relying on
the base call to add the additional_records fails because has_rec returned
true.

Adding a subdomain's additional_records in the child call works because has_rec
returns false when testing whether to add an e.g. A/AAAA override for the root,
as the defaults have not yet been added.
2014-10-11 17:04:35 +01:00
Joshua Tauberer
17331e7d82 adding a really slick ssl certificate installation form in the control panel 2014-10-10 15:49:14 +00:00
Joshua Tauberer
5130b279d8 management/mail_log.py also include the previously rotated log file 2014-10-10 13:59:50 +00:00
Joshua Tauberer
aac6e49b94 spelling typo 2014-10-10 13:50:44 +00:00
Joshua Tauberer
2f4eccd9a9 add 'source /etc/mailinabox.conf' to dns.sh so it can be run separately 2014-10-08 12:48:43 +00:00
Joshua Tauberer
ac49912b39 recommend DAVdroid
see http://discourse.mailinabox.email/t/recommend-a-different-android-carddav-and-caldav-android/102/1
2014-10-07 20:53:37 +00:00
Joshua Tauberer
0441a2e2e3 make a self-signed certificate on a non-primary domain a warning rather than an error, fixes #95 2014-10-07 20:41:07 +00:00
Joshua Tauberer
8566b78202 drop webfinger, see #95 2014-10-07 20:30:36 +00:00
Joshua Tauberer
06a8ce1c9d in the admin, show user mailbox sizes, fixes #210 2014-10-07 20:24:11 +00:00
Joshua Tauberer
443b084a17 in the admin, group aliases by domain, fixes #211 2014-10-07 19:47:46 +00:00
Joshua Tauberer
990649af2d in the admin, group users by domain, fixes 209 2014-10-07 19:47:43 +00:00
Joshua Tauberer
6f4d29a410 tweak the new web instructions 2014-10-07 16:17:45 +00:00
Joshua Tauberer
6ab29c3244 add instructions for static web hosting into the control panel 2014-10-07 16:05:42 +00:00
Joshua Tauberer
bf9b770255 sort SSHFP records so that DNS updates don't trigger spurrious zone changes 2014-10-07 15:15:22 +00:00
Joshua Tauberer
9210ebdb9f control panel tweaks 2014-10-07 15:12:35 +00:00
Joshua Tauberer
a56bb984d6 handle catastrophically bad certificates rather than raising an exception 2014-10-07 14:58:21 +00:00
Joshua Tauberer
7d1c0b3834 show SSL certificate expiration info in the control panel even long before certificates expire 2014-10-07 14:49:36 +00:00
Joshua Tauberer
20892b5d5b status check on ns records should now take into account that secondary dns may be customized, see #223 2014-10-05 18:42:52 +00:00
Joshua Tauberer
4cf53cd8ee backup status relativedelta was displaying wrong for deltas greater than 1 month 2014-10-05 18:23:29 +00:00
Joshua Tauberer
711db9352c bootstrap: apt was mangling stdin
When executed "cat bootstrap.sh | bash", apt-get mangled stdin. The script would terminate at the end of the if block containing apt-get (that seems to be as much as bash read from the pipe) and the remainder of the script was output to the console. This was very weird.

Ensuring that apt-get and git have their stdins redirected from /dev/null seems to fix the problem.

see #224
2014-10-05 13:40:12 -04:00
Joshua Tauberer
f42a1c5a74 allow overriding the second nameserver with a secondary/slave server
fixes #151
fixes #223
2014-10-05 14:53:42 +00:00
Joshua Tauberer
092c842a87 split external/custom dns into separate pages in the admin 2014-10-05 13:38:23 +00:00
Joshua Tauberer
d9ecc50119 since the management server binds to 127.0.0.1, must use that and not 'localhost' to connect to it because 'localhost' resolves to the IPv6 ::1 when it is available, see #224 2014-10-05 09:01:26 -04:00
Joshua Tauberer
7c2092d48f remove apache before installing nginx, see #224 2014-10-05 09:01:20 -04:00
Joshua Tauberer
5fd107cae5 more work on making the bash scripts readable 2014-10-04 17:57:26 -04:00
Joshua Tauberer
db0967446b remove unnecessary sudos 2014-10-04 14:06:08 -04:00
Joshua Tauberer
2ff5038c84 replace '.' with 'source' 2014-10-04 14:05:06 -04:00
Joshua Tauberer
4ae76aa2dd dnssec: use RSASHA256 keys for .email domains 2014-10-04 17:29:42 +00:00
h8h
ba33669a62 generate the locales before change to it.
For my german box changing the locale failed:
´´´´/bin/sh: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
/bin/sh: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
/bin/sh: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
/bin/sh: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
setup/functions.sh: line 6: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)´´´´

see #206 and 4e6d572de9
closes #220
commit modified by joshdata
2014-10-02 11:05:42 +00:00
Joshua Tauberer
779d921410 status checks: put DNSSEC tests in a better order w.r.t. other tests
* If the PRIMARY_HOSTNAME is in a zone with a DS record set at the registrar, show any DNSSEC failure (but only a failure) immediately since it is probably the cause of other DNS errors displayed later.
* For zones, if a DS record is set at the register, do the DNSSEC test first because even the NS test will fail if DNSSEC is improperly configure.
* But if a DS record is not set, the this is just a suggestion to configure DNSSEC so offer the suggestion last --- after mail and web checks.

see https://discourse.mailinabox.email/t/dns-nameserver-gandi-glue-records-issues/105/3
2014-10-01 12:13:11 +00:00
jkaberg
68efef1164 dont log robots.txt and favicon.ico. we should REALLY consider creating seperate include files for *all* of our "apps", this is getting messy.. 2014-09-27 17:04:05 +00:00
Joshua Tauberer
6ecada7eed Merge commit '93a722f' 2014-09-27 16:56:38 +00:00
Joshua Tauberer
94c4352f45 Merge branch 'jmar71n-master' - site-wide bayesean spam filtering 2014-09-27 16:18:55 +00:00
Joshua Tauberer
6dd6353d41 move sa-learn-pipe.sh from /usr to /usr/local 2014-09-27 16:18:40 +00:00
Joshua Tauberer
d06bfa6c1b tweak the site-wide bayesian spam filtering config 2014-09-27 16:18:36 +00:00
Joshua Tauberer
5c7ba2a4c7 preliminary work on a mail.log scanner to report things in the control panel 2014-09-27 13:33:13 +00:00
Joshua Tauberer
e9cc3fdaab make mail instructions clearer and describe greylisting, DMARC policy 2014-09-27 13:32:22 +00:00
Joshua Tauberer
8bd37ea53c add catch-alls to the admin again with nicer instructions 2014-09-27 13:32:22 +00:00
Joshua Tauberer
698ae03505 catch-all addresses should not have precedence over mail users
Aliases have precedence over mail users. A catch-all address would grab mail intended for a mail user and send it elsewhere. This adds some SQL hackery to create dummy aliases for all mail users.

fixes #200
closes #214 another way
2014-09-27 13:32:10 +00:00
Joshua Tauberer
a4c70f7a92 revert dovecot part of 39bca053ed because dovecot started behaving weird and I don't have time to debug it 2014-09-26 22:41:59 +00:00
Joshua Tauberer
39bca053ed add 2048 bits of DH params for nginx, postfix, dovecot
nginx/postfix use a new pre-generated dh2048.pem file. dovecot generates the bits on its own.

ssllabs.com reports that TLS_DHE ciphers went from 1024 to 2048 bits as expected. The ECDHE ciphers remain at 256 bits --- no idea what that really means. (This tests nginx only. I haven't tested postfix/dovecot.)

see https://discourse.mailinabox.email/t/fips-ready-for-ssl-dhec-key-exchange/76/3
2014-09-26 22:09:22 +00:00
Joshua Tauberer
c2eb8e5330 typo in roundcube download URL
see 8e0967dd8e (commitcomment-7940724)
2014-09-26 14:26:45 +00:00
Joshua Tauberer
ab47144ae3 add strict SPF and DMARC records to any subdomains (including custom records) that do not have SPF/DMARC set
closes #208
2014-09-26 14:01:03 +00:00
Joshua Tauberer
9b6f9859d1 dns_update: assume DKIM is present 2014-09-26 14:01:03 +00:00
Joshua Tauberer
4e6d572de9 ensure Python operates in UTF-8 with a consistent locale for all users
fixes #206 (hopefully)
2014-09-26 08:26:09 -04:00
Joshua Tauberer
145186a6b6 link to Modoboa in README 2014-09-26 08:20:13 -04:00
Joshua Tauberer
5714b3c6b7 bump bootstrap.sh to incoming 0.03 tag 2014-09-24 12:48:15 +00:00
Joshua Tauberer
8e0967dd8e if an earlier version of roundcube had already been installed, update to our target version
fixes #195
2014-09-24 12:46:51 +00:00
Joshua Tauberer
5a89f3c633 don't allow catch-all addresses in the admin because they take precedence over mail users and that's counter-intuitive
For now use the command-line tools/mail.py if you need it.

see #200

Revert "Changed incomming-email-input to type text"

This reverts commit 9631fab7b2.
2014-09-24 12:36:47 +00:00
Joshua Tauberer
ed8fb2d06d the latest z-push introduces a new/second USE_FULLEMAIL_FOR_LOGIN parameter
see http://discourse.mailinabox.email/t/activesync-z-push-not-working/94/3
2014-09-24 12:24:35 +00:00
Joshua Tauberer
8c8d9304ac lock z-push to a particular upstream version by fmbiete/Z-Push-contrib commit hash 2014-09-24 12:20:10 +00:00
jmar71n
b5bb12d0d2 enable site-wide bayesian filtering
Create directory in $STORAGE_ROOT for bayes database.

Added --username arg to sa-learn as the user mail does not have permission to edit files in $STORAGE_ROOT. There is probably a better solution to this...
2014-09-20 16:07:30 +01:00
jkaberg
93a722f85b ownCloud (witch is based on SabreDAV) supports sync 2014-09-10 21:22:56 +02:00
51 changed files with 2604 additions and 764 deletions

127
CHANGELOG.md Normal file
View File

@@ -0,0 +1,127 @@
CHANGELOG
=========
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.

View File

@@ -33,7 +33,7 @@ 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>.
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
-----------

128
conf/ios-profile.xml Normal file
View 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>

View File

@@ -25,6 +25,16 @@ SCRIPTNAME=/etc/init.d/$NAME
START=yes
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
[ -r /etc/default/$NAME ] && . /etc/default/$NAME

View File

@@ -1,7 +1,9 @@
# 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/;
location /admin/ {
proxy_pass http://localhost:10222/;
proxy_pass http://127.0.0.1:10222/;
proxy_set_header X-Forwarded-For $remote_addr;
}
@@ -16,8 +18,12 @@
deny all;
}
}
location ~ ^(/cloud)(/[^/]+\.php)(/.*)?$ {
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
# 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;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
fastcgi_param SCRIPT_NAME $1$2;

View File

@@ -55,7 +55,7 @@ spdy_headers_comp 0;
# openssl dhparam -outform pem -out dhparam2048.pem 2048
#
# 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,

View File

@@ -13,6 +13,7 @@ server {
# The secure HTTPS server.
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name $HOSTNAME;
@@ -24,6 +25,20 @@ server {
root $ROOT;
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;
}
# Roundcube Webmail configuration.
rewrite ^/mail$ /mail/ redirect;
rewrite ^/mail/$ /mail/index.php;
@@ -42,14 +57,10 @@ server {
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
fastcgi_pass php-fpm;
client_max_body_size 20M;
}
# Webfinger configuration.
location = /.well-known/webfinger {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-webfinger.php;
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;
}
# Z-Push (Microsoft Exchange ActiveSync)
@@ -59,6 +70,10 @@ server {
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
fastcgi_read_timeout 630;
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 {
include fastcgi_params;
@@ -70,4 +85,3 @@ server {
# ADDITIONAL DIRECTIVES HERE
}

View File

@@ -1,7 +1,6 @@
require ["regex", "fileinto", "imap4flags"];
if allof (header :regex "X-Spam-Status" "^Yes") {
setflag "\\Seen";
fileinto "Spam";
stop;
}

View File

@@ -9,10 +9,6 @@ define('CALDAV_SERVER', 'https://localhost');
define('CALDAV_PORT', '443');
define('CALDAV_PATH', '/caldav/calendars/%u/');
define('CALDAV_PERSONAL', '');
// 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);
define('CALDAV_SUPPORTS_SYNC', true);
?>

View File

@@ -14,14 +14,8 @@ define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdire
define('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */
define('CARDDAV_GAL_MIN_LENGTH', 5);
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);
// If the CardDAV server supports the FN attribute for searches
// 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

View File

@@ -16,7 +16,7 @@ from utils import exclusive_process, load_environment, shell
# destroy backups when the most recent increment in the chain
# that depends on it is this many days old.
keep_backups_for_days = 14
keep_backups_for_days = 3
def backup_status(env):
# What is the current status of backups?
@@ -25,8 +25,11 @@ def backup_status(env):
# see how large the storage is.
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)
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 > 1: return "%d days, %d hours" % (rd.days, rd.hours)
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
@@ -35,6 +38,7 @@ def backup_status(env):
backups = { }
basedir = os.path.join(env['STORAGE_ROOT'], 'backup/duplicity/')
encdir = os.path.join(env['STORAGE_ROOT'], 'backup/encrypted/')
os.makedirs(basedir, exist_ok=True) # os.listdir fails if directory does not exist
for fn in os.listdir(basedir):
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)
@@ -45,7 +49,7 @@ def backup_status(env):
backups[key] = {
"date": m.group("date"),
"date_str": date.strftime("%x %X"),
"date_delta": reldate(date, now),
"date_delta": reldate(date, now, "the future?"),
"full": m.group("incbase") is None,
"previous": m.group("incbase"),
"size": 0,
@@ -78,7 +82,7 @@ def backup_status(env):
deleted_in = None
elif saw_full and not deleted_in:
# 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
return {

View File

@@ -108,7 +108,7 @@ def buy_ssl_certificate(api_key, domain, command, env):
# Check before we overwrite something we shouldn't.
if os.path.exists(ssl_certificate):
cert_status = check_certificate(None, ssl_certificate, None)
cert_status, cert_status_details = 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)

View File

@@ -7,9 +7,9 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response
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_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
env = utils.load_environment()
@@ -71,9 +71,10 @@ def json_response(data):
def index():
# Render the control panel. This route does not require user authentication
# 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_admins_exist = (len(get_admins(env)) == 0)
return render_template('index.html',
hostname=env['PRIMARY_HOSTNAME'],
storage_root=env['STORAGE_ROOT'],
no_admins_exist=no_admins_exist,
)
@@ -97,7 +98,7 @@ def me():
@authorized_personnel_only
def mail_users():
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:
return "".join(x+"\n" for x in get_mail_users(env))
@@ -145,7 +146,7 @@ def mail_user_privs_remove():
@authorized_personnel_only
def mail_aliases():
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:
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
@@ -171,6 +172,12 @@ def mail_domains():
# 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'])
@authorized_personnel_only
def dns_update():
@@ -180,6 +187,33 @@ def dns_update():
except Exception as e:
return (str(e), 500)
@app.route('/dns/secondary-nameserver')
@authorized_personnel_only
def dns_get_secondary_nameserver():
from dns_update import get_custom_dns_config
return json_response({ "hostname": get_custom_dns_config(env).get("_secondary_nameserver") })
@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/set')
@authorized_personnel_only
def dns_get_records():
from dns_update import get_custom_dns_config, get_custom_records
additional_records = get_custom_dns_config(env)
records = get_custom_records(None, additional_records, env)
return json_response([{
"qname": r[0],
"rtype": r[1],
"value": r[2],
} for r in records])
@app.route('/dns/set/<qname>', methods=['POST'])
@app.route('/dns/set/<qname>/<rtype>', methods=['POST'])
@app.route('/dns/set/<qname>/<rtype>/<value>', methods=['POST'])
@@ -209,8 +243,32 @@ def dns_get_dump():
from dns_update import build_recommended_dns
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, csr_path = 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
@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'])
@authorized_personnel_only
def web_update():
@@ -232,6 +290,8 @@ def system_status():
self.items.append({ "type": "ok", "text": message, "extra": [] })
def print_error(self, message):
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):
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
output = WebOutput()
@@ -278,5 +338,6 @@ if __name__ == '__main__':
# debug console and enter that as the username
app.logger.info('API key: ' + auth_service.key)
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
app.run(port=10222)

View File

@@ -7,6 +7,7 @@
import os, os.path, urllib.parse, datetime, re, hashlib, base64
import ipaddress
import rtyaml
import dns.resolver
from mailconfig import get_mail_domains
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
@@ -55,6 +56,11 @@ def get_custom_dns_config(env):
except:
return { }
def write_custom_dns_config(config, env):
config_yaml = rtyaml.dump(config)
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
f.write(config_yaml)
def do_dns_update(env, force=False):
# What domains (and their zone filenames) should we build?
domains = get_dns_domains(env)
@@ -105,7 +111,7 @@ def do_dns_update(env, force=False):
zonefiles[i][1] += ".signed"
# 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
# nsd.conf so that we know to restart nsd.
if len(updated_domains) == 0:
@@ -134,12 +140,22 @@ def do_dns_update(env, force=False):
def build_zone(domain, all_domains, additional_records, env, is_zone=True):
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
# is managed outside of the box.
if is_zone:
# Obligatory definition of ns1.PRIMARY_HOSTNAME.
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 = additional_records.get("_secondary_nameserver", "ns2." + env["PRIMARY_HOSTNAME"])
records.append((None, "NS", secondary_ns+'.', False))
# In PRIMARY_HOSTNAME...
if domain == env["PRIMARY_HOSTNAME"]:
@@ -176,7 +192,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
subdomains = [d for d in all_domains if d.endswith("." + domain)]
for subdomain in subdomains:
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:
if child_qname == None:
child_qname = subdomain_qname
@@ -184,9 +200,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
child_qname += "." + subdomain_qname
records.append((child_qname, child_rtype, child_value, child_explanation))
def has_rec(qname, rtype):
def has_rec(qname, rtype, prefix=None):
for rec in records:
if rec[0] == qname and rec[1] == rtype:
if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)):
return True
return False
@@ -195,7 +211,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
if has_rec(qname, rtype): continue
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).
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),
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
@@ -208,10 +224,8 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
if not has_rec(qname, rtype):
records.append((qname, rtype, value, explanation))
# If OpenDKIM is in use..
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
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:
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
val = m.group(2) + m.group(3)
@@ -220,6 +234,16 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
# Append a DMARC record.
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.
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
@@ -230,10 +254,13 @@ 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
# 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).
# 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:
@@ -308,9 +335,11 @@ def build_sshfp_records():
}
# 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"])
for key in keys.split("\n"):
for key in sorted(keys.split("\n")):
if key.strip() == "" or key[0] == "#": continue
try:
host, keytype, pubkey = key.split(" ")
@@ -429,7 +458,7 @@ $TTL 1800 ; default time to live
########################################################################
def write_nsd_conf(zonefiles, env):
def write_nsd_conf(zonefiles, additional_records, env):
# Basic header.
nsdconf = """
server:
@@ -458,6 +487,19 @@ zone:
zonefile: %s
""" % (domain, zonefile)
# If a custom secondary nameserver has been set, allow zone transfers
# and notifies to that nameserver.
if additional_records.get("_secondary_nameserver"):
# Get the IP address of the nameserver by resolving it.
hostname = additional_records.get("_secondary_nameserver")
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 nsd.conf is changing. If it isn't changing,
# return False to flag that no change was made.
with open("/etc/nsd/nsd.conf") as f:
@@ -471,8 +513,22 @@ zone:
########################################################################
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):
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
# a new .key file with a DNSSEC record for the specific domain. We
@@ -613,7 +669,7 @@ def set_custom_dns_record(qname, rtype, value, env):
v = ipaddress.ip_address(value)
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.")
elif rtype in ("CNAME", "TXT"):
elif rtype in ("CNAME", "TXT", "SRV"):
# anything goes
pass
else:
@@ -670,14 +726,38 @@ def set_custom_dns_record(qname, rtype, value, env):
config[qname][rtype] = value
# serialize & save
config_yaml = rtyaml.dump(config)
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
f.write(config_yaml)
write_custom_dns_config(config, env)
return True
########################################################################
def set_secondary_dns(hostname, env):
config = get_custom_dns_config(env)
if hostname in (None, ""):
# Clear.
if "_secondary_nameserver" in config:
del config["_secondary_nameserver"]
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.
config["_secondary_nameserver"] = hostname
# Save and apply.
write_custom_dns_config(config, env)
return do_dns_update(env)
########################################################################
def justtestingdotemail(domain, records):
# If the domain is a subdomain of justtesting.email, which we own,
# automatically populate the zone where it is set up on dns4e.com.

121
management/mail_log.py Executable file
View 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)

View File

@@ -15,7 +15,7 @@ def validate_email(email, mode=None):
if mode == 'user':
# For Dovecot's benefit, only allow basic characters.
ATEXT = r'[\w\-]'
elif mode == 'alias':
elif mode in (None, '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.
@@ -27,7 +27,8 @@ def validate_email(email, mode=None):
DOT_ATOM_TEXT_LOCAL = ATEXT + r'+(?:\.' + ATEXT + r'+)*'
if mode == 'alias':
# For aliases, Postfix accepts '@domain.tld' format for
# catch-all addresses. Make the local part optional.
# catch-all addresses on the source side and domain aliases
# on the destination side. Make the local part optional.
DOT_ATOM_TEXT_LOCAL = '(?:' + DOT_ATOM_TEXT_LOCAL + ')?'
# as above, but we can require that the host part have at least
@@ -46,69 +47,164 @@ def open_database(env, with_connection=False):
else:
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",
# aliases: [
# ("alias@domain.tld", ["indirect.alias@domain.tld", ...]),
# ...
# ]
# },
# ...
# ]
# },
# ...
# ]
# Pre-load all aliases.
aliases = get_mail_alias_map(env)
# Get users and their privileges.
users = []
active_accounts = set()
c = open_database(env)
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
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 [
{
user = {
"email": email,
"privileges": parse_privs(privileges),
"status": "active",
"aliases": [
}
users.append(user)
if with_slow_info:
user["aliases"] = [
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
for alias in aliases.get(email.lower(), [])
]
}
for email, privileges in users
]
user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
def get_archived_mail_users(env):
real_users = set(get_mail_users(env))
# Add in archived accounts.
if with_archived:
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
ret = []
for domain_enc in os.listdir(root):
for user_enc in os.listdir(os.path.join(root, domain_enc)):
email = utils.unsafe_domain_name(user_enc) + "@" + utils.unsafe_domain_name(domain_enc)
if email in real_users: continue
ret.append({
for domain in os.listdir(root):
for user in os.listdir(os.path.join(root, domain)):
email = user + "@" + domain
mbox = os.path.join(root, domain, user)
if email in active_accounts: continue
user = {
"email": email,
"privileges": "",
"status": "inactive"
})
return ret
"status": "inactive",
"mailbox": mbox,
}
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.execute('SELECT source, destination FROM aliases')
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
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ] # sort
# but put automatic aliases to administrator@ last
aliases.sort(key = lambda x : x[1] == get_system_administrator(env))
if as_json:
required_aliases = get_required_aliases(env)
aliases = [
{
"source": alias[0],
"destination": [d.strip() for d in alias[1].split(",")],
"required": alias[0] in required_aliases or alias[0] == get_system_administrator(env),
}
for alias in aliases
]
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ]
return aliases
def get_mail_aliases_ex(env):
# Returns a complex data structure of all mail aliases, similar
# to get_mail_users_ex.
#
# [
# {
# domain: "domain.tld",
# alias: [
# {
# source: "name@domain.tld",
# destination: ["target1@domain.com", "target2@domain.com", ...],
# required: True|False
# },
# ...
# ]
# },
# ...
# ]
required_aliases = get_required_aliases(env)
domains = {}
for source, destination in get_mail_aliases(env):
# get alias info
domain = get_domain(source)
required = ((source in required_aliases) or (source == get_system_administrator(env)))
# add to list
if not domain in domains:
domains[domain] = {
"domain": domain,
"aliases": [],
}
domains[domain]["aliases"].append({
"source": source,
"destination": [d.strip() for d in destination.split(",")],
"required": required,
})
# Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
# Sort aliases within each domain first by required-ness then lexicographically by source address.
for domain in domains:
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
return domains
def get_mail_alias_map(env):
aliases = { }
for alias, targets in get_mail_aliases(env):
@@ -124,9 +220,10 @@ def evaluate_mail_alias_map(email, aliases, env):
ret |= evaluate_mail_alias_map(alias, aliases, env)
return ret
def get_mail_domains(env, filter_aliases=lambda alias : True):
def get_domain(emailaddr):
return emailaddr.split('@', 1)[1]
def get_mail_domains(env, filter_aliases=lambda alias : True):
return set(
[get_domain(addr) for addr in get_mail_users(env)]
+ [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
@@ -260,19 +357,28 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
if not validate_email(source, mode='alias'):
return ("Invalid incoming email address (%s)." % source, 400)
# parse comma and \n-separated destination emails & validate
# validate destination
dests = []
destination = destination.strip()
if validate_email(destination, mode='alias'):
# Oostfix allows a single @domain.tld as the destination, which means
# the local part on the address is preserved in the rewrite.
dests.append(destination)
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 email in line.split(","):
email = email.strip()
if email == "": continue
if not validate_email(email, mode='alias'):
if not validate_email(email):
return ("Invalid destination email address (%s)." % email, 400)
dests.append(email)
if len(destination) == 0:
return ("No destination email address(es) provided.", 400)
destination = ",".join(dests)
# save to db
conn, c = open_database(env, with_connection=True)
try:
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))

View File

@@ -11,13 +11,17 @@ import os, os.path, re, subprocess, datetime
import dns.reversename, dns.resolver
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
from web_update import get_web_domains, get_domain_ssl_files
from mailconfig import get_mail_domains, get_mail_aliases
from utils import shell, sort_domains, load_env_vars_from_file
def run_checks(env, output):
# clear bind9's DNS cache so our DNS checks are up to date
shell('check_call', ["/usr/sbin/rndc", "flush"])
# perform checks
env["out"] = output
run_system_checks(env)
run_network_checks(env)
@@ -25,8 +29,17 @@ def run_checks(env, output):
def run_system_checks(env):
env["out"].add_heading("System")
check_ssh_password(env)
check_software_updates(env)
check_system_aliases(env)
check_free_disk_space(env)
# Check that SSH login with password is disabled.
def check_ssh_password(env):
# 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()
if re.search("\nPasswordAuthentication\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\s+no", sshd):
@@ -37,6 +50,7 @@ def run_system_checks(env):
else:
env['out'].print_ok("SSH disallows password-based login.")
def check_software_updates(env):
# Check for any software package updates.
pkgs = list_apt_updates(apt_update=False)
if os.path.exists("/var/run/reboot-required"):
@@ -48,10 +62,25 @@ def run_system_checks(env):
for p in pkgs:
env['out'].print_line("%s (%s)" % (p["package"], p["version"]))
def check_system_aliases(env):
# Check that the administrator alias exists since that's where all
# admin email is automatically directed.
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env)
def check_free_disk_space(env):
# 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
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10.0)
if bytes_free > .3 * bytes_total:
env['out'].print_ok(disk_msg)
elif bytes_free > .15 * bytes_total:
env['out'].print_warning(disk_msg)
else:
env['out'].print_error(disk_msg)
def run_network_checks(env):
# Also see setup/network-checks.sh.
@@ -98,7 +127,7 @@ def run_domain_checks(env):
env["out"].add_heading(domain)
if domain == env["PRIMARY_HOSTNAME"]:
check_primary_hostname_dns(domain, env)
check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles)
if domain in dns_domains:
check_dns_zone(domain, env, dns_zonefiles)
@@ -109,9 +138,21 @@ def run_domain_checks(env):
if domain in web_domains:
check_web_domain(domain, env)
def check_primary_hostname_dns(domain, env):
if domain in dns_domains:
check_dns_zone_suggestions(domain, env, dns_zonefiles)
def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
# If a DS record is set on the zone containing this domain, check DNSSEC now.
for zone in dns_domains:
if zone == domain or domain.endswith("." + zone):
if query_dns(zone, "DS", nxdomain=None) is not None:
check_dnssec(zone, env, dns_zonefiles, is_checking_primary=True)
# 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.
# We're probably not actually checking that here but instead checking that we, as
# the nameserver, are reporting the right info --- but if the glue is incorrect this
# will probably fail.
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
env['out'].print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
@@ -150,9 +191,8 @@ def check_primary_hostname_dns(domain, env):
elif tlsa25 is None:
env['out'].print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""")
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
regenerate the record. It may take several hours for
public DNS to update after a change."""
env['out'].print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'.
It may take several hours for public DNS to update after a change."""
% (tlsa_qname, tlsa25, tlsa25_expected))
# Check that the hostmaster@ email address exists.
@@ -166,17 +206,38 @@ def check_alias_exists(alias, env):
env['out'].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, 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, dns_zonefiles)
# 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.
custom_dns = get_custom_dns_config(env)
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'],
custom_dns.get("_secondary_nameserver", "ns2." + env['PRIMARY_HOSTNAME']),
]))
if existing_ns.lower() == correct_ns.lower():
env['out'].print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
else:
env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registar's
env['out'].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."""
% (existing_ns, correct_ns) )
def check_dns_zone_suggestions(domain, env, 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, dns_zonefiles)
def check_dnssec(domain, env, dns_zonefiles, is_checking_primary=False):
# 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
# pre-generated all of the valid digests --- read them in.
@@ -188,28 +249,36 @@ def check_dns_zone(domain, env, dns_zonefiles):
# 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).
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]
# Query public DNS for the DS record at the registrar.
ds = query_dns(domain, "DS", nxdomain=None)
ds_looks_valid = ds and len(ds.split(" ")) == 4
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]):
env['out'].print_ok("DNS 'DS' record is set correctly at registrar.")
if ds_looks_valid and ds[0] == ds_keytag and ds[1] == ds_alg and ds[3] == digests.get(ds[2]):
if is_checking_primary: return
env['out'].print_ok("DNSSEC 'DS' record is set correctly at registrar.")
else:
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
env['out'].print_error("""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:""")
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:
env['out'].print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain)
return
env['out'].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
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
provide to them this information:""")
env['out'].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]))
env['out'].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]))
env['out'].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
env['out'].print_line("Digest Type: 2 / SHA-256")
# http://www.ietf.org/assignments/ds-rr-types/ds-rr-types.xml
@@ -290,7 +359,15 @@ def check_web_domain(domain, env):
check_ssl_cert(domain, env)
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:
response = dns.resolver.query(qname, rtype)
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
@@ -319,9 +396,15 @@ def check_ssl_cert(domain, env):
# 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)
if cert_status == "OK":
# The certificate is ok. The details has expiry info.
env['out'].print_ok("SSL certificate is signed & valid. " + cert_status_details)
elif cert_status == "SELF-SIGNED":
# Offer instructions for purchasing a signed certificate.
if cert_status == "SELF-SIGNED":
fingerprint = shell('check_output', [
"openssl",
"x509",
@@ -334,31 +417,22 @@ def check_ssl_cert(domain, env):
if domain == env['PRIMARY_HOSTNAME']:
env['out'].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
static site hosting). You may choose to confirm the security exception, but check that the certificate
fingerprint matches the following:""")
static site hosting). Use the SSL Certificates page in this control panel to install a signed SSL certificate.
You may choose to leave the self-signed certificate in place and confirm the security exception, but check that
the certificate fingerprint matches the following:""")
env['out'].print_line("")
env['out'].print_line(" " + fingerprint, monospace=True)
else:
env['out'].print_error("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
env['out'].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
safe to leave the self-signed certificate in place.""")
env['out'].print_line("")
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.")
safe to leave the self-signed certificate in place. Use the SSL Certificates page in this control panel to
install a signed SSL certificate.""")
else:
env['out'].print_error("The SSL certificate has a problem:")
env['out'].print_error("The SSL certificate has a problem: " + cert_status)
if cert_status_details:
env['out'].print_line("")
env['out'].print_line(cert_status)
env['out'].print_line(cert_status_details)
env['out'].print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key):
@@ -368,11 +442,18 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
# 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
# or in the SAN list, so check for that tool.
cert_dump = shell('check_output', [
retcode, cert_dump = shell('check_output', [
"openssl", "x509",
"-in", ssl_certificate,
"-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")
certificate_names = set()
cert_expiration_date = None
@@ -403,8 +484,8 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
wildcard_domain = re.sub("^[^\.]+", "*", domain)
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." % \
", ".join(sorted(certificate_names))
return ("The certificate is for the wrong domain name. It is for %s."
% ", ".join(sorted(certificate_names)), None)
# 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
@@ -420,7 +501,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
"-in", ssl_certificate,
"-noout", "-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
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
@@ -434,7 +515,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
cert = open(ssl_certificate).read()
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
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()
# This command returns a non-zero exit status in most cases, so trap errors.
@@ -450,10 +531,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
if "self signed" in verifyoutput:
# Certificate is self-signed.
return "SELF-SIGNED"
return ("SELF-SIGNED", None)
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.
return verifyoutput.strip()
return ("There is a problem with the SSL certificate.", verifyoutput.strip())
else:
# `openssl verify` returned a zero exit status so the cert is currently
# good.
@@ -461,11 +545,12 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
# But is it expiring soon?
now = datetime.datetime.now(dateutil.tz.tzlocal())
ndays = (cert_expiration_date-now).days
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
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 "OK"
return ("OK", expiry_info)
_apt_updates = None
def list_apt_updates(apt_update=True):
@@ -518,6 +603,9 @@ class ConsoleOutput:
def print_error(self, message):
self.print_block(message, first_line="")
def print_warning(self, message):
self.print_block(message, first_line="? ")
def print_block(self, message, first_line=" "):
print(first_line, end='')
message = re.sub("\n\s*", " ", message)
@@ -551,7 +639,7 @@ if __name__ == "__main__":
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
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":
sys.exit(1)
sys.exit(0)

View File

@@ -11,20 +11,33 @@
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
<div class="form-group">
<label for="addaliasEmail" class="col-sm-2 control-label">Email Address</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address">
<div class="col-sm-offset-1 col-sm-11">
<div id="alias_type_buttons" class="btn-group btn-group-xs">
<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 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">
<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>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button id="add-alias-button" type="submit" class="btn btn-primary">Add</button>
<label for="addaliasTargets" class="col-sm-1 control-label">Forward To</label>
<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>
</div>
</div>
@@ -35,7 +48,7 @@
<thead>
<tr>
<th></th>
<th>Email Address<br></th>
<th>Alias<br></th>
<th>Forwards To</th>
</tr>
</thead>
@@ -49,7 +62,7 @@
<table>
<tr id="alias-template">
<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>
</a>
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
@@ -73,16 +86,52 @@ function show_aliases() {
function(r) {
$('#alias_table tbody').html("");
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();
n.attr('id', '');
if (r[i].required) n.addClass('alias-required');
n.attr('data-email', r[i].source);
n.find('td.email').text(r[i].source)
for (var j = 0; j < r[i].destination.length; j++)
n.find('td.target').append($("<div></div>").text(r[i].destination[j]))
if (alias.required) n.addClass('alias-required');
n.attr('data-email', alias.source);
n.find('td.email').text(alias.source)
for (var j = 0; j < alias.destination.length; j++)
n.find('td.target').append($("<div></div>").text(alias.destination[j]))
$('#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 +165,7 @@ function aliases_reset_form() {
$("#addaliasEmail").val('')
$("#addaliasTargets").val('')
$('#alias-cancel').addClass('hidden');
$('#add-alias-button').text('Add');
$('#add-alias-button').text('Add Alias');
is_alias_add_update = false;
}
@@ -133,6 +182,12 @@ function aliases_edit(elem) {
$("#addaliasEmail").val(email);
$("#addaliasTargets").val(targets);
$('#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 })
}
@@ -156,4 +211,10 @@ function aliases_remove(elem) {
});
});
}
function scroll_top() {
$('html, body').animate({
scrollTop: $("#panel_aliases").offset().top
}, 1000);
}
</script>

View File

@@ -0,0 +1,239 @@
<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>
</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 &ldquo;slave&rdquo;) 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&rsquo;s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>
<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>
<h4>HTTP POST 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&rsquo;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&rsquo;s value. If omitted, the IPv4 address of the remote host is used. This is handy for dynamic DNS! To delete a record, use &ldquo;__delete__&rdquo;.</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>
<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>
<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
# sets a <a href="http://en.wikipedia.org/wiki/SRV_record">SRV record</a> for the "service" and "protocol" hosted on "target" server
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/_service._protocol.{{hostname}}/srv/"priority weight port target"
# sets a SRV record using the value syntax
curl -d "value=priority weight port target" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/_service._protocol.host/srv
</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/set",
"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.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');
do_set_custom_dns(qname, rtype, "__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) {
if (!qname) {
if ($('#customdnsQname').val() != '')
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
else
qname = $('#customdnsZone').val();
rtype = $('#customdnsType').val();
value = $('#customdnsValue').val();
}
api(
"/dns/set/" + qname + "/" + rtype,
"POST",
{
value: 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", $("<pre/>").text(err));
});
}
function show_customdns_rtype_hint() {
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
}
</script>

View 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 &mdash; such as in the DNS control panel provided by your domain name registrar or virtual cloud provider &mdash; by copying the DNS zone information shown in the table below into your external DNS server&rsquo;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>

View File

@@ -46,20 +46,32 @@
margin-bottom: 13px;
margin-top: 30px;
}
.panel-heading h3 {
border: none;
padding: 0;
margin: 0;
}
h4 {
font-size: 110%;
margin-bottom: 13px;
margin-top: 18px;
}
h4:first-child {
margin-top: 6px;
}
.panel {
.admin_panel {
display: none;
}
table.table {
margin: 1.5em 0;
}
ol li {
margin-bottom: 1em;
}
</style>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
<style>
@@ -81,26 +93,28 @@
</div>
<div class="navbar-collapse collapse">
<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>
<ul class="dropdown-menu">
<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 class="divider"></li>
<li class="dropdown-header">Super Advanced Options</li>
<li><a href="#system_dns" onclick="return show_panel(this);">DNS (Custom/External)</a></li>
<li class="dropdown-header">Advanced Options</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>
</li>
<li class="dropdown active">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail/Sync <b class="caret"></b></a>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#mail-guide" onclick="return show_panel(this);">Mail Instructions</a></li>
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
<li class="divider"></li>
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</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>
</ul>
</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 class="nav navbar-nav navbar-right">
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
@@ -109,39 +123,51 @@
</div>
</div>
<div class="container-fluid">
<div id="panel_system_status" class="container panel">
<div class="container">
<div id="panel_system_status" class="admin_panel">
{% include "system-status.html" %}
</div>
<div id="panel_system_backup" class="container panel">
<div id="panel_system_backup" class="admin_panel">
{% include "system-backup.html" %}
</div>
<div id="panel_system_dns" class="container panel">
{% include "system-dns.html" %}
<div id="panel_external_dns" class="admin_panel">
{% include "external-dns.html" %}
</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" %}
</div>
<div id="panel_mail-guide" class="container panel">
<div id="panel_mail-guide" class="admin_panel">
{% include "mail-guide.html" %}
</div>
<div id="panel_users" class="container panel">
<div id="panel_users" class="admin_panel">
{% include "users.html" %}
</div>
<div id="panel_aliases" class="container panel">
<div id="panel_aliases" class="admin_panel">
{% include "aliases.html" %}
</div>
<div id="panel_sync_guide" class="container panel">
<div id="panel_sync_guide" class="admin_panel">
{% include "sync-guide.html" %}
</div>
<div id="panel_web" class="admin_panel">
{% include "web.html" %}
</div>
<div id="panel_ssl" class="admin_panel">
{% include "ssl.html" %}
</div>
<hr>
<footer>
@@ -238,12 +264,13 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
$('#global_modal').modal({});
}
var is_ajax_loading = false;
var ajax_num_executing_requests = 0;
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() {
is_ajax_loading = false;
$('#ajax_loading_indicator').hide();
ajax_num_executing_requests--;
if (ajax_num_executing_requests == 0)
$('#ajax_loading_indicator').stop().hide(); // stop() prevents an ongoing fade from causing the thing to be shown again after this call
}
var old_success = options.success;
var old_error = options.error;
@@ -259,9 +286,9 @@ function ajax(options) {
if (!old_error)
show_modal_error("Error", "Something went wrong, sorry.")
else
old_error(jqxhr.responseText);
old_error(jqxhr.responseText, jqxhr);
};
is_ajax_loading = true;
ajax_num_executing_requests++;
$.ajax(options);
}
@@ -294,6 +321,11 @@ function api(url, method, data, callback, callback_error) {
return output;
}
function default_error(text, xhr) {
if (xhr.status != 403) // else handled below
show_modal_error("Error", "Something went wrong, sorry.")
}
ajax({
url: "/admin" + url,
method: method,
@@ -307,7 +339,7 @@ function api(url, method, data, callback, callback_error) {
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
},
success: callback,
error: callback_error,
error: callback_error || default_error,
statusCode: {
403: function(xhr) {
// Credentials are no longer valid. Try to login again.
@@ -326,7 +358,7 @@ function show_panel(panelid) {
// we might be passed an HTMLElement <a>.
panelid = panelid.getAttribute('href').substring(1);
$('.panel').hide();
$('.admin_panel').hide();
$('#panel_' + panelid).show();
if (typeof localStorage != 'undefined')
localStorage.setItem("miab-cp-lastpanel", panelid);

View File

@@ -1,7 +1,7 @@
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
{% if no_admins_exist %}
<div class="container">
<div class="row">
<div class="col-md-offset-2 col-md-8">
<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>
@@ -12,27 +12,24 @@ sudo tools/mail.py user make-admin your@emailaddress.com</pre>
</div>
{% endif %}
<div class="row">
<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>
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
<div style="margin: 0 auto; max-width: 32em;">
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
<div class="form-group">
<label for="inputEmail3" class="col-sm-2 control-label">Email</label>
<div class="col-sm-10">
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
<div class="col-sm-9">
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
</div>
</div>
<div class="form-group">
<label for="inputPassword3" class="col-sm-2 control-label">Password</label>
<div class="col-sm-10">
<label for="inputPassword3" class="col-sm-3 control-label">Password</label>
<div class="col-sm-9">
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
</div>
</div>
<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">
<label>
<input name='remember' type="checkbox" id="loginRemember"> Remember me
@@ -41,13 +38,12 @@ sudo tools/mail.py user make-admin your@emailaddress.com</pre>
</div>
</div>
<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>
</div>
</div>
</form>
</div>
</div>
<script>
@@ -99,8 +95,10 @@ function do_login() {
}
}
// Open the next panel the user wants to go to.
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel)
// Open the next panel the user wants to go to. Do this after the XHR response
// 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);
}
})
}

View File

@@ -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">
<h2>Checking and Sending Mail</h2>
<p>You can use either webmail, IMAP/SMTP, or Exchange/ActiveSync to access your mail.</p>
<hr>
<div>
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
<div class="row">
<div class="col-md-6">
<h4>How to log in</h4>
<div class="col-sm-7">
<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">
<div class="col-sm-6">
<h4>Webmail</h4>
<h3>Mobile/desktop apps</h3>
<p>Your webmail site is <a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a>.</p>
</div>
</div>
<h4>Automatic configuration</h4>
<div class="row">
<div class="col-sm-6">
<h4>IMAP/SMTP settings</h4>
<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>
<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">
<thead>
<tr><th style="padding-bottom: 1em; border-bottom: none">Server:</th> <td colspan="2">{{hostname}}</td></tr>
<tr><th>Protocol</th> <th>Port</th> <th>Options</th></tr>
<tr><th>Option</th> <th>Value</th></tr>
</thead>
<tr><th>IMAP</th> <td>993</td> <td>SSL</td></tr>
<tr><th>SMTP</th> <td>587</td> <td>STARTTLS</td></tr>
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
<tr><th>Mail server</th> <td>{{hostname}}</td>
<tr><th>IMAP Port</th> <td>993</td></tr>
<tr><th>IMAP Sercurity</th> <td>SSL</td></tr>
<tr><th>SMTP Port</th> <td>587</td></tr>
<tr><th>SMTP Security</td> <td>STARTTLS <small>(&ldquo;always&rdquo; or &ldquo;required&rdquo;, 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>
<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a>.</p>
</div>
<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
<div class="col-sm-6">
<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 and devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP. If you encounter any problems, please use the manual settings above.</p>
<table class="table">
<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>
</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&rsquo;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>

View 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&rsquo;s $9 certificate</a> or <a href="https://www.startssl.com/">StartSSL&rsquo;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-----&#xA;stuff here&#xA;-----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-----&#xA;stuff here&#xA;-----END CERTIFICATE-----&#xA;-----BEGIN CERTIFICATE-----&#xA;more stuff here&#xA;-----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>

View File

@@ -1,4 +1,4 @@
<div class="container">
<div>
<h2>Contacts &amp; Calendar Synchronization</h2>
<p>This box can hold your contacts and calendar, just like it holds your email.</p>
@@ -13,8 +13,8 @@
<table class="table">
<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><td>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
<tr><th>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
<tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
</table>
<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">
<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>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>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>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>
<p>Use the following settings:</p>

View File

@@ -28,7 +28,7 @@
</tbody>
</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>
<p style="margin-top: 2em"><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>
function nice_size(bytes) {
@@ -39,7 +39,12 @@ function nice_size(bytes) {
bytes /= 1024;
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() {
@@ -54,6 +59,12 @@ function show_system_backup() {
$('#backup-status tbody').html("");
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++) {
var b = r.backups[i];
var tr = $('<tr/>');

View File

@@ -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&rsquo;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&rsquo;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&rsquo;s value. If omitted, the IPv4 address of the remote host is used. This is handy for dynamic DNS! To delete a record, use &ldquo;__delete__&rdquo;.</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>

View File

@@ -10,11 +10,14 @@
border-top: none;
padding-top: 0;
}
#system-checks .error td {
#system-checks .status-error td {
color: #733;
}
#system-checks .ok td {
color: #030;
#system-checks .status-warning td {
color: #770;
}
#system-checks .status-ok td {
color: #040;
}
#system-checks div.extra {
display: none;
@@ -52,9 +55,13 @@ function show_system_status() {
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>");
if (i == 0) n.addClass('first')
if (r[i].type == "heading")
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 == "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)
$('#system-checks tbody').append(n);

View File

@@ -2,7 +2,7 @@
<style>
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
#user_table .aliases { margin-top: .33em; font-size: 95%; }
#user_table .aliases { font-size: 90%; }
#user_table .aliases div:before { content: "⇖ "; }
#user_table .aliases div { }
#user_table .actions { margin-top: .33em; font-size: 95%; }
@@ -38,6 +38,13 @@
<h3>Existing mail users</h3>
<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>
</table>
@@ -45,10 +52,9 @@
<div style="display: none">
<table>
<tr id="user-template">
<td class='email'>
<div class='address'> </div>
<div class='actions'>
<td class='address'>
</td>
<td class='actions'>
<span class='privs'>
</span>
@@ -65,9 +71,13 @@
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
archive account
</a>
<div class='if_inactive' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address.</div>
</div>
</td>
<td class='mailboxsize'>
</td>
</tr>
<tr id="user-extra-template">
<td colspan="3" style="border-top: 0; padding-top: 0">
<div class='if_inactive 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>
<div class='aliases' style='display: none'> </div>
</td>
@@ -86,24 +96,38 @@ function show_users() {
function(r) {
$('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><td><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 n2 = $("#user-extra-template").clone();
n.attr('id', '');
n.addClass("account_" + r[i].status);
n.attr('data-email', r[i].email);
n.find('td.email .address').text(r[i].email)
n2.attr('id', '');
$('#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"];
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>");
p.find('span.name').text(r[i].privileges[j]);
p.find('span.name').text(user.privileges[j]);
n.find('.privs').append(p);
if (add_privs.indexOf(r[i].privileges[j]) >= 0)
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1);
if (add_privs.indexOf(user.privileges[j]) >= 0)
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
}
for (var j = 0; j < add_privs.length; j++) {
@@ -112,16 +136,17 @@ function show_users() {
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(", ") : "")
if (user.aliases && user.aliases.length > 0) {
n2.find('.aliases').show();
for (var j = 0; j < user.aliases.length; j++) {
n2.find('.aliases').append($("<div/>").text(
user.aliases[j][0]
+ (user.aliases[j][1].length > 0 ? " ⇐ " + user.aliases[j][1].join(", ") : "")
))
}
}
}
}
})
}

View File

@@ -0,0 +1,102 @@
<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&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload it 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 (if you have customized that, see next section).</li>
<table id="web_domains_existing" class="table" style="margin-bottom: 2em; width: auto;">
<thead>
<tr>
<th>Site</th>
<th>Directory for Files</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<li>If you want to have this box host a static website on a domain that is not listed in the 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.</li>
</ol>
<h3>Different sites for different domains</h3>
<p>Create one of the directories shown in the table below to create a space for different files for one of the websites.</p>
<p>After you create one of these directories, click <button id="web_update" class="btn btn-primary" onclick="do_web_update()">Web Update</button> to restart the box&rsquo;s web server so that it sees the new website file location.</p>
<table id="web_domains_custom" class="table" style="margin-bottom: 2em; width: auto;">
<thead>
<tr>
<th>Site</th>
<th>Create Directory</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<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++) {
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td></tr>");
tb.append(row);
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);
}
tb = $('#web_domains_custom tbody');
tb.text('');
for (var i = 0; i < domains.length; i++) {
if (domains[i].root != domains[i].custom_root) {
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt></td></tr>");
tb.append(row);
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].custom_root);
}
}
});
}
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() });
});
}
</script>

View File

@@ -23,10 +23,6 @@ def safe_domain_name(name):
import urllib.parse
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):
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
# must appear first so it becomes the nginx default server.
@@ -169,3 +165,22 @@ def create_syslog_handler():
handler = logging.handlers.SysLogHandler(address='/dev/log')
handler.setLevel(logging.WARNING)
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

View File

@@ -2,10 +2,10 @@
# domains for which a mail account has been set up.
########################################################################
import os, os.path, re, rtyaml
import os, os.path, shutil, re, rtyaml
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
def get_web_domains(env):
@@ -38,7 +38,7 @@ def get_web_domains(env):
return domains
def do_web_update(env):
def do_web_update(env, ok_status="web updated\n"):
# Build an nginx configuration file.
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
@@ -65,7 +65,7 @@ def do_web_update(env):
# enough and doesn't break any open connections.
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):
# How will we configure this domain.
@@ -94,6 +94,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_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.
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn):
@@ -102,6 +115,8 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
yaml = yaml[domain]
for path, url in yaml.get("proxies", {}).items():
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.
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
@@ -113,14 +128,14 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
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.
for test_domain in (domain, 'default'):
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
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
# 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
@@ -144,9 +159,9 @@ def get_domain_ssl_files(domain, env):
# 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
# 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
if check_certificate(domain, ssl_certificate_primary, None) == "OK":
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
ssl_certificate = ssl_certificate_primary
# Where would the CSR go? As with the SSL cert itself, the CSR must be
@@ -178,12 +193,8 @@ 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.
# Start with a CSR.
shell("check_call", [
"openssl", "req", "-new",
"-key", ssl_key,
"-out", csr_path,
"-sha256",
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
with open(csr_path, "w") as f:
f.write(create_csr(domain, ssl_key, env))
# And then make the certificate.
shell("check_call", [
@@ -193,3 +204,81 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
"-signkey", ssl_key,
"-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_csr_path = 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):
def check_cert(domain):
from status_checks import check_certificate
ssl_key, ssl_certificate, ssl_csr_path = 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 domain == env['PRIMARY_HOSTNAME'] or ssl_certificate != get_domain_ssl_files(env['PRIMARY_HOSTNAME'], env)[1]:
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. Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME'])
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),
}
for domain in get_web_domains(env)
]

33
setup/bootstrap.sh Executable file → Normal file
View File

@@ -7,7 +7,7 @@
#########################################################
if [ -z "$TAG" ]; then
TAG=v0.02
TAG=v0.06
fi
# Are we running as root?
@@ -16,27 +16,34 @@ if [[ $EUID -ne 0 ]]; then
exit
fi
# Go to root's home directory.
cd
# Clone the Mail-in-a-Box repository if it doesn't exist.
if [ ! -d mailinabox ]; then
if [ ! -d $HOME/mailinabox ]; then
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 . . .
git clone -b $TAG --depth 1 https://github.com/mail-in-a-box/mailinabox 2> /dev/null
cd mailinabox
echo Downloading Mail-in-a-Box $TAG. . .
git clone \
-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.
else
echo
fi
# Change directory to it.
cd $HOME/mailinabox
# Update it.
if [ "$TAG" != `git describe` ]; then
echo Updating Mail-in-a-Box to $TAG . . .
cd mailinabox
git fetch
git fetch --depth 1 --force --prune origin tag $TAG
if ! git checkout -q $TAG; then
echo "Update failed. Did you modify something in `pwd`?"
exit
fi
echo
fi
# Start setup script.

View File

@@ -1,12 +1,15 @@
# OpenDKIM: Sign outgoing mail with DKIM
########################################
# After this, you'll still need to run dns_update.sh to get the DKIM
# signature in the DNS zones.
#!/bin/bash
# OpenDKIM
# --------
#
# 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 /etc/mailinabox.conf # load global vars
# Install DKIM
# Install DKIM...
apt_install opendkim opendkim-tools
# Make sure configuration directories exist.
@@ -18,9 +21,9 @@ mkdir -p $STORAGE_ROOT/mail/dkim
echo "127.0.0.1" > /etc/opendkim/TrustedHosts
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
true; # already done
true # already done #NODOC
else
# Add various configuration options to the end.
# Add various configuration options to the end of `opendkim.conf`.
cat >> /etc/opendkim.conf << EOF;
MinimumKeyBits 1024
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
@@ -32,7 +35,7 @@ RequireSafeKeys false
EOF
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
# is the actual private key and the latter is the suggested DNS TXT
# entry which we'll want to include in our DNS setup.
@@ -47,7 +50,7 @@ chmod go-rwx $STORAGE_ROOT/mail/dkim
# Add OpenDKIM as a milter to postfix, which is how it intercepts outgoing
# mail to perform the signing (by adding a mail header).
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line.
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line. #NODOC
tools/editconf.py /etc/postfix/main.cf \
smtpd_milters=inet:127.0.0.1:8891 \
non_smtpd_milters=\$smtpd_milters \

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# DNS: Configure a DNS server to host our own DNS
# DNS
# -----------------------------------------------
# This script installs packages, but the DNS zone files are only
@@ -8,15 +8,16 @@
# mail users & aliases created by the user later.
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install `nsd`, our DNS server software, and `ldnsutils` which helps
# 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
# (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
true #echo "nsd user exists... good"; #NODOC
else
useradd nsd;
fi
@@ -31,44 +32,75 @@ apt_install nsd ldnsutils openssh-client
# Prepare nsd's configuration.
sudo mkdir -p /var/run/nsd
mkdir -p /var/run/nsd
# Create DNSSEC signing keys.
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
# Secure Entry Point. Use a NSEC3-compatible algorithm (best
# practice), and a nice and long keylength. The domain name we
# provide ("_domain_") doesn't matter -- we'll use the same
# keys for all our domains.
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 2048 -k _domain_);
# TLDs don't all support the same algorithms, so we'll generate keys using a few
# different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used
# algorithm that supported NSEC3, which is a security best practice. However TLDs
# will probably be moving away from it to a a SHA256-based algorithm.
#
# 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
# rotated more often than a KSK, although we have no plans to
# rotate it (and doing so would be difficult to do without
# disturbing DNS availability.) Omit '-k' and use a shorter key.
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 _domain_);
# disturbing DNS availability.) Omit `-k` and use a shorter key length.
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 1024 _domain_);
# These generate two sets of files like:
#
# * `K_domain_.+007+08882.ds`: DS record to provide to domain name registrar
# * `K_domain_.+007+08882.key`: public key (goes into DS record & upstream DNS provider like your 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
# * `K_domain_.+007+08882.private`: private key (secret!)
# The filenames are unpredictable and encode the key generation
# options. So we'll store the names of the files we just generated.
# We might have multiple keys down the road. This will identify
# what keys are the current keys.
cat > $STORAGE_ROOT/dns/dnssec/keys.conf << EOF;
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
KSK=$KSK
ZSK=$ZSK
EOF
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;
#!/bin/bash
# Mail-in-a-Box

View File

@@ -18,15 +18,34 @@
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# ### Install packages and basic setup
# Install packages...
apt_install \
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite sqlite3 \
dovecot-sieve dovecot-managesieved
# The dovecot-imapd and dovecot-lmtpd packages automatically enable IMAP and LMTP protocols.
# The `dovecot-imapd` and `dovecot-lmtpd` packages automatically enable IMAP 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 \
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
mail_privileged_group=mail \
@@ -41,16 +60,18 @@ tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
disable_plaintext_auth=yes \
"auth_mechanisms=plain login"
# 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/.
# Enable SSL, specify the location of the SSL certificate and private key files.
# 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 \
ssl=required \
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.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"
# Disable in-the-clear IMAP and POP because we're paranoid (we haven't even
# enabled POP).
# Disable in-the-clear IMAP because there is no reason for a user to transmit
# login credentials outside of an encrypted connection. Although we haven't
# even installed the POP server, ensure it is disabled too.
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
@@ -66,7 +87,7 @@ tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
# ### LDA (LMTP)
# 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
# would communicate (see the Postfix setup script for the corresponding
@@ -91,30 +112,32 @@ protocol imap {
}
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 \
postmaster_address=postmaster@$PRIMARY_HOSTNAME
# ### Sieve
# 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
# as spam by Spamassassin into the user's Spam folder.
# mail as it comes in.
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
plugin {
# The path to our global sieve which handles moving spam to the Spam folder.
sieve_before = /etc/dovecot/sieve-spam.sieve
# The path to the user's main active script. ManageSieve will create a symbolic
# 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 = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
# Directory for :personal include scripts for the include extension. This
# * `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;
plugin {
sieve_before = /etc/dovecot/sieve-spam.sieve
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
}
EOF
@@ -122,7 +145,7 @@ EOF
# 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
# 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
# PERMISSIONS

View File

@@ -32,13 +32,26 @@ source /etc/mailinabox.conf # load global vars
# ### 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
# Have postfix listen on all network interfaces, set our name (the Debian default seems to be localhost),
# 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).
# Set some basic settings...
#
# * 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 \
inet_interfaces=all \
myhostname=$PRIMARY_HOSTNAME\
@@ -50,7 +63,7 @@ tools/editconf.py /etc/postfix/main.cf \
# Enable the 'submission' port 587 smtpd server and tweak its settings.
#
# * 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.
# * Add a new cleanup service specific to the submission service ('authclean')
# that filters out privacy-sensitive headers on mail being sent out by
@@ -69,44 +82,47 @@ 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
# require TLS before a user is allowed to authenticate. This also makes
# 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 \
smtpd_tls_security_level=may\
smtpd_tls_auth_only=yes \
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
smtpd_tls_received_header=yes
# Prevent non-authenticated users from sending mail that requires being
# relayed elsewhere. We don't want to be an "open relay". On outbound
# mail, require one of:
#
# * permit_sasl_authenticated: Authenticated users (i.e. on port 587).
# * permit_mynetworks: Mail that originates locally.
# * reject_unauth_destination: No one else. (Permits mail whose destination is local and rejects other mail.)
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587).
# * `permit_mynetworks`: Mail that originates locally.
# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)
tools/editconf.py /etc/postfix/main.cf \
smtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination
# ### DANE
#
# 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
# end provides, if the remote end offers STARTTLS during the connection. DANE takes this a
# step further:
# Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end
# offers it, otherwise it will transmit the message in the clear. Postfix will
# 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,
# 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
# 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
# to use it.
# relies on our local bind9 server being present and `smtp_dns_support_level=dnssec`.
#
# The smtp_tls_CAfile is superflous, but it turns warnings in the logs about untrusted certs
# into notices about trusted certs. Since in these cases Postfix is doing opportunistic TLS,
# it does not care about whether the remote certificate is trusted. But, looking at the logs,
# it's nice to be able to see that the connection was in fact encrypted for the right party.
# The CA file is provided by the package ca-certificates.
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
tools/editconf.py /etc/postfix/main.cf \
smtp_tls_security_level=dane \
smtp_dns_support_level=dnssec \
@@ -126,25 +142,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.
#
# * reject_non_fqdn_sender: Reject not-nice-looking return paths.
# * reject_unknown_sender_domain: Reject return paths with invalid 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_mynetworks: Mail that originates locally can skip further checks.
# * 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.
# * check_policy_service: Apply greylisting using postgrey.
# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
# * `reject_unknown_sender_domain`: Reject return paths with invalid 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_mynetworks`: Mail that originates locally can skip further checks.
# * `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.
# * `check_policy_service`: Apply greylisting using postgrey.
#
# Notes:
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting
# 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
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce.
# Notes: #NODOC
# 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 #NODOC
# 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. #NODOC
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_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.
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
tools/editconf.py /etc/postfix/main.cf \
message_size_limit=134217728

View File

@@ -53,7 +53,6 @@ EOF
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
# 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;
service auth {
unix_listener /var/spool/postfix/private/auth {
@@ -93,9 +92,16 @@ query = SELECT 1 FROM users WHERE email='%s'
EOF
# 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;
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
# Restart Services

View File

@@ -60,6 +60,13 @@ def migration_5(env):
# The secret key for encrypting backups was world readable. Fix here.
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 get_current_migration():
ver = 0
while True:

View File

@@ -15,7 +15,7 @@ apt_install \
apt-get purge -qq -y owncloud*
# Install ownCloud from source of this version:
owncloud_ver=7.0.2
owncloud_ver=7.0.4
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
if [ ! -d /usr/local/lib/owncloud/ ] \

95
setup/spamassassin.sh Normal file → Executable file
View File

@@ -1,16 +1,20 @@
#!/bin/bash
# Spam filtering with spamassassin via spampd
#############################################
# -------------------------------------------
#
# spampd sits between postfix and dovecot. It takes mail from postfix
# over the LMTP protocol, runs spamassassin on it, and then passes the
# message over LMTP to dovecot for local delivery.
#
# 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
# user when the mail user is created.
# plugin.
source /etc/mailinabox.conf # get global vars
source setup/functions.sh # load our functions
# Install packages and basic configuration
# ----------------------------------------
# Install packages.
apt_install spampd razor pyzor dovecot-antispam
@@ -26,20 +30,47 @@ hide_output pyzor discover
# We've already configured Dovecot to listen on this port.
tools/editconf.py /etc/default/spampd DESTPORT=10026
# Enable the Dovecot antispam plugin to detect when a message moves between folders so we can
# pass it to sa-learn for training. (Be careful if we use multiple plugins later.)
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
# Spamassassin normally wraps spam as an attachment inside a fresh
# email with a report about the message. This also protects the user
# 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
# that sends the mail to spamassassin.
# from http://wiki2.dovecot.org/Plugins/Antispam
cat > /usr/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/bin/sa-learn-pipe.sh
# Bayesean learning
# -----------------
#
# Spamassassin can learn from mail marked as spam or ham, but it needs to be
# configured. We'll store the learning data in our storage area.
#
# These files must be:
#
# * Writable by sa-learn-pipe script below, which run as the 'mail' user, for manual tagging of mail as spam/ham.
# * 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
# Configure the antispam plugin to call sa-learn-pipe.sh.
cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
@@ -47,12 +78,36 @@ plugin {
antispam_backend = pipe
antispam_spam_pattern_ignorecase = SPAM
antispam_allow_append_to_spam = yes
antispam_pipe_program_spam_args = /usr/bin/sa-learn-pipe.sh;--spam
antispam_pipe_program_notspam_args = /usr/bin/sa-learn-pipe.sh;--ham
antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
antispam_pipe_program = /bin/bash
}
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?
# sa-learn --ham storage/mail/mailboxes/*/*/cur/
# sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/

View File

@@ -2,7 +2,7 @@
#
# SSL Certificate
# ---------------
#
# Create a self-signed SSL certificate if one has not yet been created.
#
# 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
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.
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
(umask 077; hide_output \
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
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
hide_output \
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"
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
hide_output \
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
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 Security. 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

View File

@@ -6,7 +6,22 @@ source setup/functions.sh # load our functions
# 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.
. 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.
if [ -f /etc/mailinabox.conf ]; then
@@ -33,7 +48,7 @@ chmod +x /usr/local/bin/mailinabox
# 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
# 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.
if [ "$PUBLIC_IP" = "auto" ]; then
@@ -97,19 +112,19 @@ CSR_COUNTRY=$CSR_COUNTRY
EOF
# Start service configuration.
. setup/system.sh
. setup/ssl.sh
. setup/dns.sh
. setup/mail-postfix.sh
. setup/mail-dovecot.sh
. setup/mail-users.sh
. setup/dkim.sh
. setup/spamassassin.sh
. setup/web.sh
. setup/webmail.sh
. setup/owncloud.sh
. setup/zpush.sh
. setup/management.sh
source setup/system.sh
source setup/ssl.sh
source setup/dns.sh
source setup/mail-postfix.sh
source setup/mail-dovecot.sh
source setup/mail-users.sh
source setup/dkim.sh
source setup/spamassassin.sh
source setup/web.sh
source setup/webmail.sh
source setup/owncloud.sh
source setup/zpush.sh
source setup/management.sh
# Write the DNS and nginx configuration files.
sleep 5 # wait for the daemon to start
@@ -117,7 +132,7 @@ curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:102
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update
# If there aren't any mail users yet, create one.
. setup/firstuser.sh
source setup/firstuser.sh
# Done.
echo
@@ -145,4 +160,3 @@ openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
echo
echo Then you can confirm the security exception and continue.
echo

View File

@@ -1,11 +1,11 @@
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...
hide_output apt-get update
@@ -19,9 +19,12 @@ hide_output apt-get -y upgrade
# * unattended-upgrades: Apt tool to install security updates automatically.
# * ntp: keeps the system time correct
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
# * 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 \
wget curl \
wget curl sudo coreutils bc \
haveged unattended-upgrades ntp fail2ban
# Allow apt to install system updates automatically every day.
@@ -35,8 +38,6 @@ EOF
# ### Firewall
# Turn on the firewall.
#
# 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
# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC
@@ -47,15 +48,15 @@ if [ -z "$DISABLE_FIREWALL" ]; then
# Allow incoming connections to SSH.
ufw_allow ssh;
# ssh might be running on an alternate port. Use sshd -T to dump sshd's
# settings, find the port it is supposedly running on, and open that port
# too.
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //")
# 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 #NODOC
# too. #NODOC
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
if [ ! -z "$SSH_PORT" ]; then
if [ "$SSH_PORT" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT.
ufw_allow $SSH_PORT;
echo Opening alternate SSH port $SSH_PORT. #NODOC
ufw_allow $SSH_PORT #NODOC
fi
fi

View File

@@ -5,20 +5,34 @@
source setup/functions.sh # load our functions
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
rm -f /etc/nginx/sites-enabled/default
# copy in a nginx configuration file for common and best-practices
# SSL settings from @konklone
cp conf/nginx-ssl.conf /etc/nginx/nginx-ssl.conf
# Copy in a nginx configuration file for common and best-practices
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
# the DH params.
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
conf/nginx-ssl.conf > /etc/nginx/nginx-ssl.conf
# Fix some nginx defaults.
# The server_names_hash_bucket_size seems to prevent long domain names?
tools/editconf.py /etc/nginx/nginx.conf -s \
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 ';' \
pm.max_children=8
@@ -26,38 +40,41 @@ 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
# 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
# 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
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
fi
chown -R $STORAGE_USER $STORAGE_ROOT/www
# We previously installed a custom init script to start the PHP FastCGI daemon.
# Remove it now that we're using php5-fpm.
# We previously installed a custom init script to start the PHP FastCGI daemon. #NODOC
# Remove it now that we're using php5-fpm. #NODOC
if [ -L /etc/init.d/php-fastcgi ]; then
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..."
rm -f /etc/init.d/php-fastcgi
hide_output update-rc.d php-fastcgi remove
apt-get -y purge php5-cgi
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..." #NODOC
rm -f /etc/init.d/php-fastcgi #NODOC
hide_output update-rc.d php-fastcgi remove #NODOC
apt-get -y purge php5-cgi #NODOC
fi
# Put our webfinger script into a well-known location.
for f in webfinger; do
cp tools/$f.php /usr/local/bin/mailinabox-$f.php
chown www-data.www-data /usr/local/bin/mailinabox-$f.php
done
# 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
# Remove obsoleted scripts. #NODOC
# exchange-autodiscover is now handled by Z-Push. #NODOC
for f in webfinger exchange-autodiscover; do #NODOC
rm -f /usr/local/bin/mailinabox-$f.php #NODOC
done #NODOC
# Start services.
restart_service nginx

View File

@@ -1,3 +1,4 @@
#!/bin/bash
# Webmail with Roundcube
# ----------------------
@@ -23,19 +24,30 @@ 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 \
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
# We used to install Roundcube from Ubuntu, without triggering the dependencies
# on Apache and MySQL, by downloading the debs and installing them manually.
# Now that we're beyond that, get rid of those debs before installing from source.
apt-get purge -qq -y roundcube*
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
# 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. #NODOC
apt-get purge -qq -y roundcube* #NODOC
# Install Roundcube from source if it is not already present.
# TODO: Check version?
if [ ! -d /usr/local/lib/roundcubemail ]; then
# Install Roundcube from source if it is not already present or if it is out of date.
VERSION=1.0.3
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
# not installed yet #NODOC
needs_update=1 #NODOC
elif [[ $VERSION != `cat /usr/local/lib/roundcubemail/version` ]]; then
# checks if the version is what we want
needs_update=1 #NODOC
fi
if [ $needs_update == 1 ]; then
echo installing roudcube webmail $VERSION...
rm -f /tmp/roundcube.tgz
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/1.0.2/roundcubemail-1.0.2.tar.gz
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
mv /usr/local/lib/roundcubemail-1.0.2/ /usr/local/lib/roundcubemail
rm -rf /usr/local/lib/roundcubemail
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
rm -f /tmp/roundcube.tgz
echo $VERSION > /usr/local/lib/roundcubemail/version
fi
# ### Configuring Roundcube

View File

@@ -3,7 +3,7 @@
# 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
# so we won't install it that way.
@@ -21,30 +21,33 @@ apt_install \
php5enmod imap
# Copy Z-Push into place.
TARGETHASH=d0cd5a47c53afac5c3b287006dc8a48a1c4ffcd5
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/z-push/version ]; then
needs_update=1 #NODOC
elif [[ `curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master` != `cat /usr/local/lib/z-push/version` ]]; then
elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
# checks if the version
needs_update=1 #NODOC
fi
if [ $needs_update == 1 ]; then
rm -rf /usr/local/lib/z-push
rm -f /tmp/zpush.zip
rm -f /tmp/zpush-repo
echo installing z-push \(fmbiete fork\)...
wget -qO /tmp/zpush.zip https://github.com/fmbiete/Z-Push-contrib/archive/master.zip
unzip -q /tmp/zpush.zip -d /usr/local/lib/
mv /usr/local/lib/Z-Push-contrib-master /usr/local/lib/z-push
git clone -q https://github.com/fmbiete/Z-Push-contrib /tmp/zpush-repo
(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}
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
rm /tmp/zpush.zip;
curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master > /usr/local/lib/z-push/version
rm -f /tmp/zpush-repo
echo $TARGETHASH > /usr/local/lib/z-push/version
fi
# Configure default config.
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
# Configure BACKEND
rm -f /usr/local/lib/z-push/backend/combined/config.php

View File

@@ -3,7 +3,8 @@
import sys, getpass, urllib.request, urllib.error, json
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)
@@ -67,7 +68,8 @@ if 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.
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
print(user['email'], end='')
if "admin" in user['privileges']:
@@ -102,7 +104,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":
# Dump a list of admin users.
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']:
print(user['email'])

View 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)

View File

@@ -30,33 +30,78 @@ def generate_documentation():
color: #555;
}
h2, h3 {
margin-bottom: 1em;
margin-top: .25em;
margin-bottom: .75em;
}
p {
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 {
margin: 1em 1em 1.5em 1em;
color: black;
border: 0;
background: none;
font-size: 100%;
}
div.write-to {
margin: 1em;
border: 1px solid #999;
margin: 0 0 1em .5em;
}
div.write-to p {
padding: .5em;
margin: 0;
}
div.write-to .filename {
background-color: #EEE;
padding: .5em;
padding: .25em .5em;
background-color: #666;
color: white;
font-family: monospace;
font-weight: bold;
}
div.write-to .filename span {
font-family: sans-serif;
font-weight: normal;
}
div.write-to pre {
padding: .5em;
margin: 0;
padding: .5em;
border: 1px solid #999;
border-radius: 0;
font-size: 90%;
}
pre.shell > div:before {
@@ -67,11 +112,15 @@ def generate_documentation():
</head>
<body>
<div class="container">
<div class="row">
<div class="row intro">
<div class="col-xs-12">
<h1>Build Your Own Mail Server From Scratch</h1>
<p>Here&rsquo;s how you can build your own mail server from scratch. This document is generated automatically from our setup script.</p>
<p>Here&rsquo;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>&rsquo;s setup script <a href="https://github.com/mail-in-a-box/mailinabox">source code</a>.</p>
<hr>
</div>
</div>
<div class="container instructions">
""")
parser = Source.parser()
@@ -80,7 +129,7 @@ def generate_documentation():
fn = parser.parse_string(line).filename()
except:
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
import sys
@@ -91,6 +140,13 @@ def generate_documentation():
print("""
<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>
$(function() {
$('.terminal').each(function() {
$(this).outerHeight( $(this).parent().innerHeight() );
});
})
</script>
</body>
</html>
""")
@@ -101,8 +157,13 @@ class HashBang(Grammar):
return ""
def strip_indent(s):
s = s.replace("\t", " ")
lines = s.split("\n")
try:
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]
return "\n".join(lines)
@@ -126,22 +187,25 @@ class Source(Grammar):
return BashScript.parse(self.filename())
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):
content = self[7].string
content = self[9].string
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):
grammar = (L("hide_output "), REF("BashElement"))
def value(self):
return self[1].value()
class SuppressedLine(Grammar):
class EchoLine(Grammar):
grammar = (OPTIONAL(SPACE), L("echo "), REST_OF_LINE, EOL)
def value(self):
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 ""
class EditConf(Grammar):
@@ -150,7 +214,7 @@ class EditConf(Grammar):
FILENAME,
SPACE,
OPTIONAL((LIST_OF(
L("-w") | L("-s") | L("-c ';'"),
L("-w") | L("-s") | L("-c ;"),
sep=SPACE,
), SPACE)),
REST_OF_LINE,
@@ -159,27 +223,14 @@ class EditConf(Grammar):
)
def value(self):
conffile = self[1]
options = [""]
mode = 1
for c in self[4].string:
if mode == 1 and c in (" ", "\t") and options[-1] != "":
# new word
options.append("")
elif mode < 0:
# escaped character
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"
options = []
eq = "="
if self[3] and "-s" in self[3].string: eq = " "
for opt in re.split("\s+", self[4].string):
k, v = opt.split("=", 1)
v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled
options.append("%s%s%s" % (k, eq, v))
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"
class CaptureOutput(Grammar):
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):
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):
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):
grammar = (ZERO_OR_MORE(SPACE), L("apt_install "), REST_OF_LINE, EOL)
@@ -213,13 +270,125 @@ class OtherLine(Grammar):
grammar = (REST_OF_LINE, EOL)
def value(self):
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):
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):
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):
grammar = (OPTIONAL(HashBang), REPEAT(BashElement))
def value(self):
@@ -228,22 +397,68 @@ class BashScript(Grammar):
@staticmethod
def parse(fn):
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
parser = BashScript.parser()
string = open(fn).read()
string = re.sub(r"\s*\\\n\s*", " ", string)
# tokenize
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)
parser = BashScript.parser()
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 += "".join(result.value())
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" \
% ("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 = 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"\$?STORAGE_ROOT", "<code><b>/path/to/user-data</b></code>", v)
v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", 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>")
return v

View File

@@ -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);
?>