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

Compare commits

...

428 Commits
v0.01 ... v0.10

Author SHA1 Message Date
Joshua Tauberer
47a5a44b9e v0.10
* SMTP Submission (port 587) began offering the insecure SSLv3 protocol due to a misconfiguration in the previous version.
* Roundcube now allows persistent logins using Roundcube-Persistent-Login-Plugin.
* ownCloud is updated to version 8.0.3.
* SPF records for non-mail domains were tightened.
* The minimum greylisting delay has been reduced from 5 minutes to 3 minutes.
* Users and aliases weren't working if they were entered with any uppercase letters. Now only lowercase is allowed.
* After installing an SSL certificate from the control panel, the page wasn't being refreshed.
* Backups broke if the box's hostname was changed after installation.
* Dotfiles (i.e. .svn) stored in ownCloud Files were not accessible from ownCloud's mobile/desktop clients.
* Fix broken install on OVH VPS's.
2015-06-01 18:05:41 -04:00
Joshua Tauberer
a0e6c7ceb6 fix downloading dotfiles through ownCloud's webdav
fixes #414
2015-05-30 18:03:37 +00:00
Joshua Tauberer
49aa367ffa merge #422 - Add persistent login functionality to roundcube 2015-05-30 14:07:50 +00:00
Joshua Tauberer
83b36f2c3a simplify the roundcube updating logic, changelog entry for roundcube persistent login 2015-05-30 14:07:36 +00:00
Joshua Tauberer
2b341d884f merge #396 - allow the backup process to work after a hostname change 2015-05-30 13:55:08 +00:00
Joshua Tauberer
141a09b31e changelog, comments for duplicity --allow-source-mismatch 2015-05-30 13:46:39 +00:00
Joshua Tauberer
6378ec4bbd Merge pull request #423 from BrianZachary/master
Update README.md
2015-05-29 16:53:38 -04:00
BrianZachary
603fb1c698 Update README.md
Added latest front page appearance of Mail-In-A-Box to README.md
2015-05-29 16:43:14 -04:00
Joaquin Bravo
67b4ea947b Add persistent login functionality to roundcube 2015-05-29 14:49:40 -05:00
Joshua Tauberer
4075b7c78a Merge pull request #421 from samrobotmesh/patch-1
Echange -> Exchange
2015-05-29 10:59:25 -04:00
Sam
6499eba0cb Echange -> Exchange 2015-05-29 07:36:53 -07:00
Joshua Tauberer
980626aa40 Merge branch 'postgrey/delay/clean' of https://github.com/Xoib/mailinabox
Closes #413.
2015-05-29 13:00:51 +00:00
Eric Mill
3f329bc1a8 fix typos 2015-05-29 01:38:42 -04:00
Joshua Tauberer
69de67b1c2 link security.md from the readme 2015-05-28 21:41:23 -04:00
Joshua Tauberer
7158f9a8d9 security.md: add links to appropriate source files in various places to make it easier to inspect the code to verify the statements; unfortunately line numbers will drift but it would be nice if we could link right to line numbers 2015-05-28 21:39:50 -04:00
Joshua Tauberer
bb75bd7167 more security details 2015-05-28 21:39:50 -04:00
Joshua Tauberer
4fa58169f1 after installing an SSL certificate from the control panel the page wasn't being refreshed, broken in ec73c171c7 2015-05-28 18:45:53 +00:00
Joshua Tauberer
564040897f Merge pull request #420 from dhpiggott/increase-dmarc-and-spf-strictness
Make SPF forbid any outbound mail from non-mail domains
2015-05-28 13:17:14 -04:00
David Piggott
f78bbab289 Make SPF forbid any outbound mail from non-mail domains 2015-05-28 18:11:44 +01:00
Joshua Tauberer
d3c82d7363 Merge pull request #419 from dhpiggott/improve-dmarc-and-spf-descriptions
Improve DMARC and SPF record descriptions
2015-05-28 13:06:44 -04:00
David Piggott
7b9b978a6d Improve DMARC and SPF record descriptions 2015-05-28 16:34:58 +01:00
Joshua Tauberer
45d47818ca add changelog entry for 4f98d470a0 2015-05-28 13:12:57 +00:00
Joshua Tauberer
202c4a948b our users/aliases database is case sensitive - force new users/aliases to lowercase
Unfortunately our users/aliases database is case sensitive. (Perhaps I should have defined the columns with COLLATE NOCASE, see https://www.sqlite.org/datatype3.html.) Postfix always queries the tables in lowecase, so mail delivery would fail if a user or alias were defined with any capital letters. It would have also been possible to add multiple euqivalent addresses into the database with different case.

This commit rejects new mail users that have capital letters and forces new aliases to lowecase. I prefer to reject rather than casefold user accounts so that the login credentials the user gave are exactly what goes into the database.

https://discourse.mailinabox.email/t/recipient-address-rejected-user-unknown-in-virtual-mailbox-table/512/4
2015-05-28 13:11:30 +00:00
Joshua Tauberer
b5269bb28e Merge pull request #418 from dhpiggott/aliases-template-tweak
Use lowercase h for consistency in aliases template - it reads better…
2015-05-28 08:56:44 -04:00
David Piggott
d6c5f09a1a Use lowercase h for consistency in aliases template - it reads better (IMO!)
This also includes fixes for a typo and some whitespace inconsistencies in
mailconfig.py. In fact the capitalisation change and those fixes are the
remnants of a patch I had been running that changed the default aliases - it
was through developing it that I found the issues.

(I wanted to bring the number of patches I apply before deploying to zero and
in the case of this one I've come to view the way MIAB already is as superior,
so I've undone the core of my patch and these tiny issues are all that remain).
2015-05-28 13:46:15 +01:00
Xoib
11546b97bb softer the greylisting delay restriction
A lot of legit mail servers try again between 200 and 285 seconds, then
3 hours later. Why? RFC is not strict about retry timer so postfix and
other MTA have their own intervals. To fix the problem of receiving
these e-mail really latter, I reduced the delay of postgrey to
180 seconds (default is 300 seconds).
2015-05-26 16:10:14 +02:00
Joshua Tauberer
d6f26609fc Merge pull request #410 from stevesbrain/master
Fixing minor misspelling of the word: encrypted
2015-05-24 22:12:16 -04:00
StevesMonkey
05438d047d Fixing minor misspelling of the word: encrypted 2015-05-25 10:15:57 +09:30
Joshua Tauberer
a5ef64919a ppa: build the dovecot-lucene package 2015-05-23 14:03:14 -04:00
Joshua Tauberer
e132125cf3 ppa: move build to /tmp 2015-05-23 12:55:55 -04:00
Joshua Tauberer
01b5512ac7 add @Jonty's .deb patch for building dovecot lucene 2015-05-23 12:14:31 -04:00
Joshua Tauberer
a0c7e63d78 best guess at what clients are supported by the tls settings used 2015-05-22 17:36:55 -04:00
Joshua Tauberer
8ba5f2ffa7 add security.md and clean up README 2015-05-22 16:53:13 -04:00
Joshua Tauberer
2c44333679 compare tls ciphers against Mozilla's recommendations 2015-05-20 19:41:04 -04:00
Joshua Tauberer
610be9cf17 record current TLS settings from my box 2015-05-20 18:31:46 -04:00
Joshua Tauberer
eb5e8fe388 the switch of smtpd_tls_security_level may to encrypt for submission broke smtpd_tls_protocols
The submission port began offering SSLv3.

With `encrypt`, the smtpd_tls_protocols option is ignored and smtpd_tls_mandatory_protocols must be set instead.

see e39b777abc
2015-05-20 22:27:11 +00:00
Joshua Tauberer
c999c6082f tweak unhackable language, see #402 2015-05-19 11:18:53 -04:00
Joshua Tauberer
3b86b3fe66 bump to email_validator 0.1.0-rc5 2015-05-19 08:37:17 -04:00
Joshua Tauberer
0a71dca825 add preliminary build tools for a Mail-in-a-Box PPA
Starting with my dnswl.org modifications to postgrey.
2015-05-18 22:15:09 -04:00
Joshua Tauberer
4f98d470a0 '/dev/stdout' does not exist on some systems (!)
The OVH VPS provider creates systems without /dev/stdout. I have never seen that before. But fine. We were passing it as a command line option to `openssl req`, but outputting to stdout is the default so it's not necessary to specify /dev/stdout.

Fixes #277. Also https://discourse.mailinabox.email/t/500-internal-server-error/475/10.
2015-05-16 13:34:47 +00:00
Joshua Tauberer
57abae3999 if the main ssl cert is expiring soon, the end of setup would display the control panel instructions as if the cert were self-signed 2015-05-14 19:16:31 +00:00
Xoib
202e49a897 allow the backup process to work after a hostname change 2015-05-13 13:52:23 +02:00
Joshua Tauberer
13093f1732 update to ownCloud 8.0.3
see #375
2015-05-11 13:00:40 +00:00
Joshua Tauberer
837d327c1e v0.09
=====

May 8, 2015

Mail:

* Spam checking is now performed on messages larger than the previous limit of 64KB.
* POP3S is now enabled (port 995).
* Roundcube is updated to version 1.1.1.
* Minor security improvements (more mail headers with user agent info are anonymized; crypto settings were tightened).

ownCloud:

* Downloading files you uploaded to ownCloud broke because of a change in ownCloud 8.

DNS:

* Internationalized Domain Names (IDNs) should now work in email. If you had custom DNS or custom web settings for internationalized domains, check that they are still working.
* It is now possible to set multiple TXT and other types of records on the same domain in the control panel.
* The custom DNS API was completely rewritten to support setting multiple records of the same type on a domain. Any existing client code using the DNS API will have to be rewritten. (Existing code will just get 404s back.)
* On some systems the `nsd` service failed to start if network inferfaces were not ready.

System / Control Panel:

* In order to guard against misconfiguration that can lead to domain control validation hijacking, email addresses that begin with admin, administrator, postmaster, hostmaster, and webmaster can no longer be used for (new) mail user accounts, and aliases for these addresses may direct mail only to the box's administrator(s).
* Backups now use duplicity's built-in gpg symmetric AES256 encryption rather than my home-brewed encryption. Old backups will be incorporated inside the first backup after this update but then deleted from disk (i.e. your backups from the previous few days will be backed up).
* There was a race condition between backups and the new nightly status checks.
* The control panel would sometimes lock up with an unnecessary loading indicator.
* You can no longer delete your own account from the control panel.

Setup:

* All Mail-in-a-Box release tags are now signed on github, instructions for verifying the signature are added to the README, and the integrity of some packages downloaded during setup is now verified against a SHA1 hash stored in the tag itself.
* Bugs in first user account creation were fixed.
2015-05-08 08:10:39 -04:00
Joshua Tauberer
e39b777abc require TLS on SMTP submission (port 587) to prevent accidental client misconfiguration, although this has no other practical consequences since without TLS clients couldn't authenticate anyway 2015-05-06 00:25:03 +00:00
Joshua Tauberer
7ca42489ae drop legacy, export-grade, and anonymous ciphers from SMTP (port 25, opportunistic)
Even though SMTP (on port 25) is typically opportunistic and a MitM attack can't be prevented, we may as well only offer ciphers that provide some level of security. If a client is so old or misconfigured that it doesn't support newer ciphers, it should hopefully fall back to a non-TLS connection.

Postfix's default was basically anything goes (anonymous and 40-bit ciphers!). Google's MTA's only offer ciphers at 112 bits at greater, and this change approximates that with Postfix's "medium" setting.

Fixes #371
2015-05-05 23:50:07 +00:00
Joshua Tauberer
8c6363f792 bad ciphers were allowed in smtp submssion
This disallows aNULL and other bad ciphers in the Postfix submission server.

I missed an option in 45e93f7dcc recommended by the blog post I was reading.

Fixes #389.
2015-05-05 23:14:59 +00:00
Joshua Tauberer
cbb7f29f96 add 'ip-transparent: yes' to nsd.conf
https://discourse.mailinabox.email/t/nsd-service-not-started-at-startup-dns-not-working/449
2015-05-04 11:24:40 +00:00
Joshua Tauberer
8886c9b6bc move the server: block of nsd.conf out of the management daemon and into the setup scripts 2015-05-04 11:24:40 +00:00
Joshua Tauberer
a07de38e80 remove workaround for buggy nsd installation
Prior to nsd 4.0.1-1ubuntu0.1, we had to create the nsd user before installing the nsd package.

This was our issue #25 (see 4e6037c0e1, c7e1e29d) and I reported it upstream at https://bugs.launchpad.net/ubuntu/+source/nsd/+bug/1311886. The new package was published by Ubuntu on 2015-01-15 so this work-around is no longer needed.
2015-05-04 11:24:40 +00:00
Joshua Tauberer
1f08997a9e need my new email_validator library during questions 2015-05-03 11:02:23 -04:00
Joshua Tauberer
1b2d07d81d update CHANGELOG 2015-05-03 14:33:22 +00:00
Joshua Tauberer
fc32cf5bcc permit the first user account to be a domain control validation address because a) it will necessarily be an admin and b) the user doesn't know the rules yet 2015-05-03 14:21:36 +00:00
Joshua Tauberer
ce94ef38b2 anonymize X-Pgp-Agent, Mime-Version outgoing mail headers; fixes #342
I don't have a mail client that sets Mime-Version with a user agent string so I couldn't really test.
2015-05-03 14:03:59 +00:00
Joshua Tauberer
1e9c587b92 rewrite the DNS API to permit setting multiple records of the same type on the same domain
e.g. multiple TXT records

fixes #333
2015-05-03 13:43:38 +00:00
Joshua Tauberer
9f1d633ae4 re-do the custom DNS get/set routines so it is possible to store more than one record for a qname-rtype pair, like multiple TXT records 2015-05-03 13:43:38 +00:00
Joshua Tauberer
f01189631a management api: make json responses nicely formatted
Better while debugging.
2015-05-03 13:43:38 +00:00
Joshua Tauberer
542877ee46 use the font-awesome .fa-spinner.fa-pulse classes for the AJAX loading indicator, rather than the static glyphicon-time icon 2015-05-03 13:43:38 +00:00
Joshua Tauberer
f1760b516d control panel: sometimes the ajax loading modal would show after operations were already done
Needed to add the clearQueue flag to jQuery's stop() method
2015-05-03 13:43:38 +00:00
Joshua Tauberer
f0143fd6c9 bump version of my email_validator library 2015-04-29 21:18:14 +00:00
Joshua Tauberer
febfa72d60 race condition between backups and status checks - connection refused
At the end of the backup, wait a bit for dovecot and postfix to finish restarting.

Hopefully fixes #381.
2015-04-29 21:06:38 +00:00
Joshua Tauberer
c03e00035f prevent archiving of the user's own account because they'll lose access to the control panel 2015-04-28 07:17:21 -04:00
Joshua Tauberer
2f8866ef32 if there are no users at all the warning on the control panel login screen was incorrect 2015-04-28 07:17:21 -04:00
Joshua Tauberer
f98afac6df if you make an API call with a user-specific API key (e.g. from control panel) but your account no longer exists on the system, there was an unhandled error
see 1039a08be6
2015-04-28 07:17:21 -04:00
Joshua Tauberer
5efd5abbe4 move the email address syntax validation for users and aliases into my new email_validator library (https://github.com/JoshData/python-email-validator) 2015-04-21 14:43:12 +00:00
Joshua Tauberer
6bb8f5d889 ownCloud 8 busted MOD_X_ACCEL_REDIRECT_ENABLED
see https://github.com/owncloud/core/issues/14976

We will need to update when ownCloud makes this better with MOD_X_ACCEL_REDIRECT_PREFIX.

See https://discourse.mailinabox.email/t/owncloud-can-not-read-uploaded-data/428.
2015-04-20 22:18:45 +00:00
Joshua Tauberer
35f4a49d10 my html5 stub was wrong; 8c3aed2846 2015-04-19 13:21:38 +00:00
Joshua Tauberer
a31d713fcc stricter validation of the domain parts of email addresses: only letters, numbers, and hyphens, and the TLD ends with a letter 2015-04-19 13:06:11 +00:00
Joshua Tauberer
6f38f7afc3 update CHANGELOG 2015-04-16 12:02:17 +00:00
Joshua Tauberer
e514ca0009 bump to Roundcube 1.1.1 2015-04-16 11:45:35 +00:00
Joshua Tauberer
8c3aed2846 update the control panel html template to my latest html5 stub
jquery 1.11.1, bootstrap 3.3.0, better accessibility, see https://github.com/JoshData/html5-stub
2015-04-11 15:40:19 -04:00
Joshua Tauberer
2cab9d5514 editconf.py: better error message if command line arguments are not valid 2015-04-11 15:25:11 -04:00
Joshua Tauberer
c38bdbb0c5 mistake in 31eec9fa1c #300 2015-04-11 15:24:15 -04:00
Joshua Tauberer
2a1704a0dc check that the downloaded ownCloud and roundcube files match a known SHA1 hash 2015-04-11 15:21:38 -04:00
Joshua Tauberer
36168b4609 add a 'backup --verify' command to run duplicity's verify command to check that the backup files are OK 2015-04-11 18:43:46 +00:00
Joshua Tauberer
bd498def76 backups now use duplicity's built-in gpg symmetric encryption
Merge branch 'dhpiggott-gpg-encrypt-backups'
2015-04-11 18:33:57 +00:00
Joshua Tauberer
d8279c48ac new backup method tweaks
* use the AES256 cipher, be explicit that only the first line of secret_key.txt is used, and sanity check that the passphrase is long enough
* change overship of the encrypted files to the user-data user
* simplify variable names in management/backup.py
* although I appreciate long comments I am trimming the commentary about the backup migration
* revise the control panel template to not refer to the old unencrypted files
* add CHANGELOG entry
2015-04-11 18:32:22 +00:00
David Piggott
4232245546 Use built in duplicity encryption (GPG) for backups, closes #362, closes #363
[Josh merged some subsequent commits:]

* Guard via idempotency against termination between migration operations
* Final corrections and tweaks
* Pass passphrase through to all duplicity calls

Empirical evidence (a failed cron job) shows that cleanup requires the
passphrase (so it presumably needs to decrypt metadata), and though
remove-older-than has been working fine without it, it won't do any harm
to set it in case that changes or there are any special cases.

* Add back the archive-dir override but locate it at STORAGE_ROOT/backup/cache
2015-04-11 17:51:44 +00:00
Joshua Tauberer
2d1186e55d increase spampd maximum message size from 64KB to 500KB, matching the spamc default
see https://discourse.mailinabox.email/t/allow-spamassassin-to-scan-emails-larger-than-250kb/391
2015-04-09 14:46:02 +00:00
Joshua Tauberer
072aeca1be prevent accidental domain control validation hijacking by limiting use of admin@ etc. addresses in users/aliases 2015-04-09 14:46:02 +00:00
Joshua Tauberer
cb656f9ef4 in status checks replace '=>' with a Unicode arrow and tweak how aliases are reported 2015-04-09 14:46:02 +00:00
Joshua Tauberer
322a5779f1 store IDNs (internationalized domain names) in IDNA (ASCII) in our database, not in Unicode
I changed my mind. In 1bf8f1991f I allowed Unicode domain names to go into the database. I thought that was nice because it's what the user *means*. But it's not how the web works. Web and DNS were working, but mail wasn't. Postfix (as shipped with Ubuntu 14.04 without support for SMTPUTF8) exists in an ASCII-only world. When it goes to the users/aliases table, it queries in ASCII (IDNA) only and had no hope of delivering mail if the domain was in full Unicode in the database. I was thinking ahead to SMTPUTF8, where we *could* put Unicode in the database (though that would prevent IDNA-encoded addressing from being deliverable) not realizing it isn't well supported yet anyway.

It's IDNA that goes on the wire in most places anyway (SMTP without SMTPUTF8 (and therefore how Postfix queries our users/aliases tables), DNS zone files, nginx config, CSR 'CN' field, X509 Common Name and Subject Alternative Names fields), so we should really be talking in terms of IDNA (i.e. ASCII).

This partially reverts commit 1bf8f1991f, where I added a lot of Unicode=>IDNA conversions when writing configuration files. Instead I'm doing Unicode=>IDNA before email addresses get into the users/aliases table. Now we assume the database uses IDNA-encoded ASCII domain names. When adding/removing aliases, addresses are converted to ASCII (w/ IDNA). User accounts must be ASCII-only anyway because of Dovecot's auth limitations, so we don't do any IDNA conversion (don't want to change the user's login info behind their back!). The aliases control panel page converts domains back to Unicode for display to be nice. The status checks converts the domains to Unicode just for the output headings.

A migration is added to convert existing aliases with Unicode domains into IDNA. Any custom DNS or web settings with Unicode may need to be changed.

Future support for SMTPUTF8 will probably need to add columns in the users/aliases table so that it lists both IDNA and Unicode forms.
2015-04-09 14:46:02 +00:00
Joshua Tauberer
e41df28bf2 if a migration fails, dont continue setup 2015-04-09 14:46:02 +00:00
Joshua Tauberer
d3239b49ce Merge pull request #372 from hnk/fix-spaces_in_password
fix(read_password): regex check for spaces, quotes
2015-04-09 09:08:31 -04:00
Joshua Tauberer
d11be61d94 Add POP3S support (merge w/ adjustments)
* Add pop3s to the ufw firewall rules.
* Updated some comments.
* Updated CHANGELOG.

Merge branch 'master' of https://github.com/pichak/mailinabox
2015-04-09 08:19:20 -04:00
Morteza Milani
916063a79b Better documentation for POP3 settings, UIDL.
UIDL assigns a unique string to each email. This allows emails to
be left on the server after a client downloads them.
2015-04-08 21:32:14 -07:00
Joshua Tauberer
5aa0bf2d14 add instructions for verifying the signed tags to the README 2015-04-01 10:38:09 -04:00
Joshua Tauberer
f3ad6b4acc Version 0.08
CHANGELOG
=========

v0.08 (April 1, 2015)
---------------------

Mail:

* The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube.
* Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed.
* The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error.

DNS:

* Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records.

System:

* ownCloud updated to version 8.0.2.
* Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban.
* Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account).

Control panel:

* The new check that system services are running mistakenly checked that the Dovecot Managesieve service is publicly accessible. Although the service binds to the public network interface we don't open the port in ufw. On some machines it seems that ufw blocks the connection from the status checks (which seems correct) and on some machines (mine) it doesn't, which is why I didn't notice the problem.
* The current backup chain will now try to predict how many days until it is deleted (always at least 3 days after the next full backup).
* The list of aliases that forward to a user are removed from the Mail Users page because when there are many alises it is slow and times-out.
* Some status check errors are turned into warnings, especially those that might not apply if External DNS is used.
2015-04-01 10:14:34 -04:00
Joshua Tauberer
ec039719de prevent caching of ajax responses in the control panel
GET requests might be cached. Definitely happens on Internet Explorer. Makes it look like the user is getting unauthorized access.

See https://discourse.mailinabox.email/t/fresh-install-can-login-to-webmail-but-not-admin/394/4.
2015-03-31 14:52:11 +00:00
Sascha Reynolds
6989df0af3 fix(read_password): regex check for spaces, quotes
* Passwords must be at least four characters. So we need to check them
here to ensure that first user creation works during initial setup
* Change quotes to match rest of code
2015-03-30 19:59:07 +02:00
Joshua Tauberer
14b16b2f36 allow custom DNS TXT records for SPF, DKIM, and DMARC to override the ones we want to set
fixes #323
fixes #324
2015-03-30 01:20:03 +00:00
Joshua Tauberer
cbc7e280d6 set the SPF record after custom DNS records so that the SPF record doesn't prevent all custom TXT records from coming in 2015-03-30 01:18:05 +00:00
Joshua Tauberer
f4fa9c93a0 Merge pull request #366 from hnk/hnk-patch-read_password
Change read_password() logic to catch short passwords
2015-03-29 14:12:07 -04:00
Hnk Reno
6c64723d7c Change read_password() logic to better catch improper passwords
Currently read_password does not verify password length. But further down the chain, passwords are checked to make sure they are longer than four characters.

If during initial setup, the user enters a password that is shorter than four characters, this will not be caught here, but when the script actually calls management/mailconfig.py to add the user, it will fail without a chance to correct the short password.

The setup script will then continue without an inital user being created and this will confuse users.
2015-03-29 18:54:37 +02:00
Joshua Tauberer
3d21f2223e status checks: turn missing DNSSEC into a warning instead of an error; omit an error about missing TLSA if DNSSEC isn't in use; if DNSSEC is in use, make a missing TLSA record a warning instead of an error 2015-03-28 11:24:05 -04:00
Joshua Tauberer
710a69b812 turn some nameserver status check errors into warnings if the domain resolves correctly since the user might be using External DNS, closes #330 2015-03-28 11:23:59 -04:00
Joshua Tauberer
dd6a8d9998 upgrade to ownCloud 8.0.2
The contacts and calendar apps are now maintained outside of ownCloud core, so we now pull them in from github tags and must enable them explicitly.
2015-03-28 11:08:57 -04:00
Joshua Tauberer
9f32e5af0a the install of roundcube vacation_sieve requires that we install git
see a8669197dd
2015-03-28 09:54:52 -04:00
Joshua Tauberer
298e19598b small bug in the new system status checks show-changes command
see 4d22fb9b2a

fixes #360
2015-03-22 14:03:12 +00:00
Joshua Tauberer
680191d7cb drop the list of aliases from the users control panel page because with more than 50 aliases it seems to be so slow it times out
see https://discourse.mailinabox.email/t/small-bug-in-admin-panel-when-49-aliases/378
2015-03-22 13:59:05 +00:00
Joshua Tauberer
81d6d69b85 update CHANGELOG 2015-03-22 13:58:24 +00:00
Joshua Tauberer
6df72bf4ac create the Trash folder on new user creation (fixes #359) 2015-03-22 13:33:17 +00:00
Joshua Tauberer
01f2451349 provide a better error message when creating a user account with non-ASCII characters 2015-03-22 12:33:06 +00:00
Joshua Tauberer
dcd971d079 the opendmarc miter should run on incoming mail only
I added OpenDMARC's milter in fba4d4702e. But this started
setting Authentication-Results headers on outbound mail with failures. Not sure why it
fails at that point, but it shouldn't be set at all. The failure might cause recipients
to junk the mail. See #358.

This commit removes the milter from the SMTP submission (port 587) listener.
2015-03-21 16:14:01 +00:00
Joshua Tauberer
4d22fb9b2a run status checks each night and email the administrator with the changes from the previous day's results 2015-03-21 16:02:42 +00:00
Joshua Tauberer
c18d58b13f backups: predict when the next backup will occur 2015-03-21 15:22:45 +00:00
Joshua Tauberer
b539c2df70 Merge pull request #347 from Toilal/feat/start-enhancements
If the migration file is missing but the storage directory exists, assume this is a fresh directory -- don't bother trying to migrate, and do write the migration file with the current migration ID.
2015-03-19 11:57:24 -04:00
Toilal
64fdb4ddc1 Behave nicely when mailinabox.version file is missing 2015-03-09 08:54:32 +01:00
Joshua Tauberer
a8669197dd added Roundcube plugin vacation_sieve
Merge branch 'master' of https://github.com/zealot128-os/mailinabox

Closes #334
2015-03-08 19:15:20 +00:00
Joshua Tauberer
2412c92772 enable fail2ban for ssh and dovecot
Merge branch 'master' of https://github.com/h8h/mailinabox

see #353, #319
2015-03-08 18:40:17 +00:00
Joshua Tauberer
7c0ca42145 status checks: don't check that dovecot-sieve is publicly accessible 2015-03-08 18:35:33 +00:00
H8H
c443524ee2 Configure fail2ban jails to prevent dumb brute-force attacks against postfix, dovecot and ssh. See #319 2015-03-08 01:13:55 +01:00
Joshua Tauberer
e2fa01e0cf Merge pull request #348 from benschumacher/master
Update MX records using DNS Update API / Management UI
2015-03-04 13:42:02 -05:00
Ben Schumacher
6558f05d1d Give the DNS update tool the ability to customize MX records. Useful if you want a subdomain to send mail to another host. 2015-03-04 13:32:35 -05:00
Joshua Tauberer
1be0f39be0 prep for v0.07 tag 2015-02-28 17:09:12 -05:00
Joshua Tauberer
d01001f2a5 some more CHANGELOG entries 2015-02-28 17:06:09 -05:00
Joshua Tauberer
7c85694d60 Merge pull request #332 from mathuin/better-mx-check
Changed MX check to respect priorities other than 10.
2015-02-23 07:56:24 -05:00
Jack Twilley
b2fcd4c9e5 Now supports domains with multiple MX records.
The status check on MX records now correctly handles domains with
multiple MX records.
2015-02-22 17:05:09 -08:00
Stefan Wienert
ba8123f08a reduced diff noise 2015-02-21 16:06:56 +01:00
Stefan Wienert
e2879a8eb1 made the setup repeatable 2015-02-21 16:05:47 +01:00
Stefan Wienert
eab8652225 added vacation_sieve plugin for Roundcube 2015-02-21 16:01:27 +01:00
Jack Twilley
ead6f96513 Changed MX check to respect priorities other than 10.
Reordered the if a little, added some string parsing, and modified the
OK text to include a warning.
2015-02-20 11:29:28 -08:00
Joshua Tauberer
7ec662c83f status checks: use a worker pool that lives across flask requests, see #327 2015-02-18 16:42:33 +00:00
Joshua Tauberer
348d2b8701 Merge pull request #326 from dhpiggott/custom-dns-filter-secondary-nameserver
Do not show '_secondary_nameserver' in Custom DNS table
2015-02-17 08:31:34 -05:00
David Piggott
12f0dcb23b Do not show '_secondary_nameserver' in Custom DNS table
It's redundant and potentially confusing, as any secondary NS shows in "Using a
Secondary Nameserver".
2015-02-17 13:28:48 +00:00
Joshua Tauberer
449a538e6b if a CNAME is set for a domain, don't create a website for that domain (just like A/AAAA records) 2015-02-17 00:48:26 +00:00
Joshua Tauberer
3c50c9a18b when serving a 'www.' domain, check if the parent domain's ssl certificate can be used besides checking PRIMARY_HOSTNAME
Removing buy_certificate.py which is not working and I don't want to update its call signatures.
2015-02-17 00:42:25 +00:00
Joshua Tauberer
3c10ec70a5 update comment 2015-02-17 00:08:04 +00:00
Joshua Tauberer
1a59f343c0 adding entries to the CHANGELOG 2015-02-16 23:58:17 +00:00
Joshua Tauberer
fba4d4702e install opendmarc to add Authentication-Results headers for DMARC too 2015-02-16 23:17:44 +00:00
Joshua Tauberer
143bbf37f4 all mail domains, not just (top-level) zones, must have an entry in the opendkim key tables so that such outgoing mail gets signed
If you had both x.y.com and y.com configured here, x.y.com mail would not get DKIM-signed.
2015-02-16 18:13:51 -05:00
Joshua Tauberer
fd3ad267ba if a domain has a catch-all or domain alias then we no longer force the creation of postmaster@ and so we should not be checking for its existence in the status checks
see 85a40da83c
2015-02-15 19:07:10 -05:00
Joshua Tauberer
330583f71d status checks: if a service isn't available publicly, check if it is available on the loopback interface to distinguish not running from not accessible 2015-02-13 09:30:25 -05:00
Joshua Tauberer
d775f90f0c prevent apt from asking the user any questions
Add additional options to really prevent apt from asking questions, which causes setup to hang because stdin/out have been redirected.

fixes #270, #291
2015-02-13 13:41:52 +00:00
Joshua Tauberer
e096144713 Outlook 2007 or later on Windows 7 and later
fixes #308
2015-02-13 13:29:01 +00:00
Joshua Tauberer
7ce30ba888 roundcube 1.1.0 2015-02-13 13:22:46 +00:00
Joshua Tauberer
6a3ec1d874 updating CHANGELOG 2015-02-13 13:20:55 +00:00
Joshua Tauberer
575d3a66c6 more on being smarter about waiting for the management daemon to start
cc333b3965 worked for fresh systems, but if the system already had the daemon running the api.key file would already exist and the test would pass to early. Now removing the file first.

fixes #322
2015-02-13 13:11:03 +00:00
Joshua Tauberer
cc333b3965 be smarter about waiting for the management daemon to start before accessing it 2015-02-10 10:03:07 -05:00
Joshua Tauberer
351758b3bd typo
typo in "roudcube"
2015-02-10 09:27:36 -05:00
Joshua Tauberer
94053d8432 Merge pull request #317 from bizonix/master
Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
2015-02-09 12:53:32 -05:00
BiZoNiX
e14b2826e0 Disable viewing dotfiles (.htaccess, .svn, .git, etc.) 2015-02-09 19:41:42 +02:00
Joshua Tauberer
150611123a typo/text tweak 2015-02-05 09:17:48 -05:00
Joshua Tauberer
abfc17ee62 web admin: simplify the instructions for creating a separate web directory for particular sites by moving it into a modal 2015-02-05 09:12:55 -05:00
Joshua Tauberer
97be9c94b9 if the user has set a http proxy or redirect on the root path of a domain, using custom.yaml, skip the domain from the static hosting panel because it wont be serving any static files 2015-02-05 08:55:57 -05:00
Joshua Tauberer
21b00e8fbb if a custom A record is set, dont put in a default AAAA record pointing to the box because it will probably be wrong --- the user should either set an AAAA record or let the domain not resolve on IPv6 2015-02-03 21:51:19 -05:00
Joshua Tauberer
01636c2e4b Merge branch 'h8h-master'
I squashed some commits together and modified the commit message...
2015-02-03 23:54:17 +00:00
H8H
005315cd29 removed hardcoded /home directory to apply the existing configuration options for STORAGE_USER/ROOT if they exist
Highest priority: the pre set STORAGE_ROOT/USER, midmost priority: the config settings, lowest priority: the default one.

fixes #309; closes #311
2015-02-03 23:52:02 +00:00
Ian Beringer
20d20df829 allow for non-standard ssh port in status check
closes #313
2015-02-01 23:06:56 +00:00
Joshua Tauberer
f945a1bc6b Merge pull request #312 from ikarus23/master
hide nginx version an OS information for better privacy
2015-02-01 14:25:39 -05:00
ikarus
3a09b04786 hide nginx version an OS information for better privacy. 2015-02-01 20:13:03 +01:00
Joshua Tauberer
82e752395b Merge pull request #310 from ikarus23/master
do better redirection from http to https
2015-01-31 19:58:31 -05:00
ikarus
e330abd587 do better redirection from http to https
Redirect using the 'return' directive and the built-in
variable '$request_uri' to avoid any capturing, matching
or evaluation of regular expressions.

It's best practice. See: http://wiki.nginx.org/Pitfalls#Taxing_Rewrites
2015-02-01 01:32:07 +01:00
Joshua Tauberer
16422b4055 adding items to the CHANGELOG 2015-01-31 21:36:37 +00:00
Joshua Tauberer
b9ca74c915 implement Mozilla (e.g. Thunderbird) autoconfiguration file
fixes #241
2015-01-31 21:33:18 +00:00
Joshua Tauberer
7e05d7478f run status checks asynchronously so that they finish faster, since many checks are waiting on network replies and ought not to block the whole thing 2015-01-31 20:42:43 +00:00
Joshua Tauberer
8fd98d7db3 status checks: s/env['out']/output/ 2015-01-31 20:42:43 +00:00
Joshua Tauberer
1039a08be6 /admin login now issues a user-specific key for future calls (rather than providing the system-wide API key or passing the password on each request) 2015-01-31 20:42:43 +00:00
Joshua Tauberer
023b38df50 split management daemon authorization from authentication and use 'doveadm pw' rather than 'doveadm auth test' so that it is decoupled from dovecot's login mechanism
This was done to pave the way for two-factor authentication, but that's still a ways off.
2015-01-31 20:41:41 +00:00
Joshua Tauberer
3187053b3a dont save the CSR generated to make self-signed certificates for non-primary domains (it has no value and might be confusing) 2015-01-31 13:27:06 +00:00
Joshua Tauberer
a3e526e818 Merge pull request #307 from pierreozoux/master
Typo
2015-01-29 12:52:37 -05:00
pierreozoux
f6d4621834 Typo 2015-01-29 17:03:20 +00:00
Joshua Tauberer
d075113c1a Merge pull request #306 from dhpiggott/fix-backup-typos
Fix typos in backup status template
2015-01-29 08:22:44 -05:00
David Piggott
63f2abd923 Fix typos in backup status template 2015-01-29 09:25:12 +00:00
Morteza Milani
31eec9fa1c Add POP3s support 2015-01-25 23:37:01 -08:00
Joshua Tauberer
624cc7876a Merge pull request #297 from kurthuwig/fix_typo
Fix typo in mail-guide.html
2015-01-21 08:52:39 -05:00
Kurt Huwig
d3059c810f Fix typo in mail-guide.html
Sercurity -> Security
2015-01-21 08:23:26 +01:00
Joshua Tauberer
85a40da83c catch-all aiases and domain aliases should not require postmaster@ and admin@ aliases because they'll forward anyway 2015-01-19 23:32:36 +00:00
Joshua Tauberer
1bf8f1991f internationalized domain names (DNS, web, CSRs, normalize to Unicode in database, prohibit non-ASCII characters in user account names)
* For non-ASCII domain names, we will keep the Unicode encoding in our users/aliases table. This is nice for the user and also simplifies things like sorting domain names (using Unicode lexicographic order is good, using ASCII lexicogrpahic order on IDNA is confusing).
* Write nsd config, nsd zone files, nginx config, and SSL CSRs with domains in IDNA-encoded ASCII.
* When checking SSL certificates, treat the CN and SANs as IDNA.
* Since Chrome has an interesting feature of converting Unicode to IDNA in <input type="email"> form fields, we'll also forcibly convert IDNA to Unicode in the domain part of email addresses before saving email addresses in the users/aliases tables so that the table is normalized to Unicode.
* Don't allow non-ASCII characters in user account email addresses. Dovecot gets confused when querying the Sqlite database (which we observed even for non-word ASCII characters too, so it may not be related to the character encoding).
2015-01-19 23:31:55 +00:00
Joshua Tauberer
d155aa8745 if all system services are running, say so in the status checks rather than being totally silent 2015-01-19 22:04:25 +00:00
Joshua Tauberer
c0bfd6d15f bring CHANGELOG up to date 2015-01-19 22:04:25 +00:00
Joshua Tauberer
24cc108147 if a custom CNAME record is set, don't add a default A/AAAA record, e.g. for 'www'
see https://discourse.mailinabox.email/t/multiple-domains-in-mail-in-a-box-with-the-domains-being-hosted-elsewhere/56/18
2015-01-19 22:04:21 +00:00
Joshua Tauberer
b02d7d990e install cron in case it isn't already installed 2015-01-11 20:00:11 +00:00
Joshua Tauberer
87f82addbc preflight memory check: units problems
/proc/meminfo reports kibibytes. Lower the minimum memory requirement so that 768 MB (not MiB) also is allowed.

Report the detected memory in MB (not KiB), to be clearer.

Fixes #289.
2015-01-11 14:13:35 +00:00
Joshua Tauberer
09713e8eab status checks: check that system services are running
If bind9 isn't running, dont proceed with other checks because we can't do DNS checks. Even though we skip, add error handling so that a failed call to rndc doesn't crash and that a timeout in a DNS check doesn't crash the status checks.
2015-01-11 14:13:35 +00:00
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
Joshua Tauberer
c1ccd22531 put a start script at /usr/local/bin/mailinabox 2014-09-22 16:37:12 -04:00
Joshua Tauberer
01c964bfe3 update bootstrap.sh for next tag 2014-09-22 16:35:07 -04:00
Joshua Tauberer
6c59294e7b more readable bash 2014-09-21 16:05:11 -04:00
Joshua Tauberer
9d40a12f44 first pass at making readable documentation by parsing the bash scripts 2014-09-21 13:43:31 -04:00
Joshua Tauberer
c2ddabe683 fix ajax loading indicator positioning 2014-09-21 17:41:46 +00:00
Joshua Tauberer
846768efcb admin: update user's password from the admin 2014-09-21 17:24:01 +00:00
Joshua Tauberer
8dfbb90f3a admin: simplify the users table a bit 2014-09-21 17:10:23 +00:00
Joshua Tauberer
c7c3bd33cf DNS API should reject qnames that aren't in a zone managed by the box
see https://discourse.mailinabox.email/t/set-www-a-and-other-dns-records-after-install/63/10
2014-09-21 13:37:30 +00:00
Joshua Tauberer
1637153566 make the DNS API a little clearer 2014-09-21 13:37:30 +00:00
Joshua Tauberer
05510f25a5 warn if a SSL cert is expiring in 30 days 2014-09-21 13:37:30 +00:00
Joshua Tauberer
b8ea7282b0 don't run apt-get update when generating the status checks output because it is so slow and should be update daily by cron anyway 2014-09-21 13:37:30 +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
Joshua Tauberer
dd91553689 open the firewall to an alternative SSH port if set
https://discourse.mailinabox.email/t/opening-up-a-custom-port-for-ssh-after-install/55/2
2014-09-20 08:26:10 -04:00
Joshua Tauberer
98651deea4 python3-dev is a dependency for many pip packages, including pyyaml, fixes #196 2014-09-17 21:56:09 +00:00
Joshua Tauberer
ff0c85615b correct typo in comment 2014-09-15 10:02:25 +00:00
Joshua Tauberer
16e2350fef revise the description of A records on domains: the A record must be present for good deliverability so that the envelope domain resolves, but it doesn't have to resolve to this machine 2014-09-15 06:00:50 -04:00
Joshua Tauberer
52b2e27451 Merge pull request #193 from waldyrious/patch-1
add link to contributors, remove duplicate "to"s
2014-09-13 20:54:26 -04:00
Waldir Pimenta
48bb8a90d2 add link to contributors, remove duplicate "to"s 2014-09-14 01:45:10 +01:00
Joshua Tauberer
941684f4d9 Merge pull request #192 from ch000/patch-1
Changed incomming-email-input to type text
2014-09-12 12:15:24 -04:00
Christian
9631fab7b2 Changed incomming-email-input to type text
The input type="email" validation won't allow "@example.com", which is needed for catch-all-aliases.
2014-09-12 18:08:33 +02:00
jkaberg
93a722f85b ownCloud (witch is based on SabreDAV) supports sync 2014-09-10 21:22:56 +02:00
Joshua Tauberer
4f4a8faa00 Merge pull request #188 from Bretos/master
update roundcube version
2014-09-10 09:04:27 -04:00
Bretos
467f04facb update roundcube version 2014-09-10 12:32:32 +02:00
Joshua Tauberer
196e42e8b5 don't automatically create an alias if a user account already exists by that name
In the event the first user is an address that we'd normally create as an alias,
we'd generate a loop from the alias to the administrative alias to the first user
account (which was the alias again).

hopefully fixes #186
2014-09-09 11:41:47 +00:00
Joshua Tauberer
f09da719f7 show the response from spamhaus.org in the status checks output 2014-09-08 20:27:26 +00:00
Joshua Tauberer
e9e95cbed5 tweak backup explanatory text 2014-09-08 20:12:31 +00:00
Joshua Tauberer
98fc449b49 only hold onto backups for 14 days (not 31) and show when the backups will be deleted in the control panel 2014-09-08 20:09:18 +00:00
Joshua Tauberer
bab8b515ea new logic for determining when to take a full backup 2014-09-08 19:42:54 +00:00
Joshua Tauberer
cce6bc02a8 add links to IANA tables for DNSSEC algorithm/digest number assignemnts 2014-09-07 10:59:20 -04:00
Joshua Tauberer
85d4fad030 add Hacker News links to README 2014-09-07 10:59:20 -04:00
Joshua Tauberer
7ea956d3bc install network-checks's dependencies
Since it runs before the real setup begins, we must make sure that packages are installed.

Also removing bind9-host's installation from system.sh. In 189dd6000e I added this so we could use `host`
to aid Docker autoconfiguration. Docker support was since removed but this hadn't gotten removed, which lead me to think it was
normally installed by Ubuntu. It's now installed in `network-checks.sh`.

fixes #180
2014-09-07 12:29:23 +00:00
Joshua Tauberer
110e0f90d9 dns: move the quoting of TXT records to when we write the zone file so that we can display it unquoted in the External DNS instructions 2014-09-07 11:42:20 +00:00
Joshua Tauberer
954a234aa9 move website link to the top of README 2014-09-07 07:24:50 -04:00
Joshua Tauberer
b5122770cc tweak admin template for external DNS 2014-09-07 07:22:39 -04:00
Joshua Tauberer
711b1128e3 Merge pull request #178 from jkaberg/master
Support more concurrent connections for PHP services
2014-09-04 10:42:19 -04:00
Joel Kåberg
6b13ac1ca9 Support more concurrent connections 2014-09-04 16:40:33 +02:00
Joel Kåberg
9fd6958dc2 Revert commit "Support more concurrent connections for z-push" 2014-09-04 16:39:38 +02:00
Joel Kåberg
e434bf9fce Support more concurrent connections for z-push
My logs were showing lots of: 
[04-Sep-2014 15:52:41] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
2014-09-04 16:11:06 +02:00
Joshua Tauberer
03f9358de4 when checking SSL certs are OK, check for wildcard certificates
fixes #175 (hopefully)
2014-09-03 17:31:47 +00:00
Joshua Tauberer
c7a2aad0f8 tweak readme to emphasize not being customizable 2014-09-03 11:10:30 -04:00
Joshua Tauberer
c75a2c4ca0 add a warning not to use owncloud-unlockadmin.sh 2014-09-03 11:02:09 -04:00
Joshua Tauberer
ecbbac2ae1 Merge pull request #173 from fiws/master
fix permissions for owncloud-unlockadmin script
2014-09-03 10:57:12 -04:00
Filip Weiss
7fd32d2d0b fix permissions for owncloud-unlockadmin script 2014-09-03 16:50:48 +02:00
Joshua Tauberer
f77f1e656c split CardDAV instrctions into a new page and add CalDAV instructions; create nice redirects at /cloud/calendar and /cloud/contacts 2014-09-03 10:51:19 +00:00
Joshua Tauberer
b420e560c3 dont show 'make admin' on archived mailbox accounts and other control panel cleanup 2014-09-03 10:17:46 +00:00
Joshua Tauberer
7a449c76a1 set the DNS TTL to 30 minutes rather than 1 day
Also updating the values for secondary DNS, but we're not set up
for secondary DNS so it won't matter.

see #172
2014-09-01 23:06:55 +00:00
Joshua Tauberer
3853e8dd93 show the status of backups in the control panel 2014-09-01 13:06:53 +00:00
Joshua Tauberer
4ec6692f21 showing the mail-in-a-box version might fail if git isn't actually installed
The user might acquire the sources via some means other than a git clone. On Vagrant, the files come in via Vagrant. So test for git before running `git describe`.
2014-09-01 07:51:25 -04:00
Joshua Tauberer
6669f83bf7 install/upgrade ownCloud 7.0.2
Merge branch 'master' of github.com:jkaberg/mailinabox
2014-09-01 11:47:16 +00:00
Joel Kåberg
7603ce0489 this is what I meant 2014-09-01 10:32:44 +02:00
Joel Kåberg
8b2fed1a2a fixes comments by @JoshData 2014-09-01 10:02:46 +02:00
Joel Kåberg
ee244386ed update ownCloud if necessary
this will always download the latest ownCloud and upgrade if ownCloud install dir exist, this apphroach allows us to keep existing user plugins. currently not checking if currently installed version is equal to the one we're downloading as I couldn't find a proper solution for that
2014-08-31 20:34:57 +02:00
Joshua Tauberer
cfffb38508 link-local IPv6 addresses need a '%interface' specification to be useful 2014-08-31 08:09:13 -04:00
Joshua Tauberer
24ff0e04b1 output/text tweaks 2014-08-27 14:42:00 +00:00
Joshua Tauberer
10a37cd033 add SSHFP records to DNS 2014-08-27 12:59:40 +00:00
Joshua Tauberer
684d9b3c70 prettify the custom DNS docs 2014-08-27 12:57:47 +00:00
Joshua Tauberer
302eae3fd3 Merge pull request #167 from hjjg/feature-owncloudadmin
Add a helper script to unlock ownClouds admin features
2014-08-27 07:43:01 -04:00
Joshua Tauberer
8586723e70 Merge pull request #168 from hjjg/feature-localehandling1
locale-safe check if we have enough memory installed
2014-08-27 07:41:49 -04:00
Joshua Tauberer
aa3bc3225e expose the control panel only on PRIMARY_HOSTNAME since /admin might conflict with other stuff hosted on other domains 2014-08-27 02:38:43 +00:00
Joshua Tauberer
da2af2ea5c once the user has a signed SSL cert, simplify the message at the end of setup 2014-08-27 02:37:03 +00:00
Joshua Tauberer
6a311ee7d9 show the tag or commit the user is on in the output to aid debugging when a user posts the output somewhere 2014-08-27 02:37:03 +00:00
Helmuth Gronewold
1f19a855af Use mail.py to get the admin user 2014-08-26 23:34:27 +02:00
Helmuth Gronewold
3ce3c74273 Add a helper script to unlock ownClouds admin features 2014-08-26 23:15:09 +02:00
Joshua Tauberer
ac767dc87b Merge pull request #163 from empurium/master
CLI usage for aliases with multiple recipients
2014-08-26 16:59:50 -04:00
Joshua Tauberer
5c29275d8f Merge pull request #164 from hjjg/bugfix-roundcubekeylength
reads from /dev/random need to use count= to actually get that many bytes or else reads might be shorter
2014-08-26 16:26:38 -04:00
Helmuth Gronewold
756ba111a3 Also swith blocksize and count at the owncloud-specific key generation to ensure get as much bytes as you wanted. 2014-08-26 22:22:43 +02:00
Helmuth Gronewold
ab3d205ef6 Switch blocksize and count when reading from urandom with dd, to prevent getting fewer bytes for the secret key. 2014-08-26 22:16:31 +02:00
Joshua Tauberer
699923d605 Merge pull request #166 from benschumacher/master
Fix typo in dns_update.py.
2014-08-26 16:13:11 -04:00
Ben Schumacher
d5efb05f31 Fix typo in dns_update.py. 2014-08-26 15:58:34 -04:00
Michael Simmons
1125325718 CLI usage for aliases with multiple recipients 2014-08-26 11:45:03 -07:00
Joshua Tauberer
43fe2fe282 Merge pull request #162 from skosch/patch-2
Replace spaces by tabs in 106-109. See 92c7815d2c.
2014-08-26 14:12:34 -04:00
Sebastian Kosch
2afd0be591 Replace spaces by tabs in 106-109 2014-08-26 12:16:20 -04:00
Joshua Tauberer
92c7815d2c Merge pull request #156 from skosch/patch-1
Allow users to insert custom nginx configuration directives through new optional files.
2014-08-26 10:24:22 -04:00
Joshua Tauberer
06a4046d13 fix link to /cloud in the admin, fixes #160 2014-08-26 11:51:47 +00:00
Joshua Tauberer
9b8d85de45 if there are no admins when trying to access the control panel, tell the user how to make an admin from SSH 2014-08-26 11:31:45 +00:00
Joshua Tauberer
c0f4618bef normalize some whitespace 2014-08-26 07:13:47 -04:00
Joshua Tauberer
245864caac bug in the IPV6 question 2014-08-26 10:34:22 +00:00
Joshua Tauberer
b76cbae5a0 document the DNS API in the control panel
see #140, #155, df20d447a9
2014-08-25 23:52:41 +00:00
Joshua Tauberer
ed8ce16fb5 show custom DNS records in the control panel too, fixes #155 2014-08-25 23:35:44 +00:00
Joshua Tauberer
a32806da32 create STORAGE_ROOT/backup/duplicity if it doesn't exist
fixes #158
2014-08-25 23:29:00 +00:00
Joshua Tauberer
18f0406541 update comments in backup.py 2014-08-25 23:28:43 +00:00
Joshua Tauberer
bc9d670981 prettify mail guide 2014-08-25 23:24:41 +00:00
Helmuth Gronewold
3774f589c8 locale-safe check if we have enough memory installed 2014-08-25 23:36:55 +02:00
Sebastian Kosch
00b5c6ee9c test_domain -> domain 2014-08-25 16:02:13 -04:00
Sebastian Kosch
76ff9735cc Move custom server blocks to STORAGE_ROOT 2014-08-25 13:25:44 -04:00
Joshua Tauberer
844f744a09 Merge branch 'usedialog' 2014-08-25 08:30:19 -04:00
Joshua Tauberer
d1c7617cdb Merge branch 'master' into usedialog 2014-08-25 08:26:59 -04:00
Joshua Tauberer
ea32af1f0e Merge commit 'b0d6473c3c6748a68f4845324fee13f3153bc18f' into usedialog
Conflicts:
	setup/start.sh (changes are in questions.sh now)
2014-08-25 08:26:39 -04:00
Joshua Tauberer
c18200d9b1 Merge commit '09d2a08ce620928d0398068197951e5acebca0f0' into usedialog
Conflicts:
	setup/start.sh (change was already applied)
2014-08-25 08:23:28 -04:00
Joshua Tauberer
bf5016a8ac bootstrap.sh: allow overring the tag to checkout by setting the TAG environment variable (helpful for debugging) 2014-08-25 08:18:46 -04:00
Joshua Tauberer
7cec45b436 tools/mail.py should not list archived mailboxes
broken by b30d7ad80a when the API began to list both real users and archived mailboxes in the JSON response
2014-08-25 08:13:46 -04:00
Joshua Tauberer
e0dc8ff04a when deleting my old /usr/local/bin/mailinabox-exchange-autodiscover.php file from existing systems, don't emit an error if the file doesn't exist (added -f) 2014-08-25 08:10:54 -04:00
Joshua Tauberer
faf6f87a63 move the user-interactive questions and other parts of start.sh into new files 2014-08-25 08:09:37 -04:00
Joshua Tauberer
4ed69cbae5 replace '-t 0' test with an environment variable since '-t 0' is false when standard input has been redirected and doesn't tell us whether or not we can use dialog for input, but Vagrant must be non-interactive 2014-08-25 07:54:11 -04:00
Sebastian Kosch
9bfff1f679 Add server block customizations
This allows users to add a file /etc/nginx/conf.d/includes/mydomain.com.conf, the contents of which will be included in the server block for mydomain.com.
2014-08-24 17:34:15 -04:00
Joshua Tauberer
28231ac248 Merge pull request #150 from hjjg/secretkeyfix
The secret key that encrypts the backups should not be world readable.
2014-08-24 17:21:38 -04:00
Helmuth Gronewold
90c7655d82 Fix wrong permissions of backup secret. Pyhton 3 needs octal permissions. 2014-08-24 21:27:39 +02:00
Joshua Tauberer
df20d447a9 add an api for setting custom DNS records
Works like this:

```curl -d "" --user email:password https://.../admin/dns/set/qname/rtype/value```

where the rtype and value default to "A" and the remote IP address of the request, so that a simple, empty POST to

```https://.../admin/dns/set/desktop.mydomain.com```

will point desktop.mydomain.com to the caller's IPv4 address.

closes #140
2014-08-23 23:03:45 +00:00
Joshua Tauberer
5d42c125eb update the first goal to match what's on the website: s/email appliance/easy email/ 2014-08-23 18:09:07 -04:00
Joshua Tauberer
de864b998b fix double negation in README, fixes #154 2014-08-23 18:08:03 -04:00
Joshua Tauberer
6e3b04ce83 when generating SSL CSRs, using SHA256 as SHA1 is being phased out, per @konklone 2014-08-23 17:49:33 -04:00
Joshua Tauberer
b0d6473c3c Merge branch 'box-in-a-name' of github.com:hjjg/mailinabox 2014-08-23 12:43:47 +00:00
Joshua Tauberer
03bbd25a10 re-do allow apt to perform security updates on its own
Move this into system.sh rather than anagement.sh.

This reverts commit eab28c97ff.
2014-08-23 12:35:59 +00:00
Joshua Tauberer
a0b056ae29 put a sterner warning in nginx local.conf about not modifying it 2014-08-23 12:35:59 +00:00
Helmuth Gronewold
ff8413a622 Better handling of hostname and email address recommendation. 2014-08-23 08:51:18 +02:00
Helmuth Gronewold
ee9552734f Fix permissions of backup secret according to Josh's comment at
https://github.com/mail-in-a-box/mailinabox/pull/150#issuecomment-53120156
2014-08-22 23:23:56 +02:00
Helmuth Gronewold
a68fd6429f The secret key that encrypts the backups should not be world readable. 2014-08-22 22:55:34 +02:00
Joshua Tauberer
f7c7d5b9c3 Merge pull request #146 from ls42/zpush/auto-timezone
Read timezone from /etc/timezone.
2014-08-21 17:21:47 -04:00
Joshua Tauberer
4b75148f01 Merge pull request #148 from koptein/typos
Typo in introduction "later later"
2014-08-21 17:06:31 -04:00
Christian Koptein
09d2a08ce6 Typo in introduction 2014-08-21 21:51:54 +02:00
Joshua Tauberer
9576594cfe bootstrap script should check out a particular tag rather than master 2014-08-21 17:28:20 +00:00
Joshua Tauberer
76dcab3139 now that we use dialog for input we can pipe the bootstrap script to bash 2014-08-21 17:28:12 +00:00
Joshua Tauberer
7e8e104964 when asking for a CSR country code, give the user a list 2014-08-21 17:28:04 +00:00
Joshua Tauberer
7ea4d33e06 simplify the input_box function 2014-08-21 16:01:12 +00:00
Joshua Tauberer
eab28c97ff allow apt to perform security updates on its own 2014-08-21 11:47:28 +00:00
Joshua Tauberer
2d5097345a move the package update check into the system status checks 2014-08-21 11:24:40 +00:00
Joshua Tauberer
294d19e0af rename whats_next.py to status_checks.py 2014-08-21 10:43:55 +00:00
H8H
980b83b124 Added dialogs, so that the setup.sh can ask the user any questions even when its piped; Added additional email valdidation for the last step 2014-08-21 03:09:09 +02:00
Stephan Brauer
2cab02c831 Read timezone from /etc/timezone. 2014-08-20 23:51:10 +02:00
74 changed files with 7268 additions and 1973 deletions

243
CHANGELOG.md Normal file
View File

@@ -0,0 +1,243 @@
CHANGELOG
=========
v0.10 (June 1, 2015)
--------------------
* SMTP Submission (port 587) began offering the insecure SSLv3 protocol due to a misconfiguration in the previous version.
* Roundcube now allows persistent logins using Roundcube-Persistent-Login-Plugin.
* ownCloud is updated to version 8.0.3.
* SPF records for non-mail domains were tightened.
* The minimum greylisting delay has been reduced from 5 minutes to 3 minutes.
* Users and aliases weren't working if they were entered with any uppercase letters. Now only lowercase is allowed.
* After installing an SSL certificate from the control panel, the page wasn't being refreshed.
* Backups broke if the box's hostname was changed after installation.
* Dotfiles (i.e. .svn) stored in ownCloud Files were not accessible from ownCloud's mobile/desktop clients.
* Fix broken install on OVH VPS's.
v0.09 (May 8, 2015)
-------------------
Mail:
* Spam checking is now performed on messages larger than the previous limit of 64KB.
* POP3S is now enabled (port 995).
* Roundcube is updated to version 1.1.1.
* Minor security improvements (more mail headers with user agent info are anonymized; crypto settings were tightened).
ownCloud:
* Downloading files you uploaded to ownCloud broke because of a change in ownCloud 8.
DNS:
* Internationalized Domain Names (IDNs) should now work in email. If you had custom DNS or custom web settings for internationalized domains, check that they are still working.
* It is now possible to set multiple TXT and other types of records on the same domain in the control panel.
* The custom DNS API was completely rewritten to support setting multiple records of the same type on a domain. Any existing client code using the DNS API will have to be rewritten. (Existing code will just get 404s back.)
* On some systems the `nsd` service failed to start if network inferfaces were not ready.
System / Control Panel:
* In order to guard against misconfiguration that can lead to domain control validation hijacking, email addresses that begin with admin, administrator, postmaster, hostmaster, and webmaster can no longer be used for (new) mail user accounts, and aliases for these addresses may direct mail only to the box's administrator(s).
* Backups now use duplicity's built-in gpg symmetric AES256 encryption rather than my home-brewed encryption. Old backups will be incorporated inside the first backup after this update but then deleted from disk (i.e. your backups from the previous few days will be backed up).
* There was a race condition between backups and the new nightly status checks.
* The control panel would sometimes lock up with an unnecessary loading indicator.
* You can no longer delete your own account from the control panel.
Setup:
* All Mail-in-a-Box release tags are now signed on github, instructions for verifying the signature are added to the README, and the integrity of some packages downloaded during setup is now verified against a SHA1 hash stored in the tag itself.
* Bugs in first user account creation were fixed.
v0.08 (April 1, 2015)
---------------------
Mail:
* The Roundcube vacation_sieve plugin by @arodier is now installed to make it easier to set vacation auto-reply messages from within Roundcube.
* Authentication-Results headers for DMARC, added in v0.07, were mistakenly added for outbound mail --- that's now removed.
* The Trash folder is now created automatically for new mail accounts, addressing a Roundcube error.
DNS:
* Custom DNS TXT records were not always working and they can now override the default SPF, DKIM, and DMARC records.
System:
* ownCloud updated to version 8.0.2.
* Brute-force SSH and IMAP login attempts are now prevented by properly configuring fail2ban.
* Status checks are run each night and any changes from night to night are emailed to the box administrator (the first user account).
Control panel:
* The new check that system services are running mistakenly checked that the Dovecot Managesieve service is publicly accessible. Although the service binds to the public network interface we don't open the port in ufw. On some machines it seems that ufw blocks the connection from the status checks (which seems correct) and on some machines (mine) it doesn't, which is why I didn't notice the problem.
* The current backup chain will now try to predict how many days until it is deleted (always at least 3 days after the next full backup).
* The list of aliases that forward to a user are removed from the Mail Users page because when there are many alises it is slow and times-out.
* Some status check errors are turned into warnings, especially those that might not apply if External DNS is used.
v0.07 (February 28, 2015)
-------------------------
Mail:
* If the box manages mail for a domain and a subdomain of that domain, outbound mail from the subdomain was not DKIM-signed and would therefore fail DMARC tests on the receiving end, possibly result in the mail heading into spam folders.
* Auto-configuration for Mozilla Thunderbird, Evolution, KMail, and Kontact is now available.
* Domains that only have a catch-all alias or domain alias no longer automatically create/require admin@ and postmaster@ addresses since they'll forward anyway.
* Roundcube is updated to version 1.1.0.
* Authentication-Results headers for DMARC are now added to incoming mail.
DNS:
* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.
* If a custom DNS A record overrides one provided by the box, the a corresponding default IPv6 record by the box is removed since it will probably be incorrect.
* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.
Web:
* Static websites now deny access to certain dot (.) files and directories which typically have sensitive info: .ht*, .svn*, .git*, .hg*, .bzr*.
* The nginx server no longer reports its version and OS for better privacy.
* The HTTP->HTTPS redirect is now more efficient.
* When serving a 'www.' domain, reuse the SSL certificate for the parent domain if it covers the 'www' subdomain too
* If a custom DNS CNAME record is set on a domain, don't offer to put a website on that domain. (Same logic already applies to custom A/AAAA records.)
Control panel:
* Status checks now check that system services are actually running by pinging each port that should have something running on it.
* The status checks are now parallelized so they may be a little faster.
* The status check for MX records now allow any priority, in case an unusual setup is required.
* The interface for setting website domain-specific directories is simplified.
* The mail guide now says that to use Outlook, Outlook 2007 or later on Windows 7 and later is required.
* External DNS settings now skip the special "_secondary_nameserver" key which is used for storing secondary NS information.
Setup:
* Install cron if it isn't already installed.
* Fix a units problem in the minimum memory check.
* If you override the STORAGE_ROOT, your setting will now persist if you re-run setup.
* Hangs due to apt wanting the user to resolve a conflict should now be fixed (apt will just clobber the problematic file now).
v0.06 (January 4, 2015)
-----------------------
Mail:
* Set better default system limits to accommodate boxes handling mail for 20+ users.
Contacts/calendar:
* Update to ownCloud to 7.0.4.
* Contacts syncing via ActiveSync wasn't working.
Control panel:
* New control panel for setting custom DNS settings (without having to use the API).
* Status checks showed a false positive for Spamhause blacklists and for secondary DNS in some cases.
* Status checks would fail to load if openssh-sever was not pre-installed, but openssh-server is not required.
* The local DNS cache is cleared before running the status checks using 'rncd' now rather than restarting 'bind9', which should be faster and wont interrupt other services.
* Multi-domain and wildcard certificate can now be installed through the control panel.
* The DNS API now allows the setting of SRV records.
Misc:
* IPv6 configuration error in postgrey, nginx.
* Missing dependency on sudo.
v0.05 (November 18, 2014)
-------------------------
Mail:
* The maximum size of outbound mail sent via webmail and Exchange/ActiveSync has been increased to 128 MB, the same as when using SMTP.
* Spam is no longer wrapped as an attachment inside a scary Spamassassin explanation. The original message is simply moved straight to the Spam folder unchanged.
* There is a new iOS/Mac OS X Configuration Profile link in the control panel which makes it easier to configure IMAP/SMTP/CalDAV/CardDAV on iOS devices and Macs.
* "Domain aliases" can now be configured in the control panel.
* Updated to [Roundcube 1.0.3](http://trac.roundcube.net/wiki/Changelog).
* IMAP/SMTP is now recommended even on iOS devices as Exchange/ActiveSync is terribly buggy.
Control panel:
* Installing an SSL certificate for the primary hostname would cause problems until a restart (services needed to be restarted).
* Installing SSL certificates would fail if /tmp was on a different filesystem.
* Better error messages when installing a SSL certificate fails.
* The local DNS cache is now cleared each time the system status checks are run.
* Documented how to use +tag addressing.
* Minor UI tweaks.
Other:
* Updated to [ownCloud 7.0.3](http://owncloud.org/changelog/).
* The ownCloud API is now exposed properly.
* DNSSEC now works on `.guide` domains now too (RSASHA256).
v0.04 (October 15, 2014)
------------------------
Breaking changes:
* On-disk backups are now retained for a minimum of 3 days instead of 14. Beyond that the user is responsible for making off-site copies.
* IMAP no longer supports the legacy SSLv3 protocol. SSLv3 is now known to be insecure. I don't believe any modern devices will be affected by this. HTTPS and SMTP submission already had SSLv3 disabled.
Control panel:
* The control panel has a new page for installing SSL certificates.
* The control panel has a new page for hosting static websites.
* The control panel now shows mailbox sizes on disk.
* It is now possible to create catch-all aliases from the control panel.
* Many usability improvements in the control panel.
DNS:
* Custom DNS A/AAAA records on subdomains were ignored.
* It is now possible to set up a secondary DNS server.
* DNS zones were updating even when nothing changed.
* Strict SPF and DMARC settings are now set on all subdomains not used for mail.
Security:
* DNSSEC is now supported for the .email TLD which required a different key algorithm.
* Nginx and Postfix now use 2048 bits of DH parameters instead of 1024.
Other:
* Spam filter learning by dragging mail in and out of the Spam folder should hopefully be working now.
* Some things were broken if the machine had an IPv6 address.
* Other things were broken if the machine was on a non-utf8 locale.
* No longer implementing webfinger.
* Removes apache before installing nginx, in case it has been installed by distro.
v0.03 (September 24, 2014)
--------------------------
* Update existing installs of Roundcube.
* Disabled catch-alls pending figuring out how to get users to take precedence.
* Z-Push was not working because in v0.02 we had accidentally moved to a different version.
* Z-Push is now locked to a specific commit so it doesn't change on us accidentally.
* The start script is now symlinked to /usr/local/bin/mailinabox.
v0.02 (September 21, 2014)
--------------------------
* Open the firewall to an alternative SSH port if set.
* Fixed missing dependencies.
* Set Z-Push to use sync command with ownCloud.
* Support more concurrent connections for z-push.
* In the status checks, handle wildcard certificates.
* Show the status of backups in the control panel.
* The control panel can now update a user's password.
* Some usability improvements in the control panel.
* Warn if a SSL cert is expiring in 30 days.
* Use SHA2 to generate CSRs.
* Better logic for determining when to take a full backup.
* Reduce DNS TTL, not that it seems to really matter.
* Add SSHFP DNS records.
* Add an API for setting custom DNS records
* Update to ownCloud 7.0.2.
* Some things were broken if the machine had an IPv6 address.
* Use a dialogs library to ask users questions during setup.
* Other fixes.
v0.01 (August 19, 2014)
-----------------------
First release.

View File

@@ -1,59 +1,78 @@
Mail-in-a-Box Mail-in-a-Box
============= =============
By [@JoshData](https://github.com/JoshData) and contributors. By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors).
Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box. Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box.
**This is a work in progress. I work on this in my limited free time.** **Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!**
Why build this? Mass electronic surveillance by governments revealed over the last year has spurred a new movement to [re-decentralize](http://redecentralize.org/) the web, that is, to empower netizens to be their own service providers again. SMTP, the protocol of email, is decentralized in principle but highly centralized in practice due to the high cost of implementing all of the modern protocols that surround it. As a result, most individuals trade their independence for access to a “free” email service. * * *
I am trying to:
* Make deploying a good mail server easy.
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
* Have automated, auditable, and [idempotent](http://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
* **Not** make a totally unhackable, NSA-proof server.
* **Not** make something customizable by power users.
This setup is what has been powering my own personal email since September 2013.
The Box The Box
------- -------
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server, including SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib)), webmail ([Roundcube](http://roundcube.net/)), spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), DNS, [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), and basic system services like a firewall, intrusion protection, and setting the system clock. Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
This setup is what has been powering my own personal email since September 2013. It is a one-click email appliance (see the [setup guide](https://mailinabox.email/guide.html)). There are no user-configurable setup options. It "just works".
Please see [mailinabox.email](https://mailinabox.email) for more information and how to set up a Mail-in-a-Box. The components installed are:
In short, it's like this: * SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib))
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/))
* DNS ([nsd4](http://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), and [SSHFP](https://tools.ietf.org/html/rfc4255) records automatically set
* Firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page))
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and system monitoring.
# do this on a fresh install of Ubuntu 14.04 only! For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
sudo apt-get install -y git
git clone https://github.com/mail-in-a-box/mailinabox
cd mailinabox
sudo setup/start.sh
Congratulations! You should now have a working setup. You'll be given the address of the administrative interface for further instructions. The Security
------------
**Status**: This is a work in progress. It works for what it is, but it is missing such things as quotas, backup/restore, etc. See the [security guide](security.md) for more information about the box's security configuration (TLS, password storage, etc).
The Goals I sign the release tags on git. To verify that a tag is signed by me, you can perform the following steps:
---------
* Create a push-button "Email Appliance" for everyday users. # Download my PGP key.
* Promote decentralization, innovation, and privacy on the web. $ curl -s https://keybase.io/joshdata/key.asc | gpg --import
* Have automated, auditable, and [idempotent](http://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration. gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
For more background, see [The Rationale](https://github.com/mail-in-a-box/mailinabox/wiki). # Clone this repository.
$ git clone https://github.com/mail-in-a-box/mailinabox
$ cd mailinabox
What I am not trying to do: # Verify the tag.
$ git verify-tag v0.10
gpg: Signature made ..... using RSA key ID C10BDD81
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 5F4C 0E73 13CC D744 693B 2AEA B920 41F4 C10B DD81
* **Not** to be a mail server that the NSA cannot hack. The key ID and fingerprint above should match my [Keybase.io key](https://keybase.io/joshdata) and the fingerprint I publish on [my homepage](https://razor.occams.info/).
* **Not** to be customizable by power users.
The Acknowledgements The Acknowledgements
-------------------- --------------------
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/al3x/sovereign) by Alex Payne, and conversations with <a href="http://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>. This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/al3x/sovereign) by Alex Payne, and conversations with <a href="http://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/). Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
The History The History
----------- -----------
* In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf). * In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf).
* In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts.
* Mail-in-a-Box was a semifinalist in the 2014 [Knight News Challenge](https://www.newschallenge.org/challenge/2014/submissions/mail-in-a-box), but it was not selected as a winner. * Mail-in-a-Box was a semifinalist in the 2014 [Knight News Challenge](https://www.newschallenge.org/challenge/2014/submissions/mail-in-a-box), but it was not selected as a winner.
* Mail-in-a-Box hit the front page of Hacker News in [April](https://news.ycombinator.com/item?id=7634514) 2014, [September](https://news.ycombinator.com/item?id=8276171) 2014, and [May](https://news.ycombinator.com/item?id=9624267) 2015.

1
Vagrantfile vendored
View File

@@ -18,6 +18,7 @@ Vagrant.configure("2") do |config|
# machine figure out its own public IP and it'll take a # machine figure out its own public IP and it'll take a
# subdomain on our justtesting.email domain so we can get # subdomain on our justtesting.email domain so we can get
# started quickly. # started quickly.
export NONINTERACTIVE=1
export PUBLIC_IP=auto export PUBLIC_IP=auto
export PUBLIC_IPV6=auto export PUBLIC_IPV6=auto
export PRIMARY_HOSTNAME=auto-easy export PRIMARY_HOSTNAME=auto-easy

View File

@@ -0,0 +1,22 @@
# Fail2Ban filter Dovecot authentication and pop3/imap server
# For Mail-in-a-Box
[INCLUDES]
before = common.conf
[Definition]
_daemon = (auth|dovecot(-auth)?|auth-worker)
failregex = ^%(__prefix_line)s(pop3|imap)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
ignoreregex =
# DEV Notes:
# * the first regex is essentially a copy of pam-generic.conf
# * Probably doesn't do dovecot sql/ldap backends properly
#
# Author: Martin Waschbuesch
# Daniel Black (rewrote with begin and end anchors)
# Mail-in-a-Box (swapped session=...)

34
conf/fail2ban/jail.local Normal file
View File

@@ -0,0 +1,34 @@
# Fail2Ban configuration file.
# For Mail-in-a-Box
[DEFAULT]
# bantime in seconds
bantime = 60
# This should ban dumb brute-force attacks, not oblivious users.
findtime = 30
maxretry = 20
#
# JAILS
#
[ssh]
enabled = true
logpath = /var/log/auth.log
maxretry = 20
[ssh-ddos]
enabled = true
maxretry = 20
[sasl]
enabled = true
[dovecot]
enabled = true
filter = dovecotimap

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

@@ -12,7 +12,7 @@
# Adapted from http://blog.codefront.net/2007/06/11/nginx-php-and-a-php-fastcgi-daemon-init-script/ # Adapted from http://blog.codefront.net/2007/06/11/nginx-php-and-a-php-fastcgi-daemon-init-script/
PATH=/sbin:/usr/sbin:/bin:/usr/bin PATH=/sbin:/usr/sbin:/bin:/usr/bin
DESC="Mail-in-a-Box Mgmt" DESC="Mail-in-a-Box Management Daemon"
NAME=mailinabox NAME=mailinabox
DAEMON=/usr/local/bin/mailinabox-daemon DAEMON=/usr/local/bin/mailinabox-daemon
PIDFILE=/var/run/$NAME.pid PIDFILE=/var/run/$NAME.pid
@@ -25,6 +25,16 @@ SCRIPTNAME=/etc/init.d/$NAME
START=yes START=yes
EXEC_AS_USER=root EXEC_AS_USER=root
# Ensure Python reads/writes files in UTF-8. If the machine
# triggers some other locale in Python, like ASCII encoding,
# Python may not be able to read/write files. Here and in
# setup/start.sh (where the locale is also installed if not
# already present).
export LANGUAGE=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export LANG=en_US.UTF-8
export LC_TYPE=en_US.UTF-8
# Read configuration variable file if it is present # Read configuration variable file if it is present
[ -r /etc/default/$NAME ] && . /etc/default/$NAME [ -r /etc/default/$NAME ] && . /etc/default/$NAME

View File

@@ -0,0 +1,44 @@
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="PRIMARY_HOSTNAME">
<domain>PRIMARY_HOSTNAME</domain>
<displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>
<displayShortName>PRIMARY_HOSTNAME</displayShortName>
<incomingServer type="imap">
<hostname>PRIMARY_HOSTNAME</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>PRIMARY_HOSTNAME</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
<addThisServer>true</addThisServer>
<useGlobalPreferredServer>true</useGlobalPreferredServer>
</outgoingServer>
<documentation url="https://PRIMARY_HOSTNAME/">
<descr lang="en">PRIMARY_HOSTNAME website.</descr>
</documentation>
</emailProvider>
<webMail>
<loginPage url="https://PRIMARY_HOSTNAME/mail/" />
<loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" >
<username>%EMAILADDRESS%</username>
<usernameField id="rcmloginuser" name="_user" />
<passwordField id="rcmloginpwd" name="_pass" />
<loginButton id="rcmloginsubmit" />
</loginPageInfo>
</webMail>
<clientConfigUpdate url="https://PRIMARY_HOSTNAME/.well-known/autoconfig/mail/config-v1.1.xml" />
</clientConfig>

View File

@@ -1,6 +1,16 @@
# 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://127.0.0.1:10222/;
proxy_set_header X-Forwarded-For $remote_addr;
}
# ownCloud configuration. # ownCloud configuration.
rewrite ^/cloud$ /cloud/ redirect; rewrite ^/cloud$ /cloud/ redirect;
rewrite ^/cloud/$ /cloud/index.php; rewrite ^/cloud/$ /cloud/index.php;
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html; rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
location /cloud/ { location /cloud/ {
alias /usr/local/lib/owncloud/; alias /usr/local/lib/owncloud/;
@@ -8,12 +18,18 @@
deny all; deny all;
} }
} }
location ~ ^(/cloud)(/[^/]+\.php)(/.*)?$ { location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
# note: ~ has precendence over a regular location block # note: ~ has precendence over a regular location block
# Accept URLs like:
# /cloud/index.php/apps/files/
# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)
# /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240)
# /cloud/remote.php/webdav/yourfilehere...
include fastcgi_params; include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2; fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
fastcgi_param SCRIPT_NAME $1$2; fastcgi_param SCRIPT_NAME $1$2;
fastcgi_param PATH_INFO $3; fastcgi_param PATH_INFO $3;
# TODO: see the dispreferred "method 2" for xaccel at https://doc.owncloud.org/server/8.1/admin_manual/configuration_files/serving_static_files_configuration.html
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on; fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
fastcgi_read_timeout 630; fastcgi_read_timeout 630;
fastcgi_pass php-fpm; fastcgi_pass php-fpm;
@@ -22,10 +38,13 @@
client_max_body_size 1G; client_max_body_size 1G;
fastcgi_buffers 64 4K; fastcgi_buffers 64 4K;
} }
location ^~ /cloud/data { location ^~ $STORAGE_ROOT/owncloud {
# In order to support MOD_X_ACCEL_REDIRECT_ENABLED, we need to expose # This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. It works a little weird.
# the data directory but only allow 'internal' redirects within nginx # The full path on disk of the file is passed as the URL path. ownCloud 8 totally
# so that this is not exposed to the world. # busted the sane way this worked in ownCloud 7. There's a pending change using
# a new parameter to make this make more sense.
# We need to only allow 'internal' redirects within nginx so that the filesystem
# is not exposed to the world.
internal; internal;
alias $STORAGE_ROOT/owncloud; alias $STORAGE_ROOT/owncloud;
} }

View File

@@ -55,7 +55,7 @@ spdy_headers_comp 0;
# openssl dhparam -outform pem -out dhparam2048.pem 2048 # openssl dhparam -outform pem -out dhparam2048.pem 2048
# #
# Note: raising the bits to 2048 excludes Java 6 clients. Comment out if a problem. # Note: raising the bits to 2048 excludes Java 6 clients. Comment out if a problem.
#ssl_dhparam /path/to/dhparam2048.pem; ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
# OCSP stapling - means nginx will poll the CA for signed OCSP responses, # OCSP stapling - means nginx will poll the CA for signed OCSP responses,

View File

@@ -1,6 +1,10 @@
## NOTE: This file is automatically generated by Mail-in-a-Box. ## NOTE: This file is automatically generated by Mail-in-a-Box.
## Do not edit this file. It will be replaced each time ## Do not edit this file. It is continually updated by
## Mail-in-a-Box needs to update the web configuration. ## Mail-in-a-Box and your changes will be lost.
##
## Mail-in-a-Box machines are not meant to be modified.
## If you modify any system configuration you are on
## your own --- please do not ask for help from us.
upstream php-fpm { upstream php-fpm {
server unix:/var/run/php5-fpm.sock; server unix:/var/run/php5-fpm.sock;

View File

@@ -7,15 +7,28 @@ server {
server_name $HOSTNAME; server_name $HOSTNAME;
root /tmp/invalid-path-nothing-here; root /tmp/invalid-path-nothing-here;
rewrite ^/(.*)$ https://$HOSTNAME/$1 permanent;
# Improve privacy: Hide version an OS information on
# error pages and in the "Server" HTTP-Header.
server_tokens off;
# Redirect using the 'return' directive and the built-in
# variable '$request_uri' to avoid any capturing, matching
# or evaluation of regular expressions.
return 301 https://$HOSTNAME$request_uri;
} }
# The secure HTTPS server. # The secure HTTPS server.
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl;
server_name $HOSTNAME; server_name $HOSTNAME;
# Improve privacy: Hide version an OS information on
# error pages and in the "Server" HTTP-Header.
server_tokens off;
ssl_certificate $SSL_CERTIFICATE; ssl_certificate $SSL_CERTIFICATE;
ssl_certificate_key $SSL_KEY; ssl_certificate_key $SSL_KEY;
include /etc/nginx/nginx-ssl.conf; include /etc/nginx/nginx-ssl.conf;
@@ -24,10 +37,21 @@ server {
root $ROOT; root $ROOT;
index index.html index.htm; index index.html index.htm;
# Control Panel location = /robots.txt {
rewrite ^/admin$ /admin/; log_not_found off;
location /admin/ { access_log off;
proxy_pass http://localhost:10222/; }
location = /favicon.ico {
log_not_found off;
access_log off;
}
location = /mailinabox.mobileconfig {
alias /var/lib/mailinabox/mobileconfig.xml;
}
location = /.well-known/autoconfig/mail/config-v1.1.xml {
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
} }
# Roundcube Webmail configuration. # Roundcube Webmail configuration.
@@ -48,14 +72,10 @@ server {
fastcgi_index index.php; fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
fastcgi_pass php-fpm; fastcgi_pass php-fpm;
client_max_body_size 20M;
}
# Webfinger configuration. # Outgoing mail also goes through this endpoint, so increase the maximum
location = /.well-known/webfinger { # file upload limit to match the corresponding Postfix limit.
include fastcgi_params; client_max_body_size 128M;
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-webfinger.php;
fastcgi_pass php-fpm;
} }
# Z-Push (Microsoft Exchange ActiveSync) # Z-Push (Microsoft Exchange ActiveSync)
@@ -65,6 +85,10 @@ server {
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc"; fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
fastcgi_read_timeout 630; fastcgi_read_timeout 630;
fastcgi_pass php-fpm; fastcgi_pass php-fpm;
# Outgoing mail also goes through this endpoint, so increase the maximum
# file upload limit to match the corresponding Postfix limit.
client_max_body_size 128M;
} }
location /autodiscover/autodiscover.xml { location /autodiscover/autodiscover.xml {
include fastcgi_params; include fastcgi_params;
@@ -75,5 +99,18 @@ server {
# ADDITIONAL DIRECTIVES HERE # ADDITIONAL DIRECTIVES HERE
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
# This block is placed at the end. Nginx's precedence rules means this block
# takes precedence over all non-regex matches and only regex matches that
# come after it (i.e. none of those, since this is the last one.) That means
# we're blocking dotfiles in the static hosted sites but not the FastCGI-
# handled locations for ownCloud (which serves user-uploaded files that might
# have this pattern, see #414) or some of the other services.
location ~ /\.(ht|svn|git|hg|bzr) {
log_not_found off;
access_log off;
deny all;
} }
}

View File

@@ -8,3 +8,7 @@
/^\s*X-Enigmail:/ IGNORE /^\s*X-Enigmail:/ IGNORE
/^\s*X-Mailer:/ IGNORE /^\s*X-Mailer:/ IGNORE
/^\s*X-Originating-IP:/ IGNORE /^\s*X-Originating-IP:/ IGNORE
/^\s*X-Pgp-Agent:/ IGNORE
# The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)).
/^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1

View File

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

View File

@@ -9,10 +9,6 @@ define('CALDAV_SERVER', 'https://localhost');
define('CALDAV_PORT', '443'); define('CALDAV_PORT', '443');
define('CALDAV_PATH', '/caldav/calendars/%u/'); define('CALDAV_PATH', '/caldav/calendars/%u/');
define('CALDAV_PERSONAL', ''); define('CALDAV_PERSONAL', '');
define('CALDAV_SUPPORTS_SYNC', true);
// If the CalDAV server supports the sync-collection operation
// DAViCal and SOGo support it
// Setting this to false will work with most servers, but it will be slower
define('CALDAV_SUPPORTS_SYNC', false);
?> ?>

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_PATH', ''); /* readonly, searchable, not syncd */
define('CARDDAV_GAL_MIN_LENGTH', 5); define('CARDDAV_GAL_MIN_LENGTH', 5);
define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook'); define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
// If the CardDAV server supports the sync-collection operation
// DAViCal supports it, but SabreDav, Owncloud, SOGo don't
// Setting this to false will work with most servers, but it will be slower: 1 petition for the href of vcards, and 1 petition for each vcard
define('CARDDAV_SUPPORTS_SYNC', false); define('CARDDAV_SUPPORTS_SYNC', false);
// If the CardDAV server supports the FN attribute for searches // If the CardDAV server supports the FN attribute for searches
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't // DAViCal supports it, but SabreDav, Owncloud and SOGo don't
// Setting this to true will search by FN. If false will search by sn, givenName and email // Setting this to true will search by FN. If false will search by sn, givenName and email

View File

@@ -1,9 +1,9 @@
import base64, os, os.path import base64, os, os.path, hmac
from flask import make_response from flask import make_response
import utils import utils
from mailconfig import get_mail_user_privileges from mailconfig import get_mail_password, get_mail_user_privileges
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
@@ -40,10 +40,12 @@ class KeyAuthService:
with create_file_with_mode(self.key_path, 0o640) as key_file: with create_file_with_mode(self.key_path, 0o640) as key_file:
key_file.write(self.key + '\n') key_file.write(self.key + '\n')
def is_authenticated(self, request, env): def authenticate(self, request, env):
"""Test if the client key passed in HTTP Authorization header matches the service key """Test if the client key passed in HTTP Authorization header matches the service key
or if the or username/password passed in the header matches an administrator user. or if the or username/password passed in the header matches an administrator user.
Returns 'OK' if the key is good or the user is an administrator, otherwise an error message.""" Returns a tuple of the user's email address and list of user privileges (e.g.
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
If the user used an API key, the user's email is returned as None."""
def decode(s): def decode(s):
return base64.b64decode(s.encode('ascii')).decode('ascii') return base64.b64decode(s.encode('ascii')).decode('ascii')
@@ -63,46 +65,64 @@ class KeyAuthService:
header = request.headers.get('Authorization') header = request.headers.get('Authorization')
if not header: if not header:
return "No authorization header provided." raise ValueError("No authorization header provided.")
username, password = parse_basic_auth(header) username, password = parse_basic_auth(header)
if username in (None, ""): if username in (None, ""):
return "Authorization header invalid." raise ValueError("Authorization header invalid.")
elif username == self.key: elif username == self.key:
return "OK" # The user passed the API key which grants administrative privs.
return (None, ["admin"])
else: else:
return self.check_imap_login( username, password, env) # The user is trying to log in with a username and user-specific
# API key or password. Raises or returns privs.
return (username, self.get_user_credentials(username, password, env))
def check_imap_login(self, email, pw, env): def get_user_credentials(self, email, pw, env):
# Validate a user's credentials. # Validate a user's credentials. On success returns a list of
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
# with a login error message.
# Sanity check. # Sanity check.
if email == "" or pw == "": if email == "" or pw == "":
return "Enter an email address and password." raise ValueError("Enter an email address and password.")
# The password might be a user-specific API key.
if hmac.compare_digest(self.create_user_key(email), pw):
# OK.
pass
else:
# Get the hashed password of the user. Raise a ValueError if the
# email address does not correspond to a user.
pw_hash = get_mail_password(email, env)
# Authenticate. # Authenticate.
try: try:
# Use doveadm to check credentials. doveadm will return # Use 'doveadm pw' to check credentials. doveadm will return
# a non-zero exit status if the credentials are no good, # a non-zero exit status if the credentials are no good,
# and check_call will raise an exception in that case. # and check_call will raise an exception in that case.
utils.shell('check_call', [ utils.shell('check_call', [
"/usr/bin/doveadm", "/usr/bin/doveadm", "pw",
"auth", "test", "-p", pw,
email, pw "-t", pw_hash,
]) ])
except: except:
# Login failed. # Login failed.
return "Invalid email address or password." raise ValueError("Invalid password.")
# Authorize. # Get privileges for authorization. This call should never fail on a valid user,
# (This call should never fail on a valid user.) # but if the caller passed a user-specific API key then the user may no longer
# exist --- in that case, get_mail_user_privileges will return a tuple of an
# error message and an HTTP status code.
privs = get_mail_user_privileges(email, env) privs = get_mail_user_privileges(email, env)
if isinstance(privs, tuple): raise Exception("Error getting privileges.") if isinstance(privs, tuple): raise ValueError(privs[0])
if "admin" not in privs:
return "You are not an administrator for this system."
return "OK" # Return a list of privileges.
return privs
def create_user_key(self, email):
return hmac.new(self.key.encode('ascii'), b"AUTH:" + email.encode("utf8"), digestmod="sha1").hexdigest()
def _generate_key(self): def _generate_key(self):
raw_key = os.urandom(32) raw_key = os.urandom(32)

View File

@@ -2,119 +2,280 @@
# This script performs a backup of all user data: # This script performs a backup of all user data:
# 1) System services are stopped while a copy of user data is made. # 1) System services are stopped while a copy of user data is made.
# 2) An incremental backup is made using rdiff-backup into the # 2) An incremental encrypted backup is made using duplicity into the
# directory STORAGE_ROOT/backup/rdiff-history. This directory # directory STORAGE_ROOT/backup/encrypted. The password used for
# will contain the latest files plus a complete history for # encryption is stored in backup/secret_key.txt.
# all prior backups.
# 3) The stopped services are restarted. # 3) The stopped services are restarted.
# 4) The backup directory is compressed into a single file using tar. # 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
# 5) That file is encrypted with a long password stored in backup/secret_key.txt.
import sys, os, os.path, shutil, glob import os, os.path, shutil, glob, re, datetime
import dateutil.parser, dateutil.relativedelta, dateutil.tz
from utils import exclusive_process, load_environment, shell from utils import exclusive_process, load_environment, shell, wait_for_service
# settings # Destroy backups when the most recent increment in the chain
full_backup = "--full" in sys.argv # that depends on it is this many days old.
keep_backups_for = "31D" # destroy backups older than 31 days except the most recent full backup keep_backups_for_days = 3
def backup_status(env):
# What is the current status of backups?
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
# get a list of all of the backups taken and sum up file sizes to
# see how large the storage is.
now = datetime.datetime.now(dateutil.tz.tzlocal())
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)
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
backups = { }
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_dir = os.path.join(backup_root, 'encrypted')
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
for fn in os.listdir(backup_dir):
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
if not m: raise ValueError(fn)
key = m.group("date")
if key not in backups:
date = dateutil.parser.parse(m.group("date"))
backups[key] = {
"date": m.group("date"),
"date_str": date.strftime("%x %X"),
"date_delta": reldate(date, now, "the future?"),
"full": m.group("incbase") is None,
"previous": m.group("incbase"),
"size": 0,
}
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
# Ensure the rows are sorted reverse chronologically.
# This is relied on by should_force_full() and the next step.
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
# Get the average size of incremental backups and the size of the
# most recent full backup.
incremental_count = 0
incremental_size = 0
first_full_size = None
for bak in backups:
if bak["full"]:
first_full_size = bak["size"]
break
incremental_count += 1
incremental_size += bak["size"]
# Predict how many more increments until the next full backup,
# and add to that the time we hold onto backups, to predict
# how long the most recent full backup+increments will be held
# onto. Round up since the backup occurs on the night following
# when the threshold is met.
deleted_in = None
if incremental_count > 0 and first_full_size is not None:
deleted_in = "approx. %d days" % round(keep_backups_for_days + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
# When will a backup be deleted?
saw_full = False
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
for bak in backups:
if deleted_in:
# Subsequent backups are deleted when the most recent increment
# in the chain would be deleted.
bak["deleted_in"] = deleted_in
if bak["full"]:
# Reset when we get to a full backup. A new chain start next.
saw_full = True
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"]), "on next daily backup")
bak["deleted_in"] = deleted_in
return {
"directory": backup_dir,
"encpwfile": os.path.join(backup_root, 'secret_key.txt'),
"tz": now.tzname(),
"backups": backups,
}
def should_force_full(env):
# Force a full backup when the total size of the increments
# since the last full backup is greater than half the size
# of that full backup.
inc_size = 0
for bak in backup_status(env)["backups"]:
if not bak["full"]:
# Scan through the incremental backups cumulating
# size...
inc_size += bak["size"]
else:
# ...until we reach the most recent full backup.
# Return if we should to a full backup.
return inc_size > .5*bak["size"]
else:
# If we got here there are no (full) backups, so make one.
# (I love for/else blocks. Here it's just to show off.)
return True
def perform_backup(full_backup):
env = load_environment() env = load_environment()
exclusive_process("backup") exclusive_process("backup")
# Ensure the backup directory exists. backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup') backup_cache_dir = os.path.join(backup_root, 'cache')
backup_duplicity_dir = os.path.join(backup_dir, 'duplicity') backup_dir = os.path.join(backup_root, 'encrypted')
os.makedirs(backup_dir, exist_ok=True)
# In an older version of this script, duplicity was called
# such that it did not encrypt the backups it created (in
# backup/duplicity), and instead openssl was called separately
# after each backup run, creating AES256 encrypted copies of
# each file created by duplicity in backup/encrypted.
#
# We detect the transition by the presence of backup/duplicity
# and handle it by 'dupliception': we move all the old *un*encrypted
# duplicity files up out of the backup/duplicity directory (as
# backup/ is excluded from duplicity runs) in order that it is
# included in the next run, and we delete backup/encrypted (which
# duplicity will output files directly to, post-transition).
old_backup_dir = os.path.join(backup_root, 'duplicity')
migrated_unencrypted_backup_dir = os.path.join(env["STORAGE_ROOT"], "migrated_unencrypted_backup")
if os.path.isdir(old_backup_dir):
# Move the old unencrypted files to a new location outside of
# the backup root so they get included in the next (new) backup.
# Then we'll delete them. Also so that they do not get in the
# way of duplicity doing a full backup on the first run after
# we take care of this.
shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)
# The backup_dir (backup/encrypted) now has a new purpose.
# Clear it out.
shutil.rmtree(backup_dir)
# On the first run, always do a full backup. Incremental # On the first run, always do a full backup. Incremental
# will fail. # will fail. Otherwise do a full backup when the size of
if len(os.listdir(backup_duplicity_dir)) == 0: # the increments since the most recent full backup are
full_backup = True # large.
else: full_backup = full_backup or should_force_full(env)
# When the size of incremental backups exceeds the size of existing
# full backups, take a new full backup. We want to avoid full backups
# because they are costly to synchronize off-site.
full_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-full.*'))
inc_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-inc.*'))
# (n.b. not counting size of new-signatures files because they are relatively small)
if inc_sz > full_sz * 1.5:
full_backup = True
# Stop services. # Stop services.
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"]) shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
shell('check_call', ["/usr/sbin/service", "postfix", "stop"]) shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
# Update the backup mirror directory which mirrors the current # Get the encryption passphrase. secret_key.txt is 2048 random
# STORAGE_ROOT (but excluding the backups themselves!). # bits base64-encoded and with line breaks every 65 characters.
# gpg will only take the first line of text, so sanity check that
# that line is long enough to be a reasonable passphrase. It
# only needs to be 43 base64-characters to match AES256's key
# length of 32 bytes.
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
passphrase = f.readline().strip()
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
env_with_passphrase = { "PASSPHRASE" : passphrase }
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
# --allow-source-mismatch is needed in case the box's hostname is changed
# after the first backup. See #396.
try: try:
shell('check_call', [ shell('check_call', [
"/usr/bin/duplicity", "/usr/bin/duplicity",
"full" if full_backup else "incr", "full" if full_backup else "incr",
"--no-encryption", "--archive-dir", backup_cache_dir,
"--archive-dir", "/tmp/duplicity-archive-dir", "--exclude", backup_root,
"--name", "mailinabox", "--volsize", "250",
"--exclude", backup_dir, "--gpg-options", "--cipher-algo=AES256",
"--volsize", "100",
"--verbosity", "warning",
env["STORAGE_ROOT"], env["STORAGE_ROOT"],
"file://" + backup_duplicity_dir "file://" + backup_dir,
]) "--allow-source-mismatch"
],
env_with_passphrase)
finally: finally:
# Start services again. # Start services again.
shell('check_call', ["/usr/sbin/service", "dovecot", "start"]) shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
shell('check_call', ["/usr/sbin/service", "postfix", "start"]) shell('check_call', ["/usr/sbin/service", "postfix", "start"])
# Once the migrated backup is included in a new backup, it can be deleted.
if os.path.isdir(migrated_unencrypted_backup_dir):
shutil.rmtree(migrated_unencrypted_backup_dir)
# Remove old backups. This deletes all backup data no longer needed # Remove old backups. This deletes all backup data no longer needed
# from more than 31 days ago. Must do this before destroying the # from more than 3 days ago.
# cache directory or else this command will re-create it.
shell('check_call', [ shell('check_call', [
"/usr/bin/duplicity", "/usr/bin/duplicity",
"remove-older-than", "remove-older-than",
keep_backups_for, "%dD" % keep_backups_for_days,
"--archive-dir", "/tmp/duplicity-archive-dir", "--archive-dir", backup_cache_dir,
"--name", "mailinabox",
"--force", "--force",
"--verbosity", "warning", "file://" + backup_dir
"file://" + backup_duplicity_dir ],
]) env_with_passphrase)
# Remove duplicity's cache directory because it's redundant with our backup directory. # From duplicity's manual:
shutil.rmtree("/tmp/duplicity-archive-dir") # "This should only be necessary after a duplicity session fails or is
# aborted prematurely."
# Encrypt all of the new files. # That may be unlikely here but we may as well ensure we tidy up if
backup_encrypted_dir = os.path.join(backup_dir, 'encrypted') # that does happen - it might just have been a poorly timed reboot.
os.makedirs(backup_encrypted_dir, exist_ok=True)
for fn in os.listdir(backup_duplicity_dir):
fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
if os.path.exists(fn2): continue
# Encrypt the backup using the backup private key.
shell('check_call', [ shell('check_call', [
"/usr/bin/openssl", "/usr/bin/duplicity",
"enc", "cleanup",
"-aes-256-cbc", "--archive-dir", backup_cache_dir,
"-a", "--force",
"-salt", "file://" + backup_dir
"-in", os.path.join(backup_duplicity_dir, fn), ],
"-out", fn2, env_with_passphrase)
"-pass", "file:%s" % os.path.join(backup_dir, "secret_key.txt"),
])
# The backup can be decrypted with: # Change ownership of backups to the user-data user, so that the after-bcakup
# openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z # script can access them.
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
# Remove encrypted backups that are no longer needed.
for fn in os.listdir(backup_encrypted_dir):
fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
if os.path.exists(fn2): continue
os.unlink(os.path.join(backup_encrypted_dir, fn))
# Execute a post-backup script that does the copying to a remote server. # Execute a post-backup script that does the copying to a remote server.
# Run as the STORAGE_USER user, not as root. Pass our settings in # Run as the STORAGE_USER user, not as root. Pass our settings in
# environment variables so the script has access to STORAGE_ROOT. # environment variables so the script has access to STORAGE_ROOT.
post_script = os.path.join(backup_dir, 'after-backup') post_script = os.path.join(backup_root, 'after-backup')
if os.path.exists(post_script): if os.path.exists(post_script):
shell('check_call', shell('check_call',
['su', env['STORAGE_USER'], '-c', post_script], ['su', env['STORAGE_USER'], '-c', post_script],
env=env) env=env)
# Our nightly cron job executes system status checks immediately after this
# backup. Since it checks that dovecot and postfix are running, block for a
# bit (maximum of 10 seconds each) to give each a chance to finish restarting
# before the status checks might catch them down. See #381.
wait_for_service(25, True, env, 10)
wait_for_service(993, True, env, 10)
def run_duplicity_verification():
env = load_environment()
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_cache_dir = os.path.join(backup_root, 'cache')
backup_dir = os.path.join(backup_root, 'encrypted')
env_with_passphrase = { "PASSPHRASE" : open(os.path.join(backup_root, 'secret_key.txt')).read() }
shell('check_call', [
"/usr/bin/duplicity",
"--verbosity", "info",
"verify",
"--compare-data",
"--archive-dir", backup_cache_dir,
"--exclude", backup_root,
"file://" + backup_dir,
env["STORAGE_ROOT"],
], env_with_passphrase)
if __name__ == "__main__":
import sys
if sys.argv[-1] == "--verify":
# Run duplicity's verification command to check a) the backup files
# are readable, and b) report if they are up to date.
run_duplicity_verification()
else:
# Perform a backup. Add --full to force a full backup rather than
# possibly performing an incremental backup.
full_backup = "--full" in sys.argv
perform_backup(full_backup)

View File

@@ -1,155 +0,0 @@
#!/usr/bin/python3
# Helps you purchase a SSL certificate from Gandi.net using
# their API.
#
# Before you begin:
# 1) Create an account on Gandi.net.
# 2) Pre-pay $16 into your account at https://www.gandi.net/prepaid/operations. Wait until the payment goes through.
# 3) Activate your API key first on the test platform (wait a while, refresh the page) and then activate the production API at https://www.gandi.net/admin/api_key.
import sys, re, os.path, urllib.request
import xmlrpc.client
import rtyaml
from utils import load_environment, shell
from web_update import get_web_domains, get_domain_ssl_files, get_web_root
from whats_next import check_certificate
def buy_ssl_certificate(api_key, domain, command, env):
if domain != env['PRIMARY_HOSTNAME'] \
and domain not in get_web_domains(env):
raise ValueError("Domain is not %s or a domain we're serving a website for." % env['PRIMARY_HOSTNAME'])
# Initialize.
gandi = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/')
try:
existing_certs = gandi.cert.list(api_key)
except Exception as e:
if "Invalid API key" in str(e):
print("Invalid API key. Check that you copied the API Key correctly from https://www.gandi.net/admin/api_key.")
sys.exit(1)
else:
raise
# Where is the SSL cert stored?
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
# Have we already created a cert for this domain?
for cert in existing_certs:
if cert['cn'] == domain:
break
else:
# No existing cert found. Purchase one.
if command != 'purchase':
print("No certificate or order found yet. If you haven't yet purchased a certificate, run ths script again with the 'purchase' command. Otherwise wait a moment and try again.")
sys.exit(1)
else:
# Start an order for a single standard SSL certificate.
# Use DNS validation. Web-based validation won't work because they
# require a file on HTTP but not HTTPS w/o redirects and we don't
# serve anything plainly over HTTP. Email might be another way but
# DNS is easier to automate.
op = gandi.cert.create(api_key, {
"csr": open(ssl_csr_path).read(),
"dcv_method": "dns",
"duration": 1, # year?
"package": "cert_std_1_0_0",
})
print("An SSL certificate has been ordered.")
print()
print(op)
print()
print("In a moment please run this script again with the 'setup' command.")
if cert['status'] == 'pending':
# Get the information we need to update our DNS with a code so that
# Gandi can verify that we own the domain.
dcv = gandi.cert.get_dcv_params(api_key, {
"csr": open(ssl_csr_path).read(),
"cert_id": cert['id'],
"dcv_method": "dns",
"duration": 1, # year?
"package": "cert_std_1_0_0",
})
if dcv["dcv_method"] != "dns":
raise Exception("Certificate ordered with an unknown validation method.")
# Update our DNS data.
dns_config = env['STORAGE_ROOT'] + '/dns/custom.yaml'
if os.path.exists(dns_config):
dns_records = rtyaml.load(open(dns_config))
else:
dns_records = { }
qname = dcv['md5'] + '.' + domain
value = dcv['sha1'] + '.comodoca.com.'
dns_records[qname] = { "CNAME": value }
with open(dns_config, 'w') as f:
f.write(rtyaml.dump(dns_records))
shell('check_call', ['tools/dns_update'])
# Okay, done with this step.
print("DNS has been updated. Gandi will check within 60 minutes.")
print()
print("See https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
elif cert['status'] == 'valid':
# The certificate is ready.
# Check before we overwrite something we shouldn't.
if os.path.exists(ssl_certificate):
cert_status = check_certificate(None, ssl_certificate, None)
if cert_status != "SELF-SIGNED":
print("Please back up and delete the file %s so I can save your new certificate." % ssl_certificate)
sys.exit(1)
# Form the certificate.
# The certificate comes as a long base64-encoded string. Break in
# into lines in the usual way.
pem = "-----BEGIN CERTIFICATE-----\n"
pem += "\n".join(chunk for chunk in re.split(r"(.{64})", cert['cert']) if chunk != "")
pem += "\n-----END CERTIFICATE-----\n\n"
# Append intermediary certificates.
pem += urllib.request.urlopen("https://www.gandi.net/static/CAs/GandiStandardSSLCA.pem").read().decode("ascii")
# Write out.
with open(ssl_certificate, "w") as f:
f.write(pem)
print("The certificate has been installed in %s. Restarting services..." % ssl_certificate)
# Restart dovecot and if this is for PRIMARY_HOSTNAME.
if domain == env['PRIMARY_HOSTNAME']:
shell('check_call', ["/usr/sbin/service", "dovecot", "restart"])
shell('check_call', ["/usr/sbin/service", "postfix", "restart"])
# Restart nginx in all cases.
shell('check_call', ["/usr/sbin/service", "nginx", "restart"])
else:
print("The certificate has an unknown status. Please check https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
if __name__ == "__main__":
if len(sys.argv) < 4:
print("Usage: python management/buy_certificate.py gandi_api_key domain_name {purchase, setup}")
sys.exit(1)
api_key = sys.argv[1]
domain_name = sys.argv[2]
cmd = sys.argv[3]
buy_ssl_certificate(api_key, domain_name, cmd, load_environment())

View File

@@ -7,9 +7,15 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response from flask import Flask, request, render_template, abort, Response
import auth, utils import auth, utils
from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_archived_mail_users from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
# Create a worker pool for the status checks. The pool should
# live across http requests so we don't baloon the system with
# processes.
import multiprocessing.pool
pool = multiprocessing.pool.Pool(processes=10)
env = utils.load_environment() env = utils.load_environment()
@@ -24,19 +30,32 @@ except OSError:
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates"))) app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
# Decorator to protect views that require authentication. # Decorator to protect views that require a user with 'admin' privileges.
def authorized_personnel_only(viewfunc): def authorized_personnel_only(viewfunc):
@wraps(viewfunc) @wraps(viewfunc)
def newview(*args, **kwargs): def newview(*args, **kwargs):
# Check if the user is authorized. # Authenticate the passed credentials, which is either the API key or a username:password pair.
authorized_status = auth_service.is_authenticated(request, env) error = None
if authorized_status == "OK": try:
# Authorized. Call view func. email, privs = auth_service.authenticate(request, env)
except ValueError as e:
# Authentication failed.
privs = []
error = str(e)
# Authorized to access an API view?
if "admin" in privs:
# Call view func.
return viewfunc(*args, **kwargs) return viewfunc(*args, **kwargs)
elif not error:
error = "You are not an administrator."
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default. # Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
status = 401 status = 401
headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) } headers = {
'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm),
'X-Reason': error,
}
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# Don't issue a 401 to an AJAX request because the user will # Don't issue a 401 to an AJAX request because the user will
@@ -46,13 +65,13 @@ def authorized_personnel_only(viewfunc):
if request.headers.get('Accept') in (None, "", "*/*"): if request.headers.get('Accept') in (None, "", "*/*"):
# Return plain text output. # Return plain text output.
return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers) return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
else: else:
# Return JSON output. # Return JSON output.
return Response(json.dumps({ return Response(json.dumps({
"status": "error", "status": "error",
"reason": authorized_status "reason": error,
}+"\n"), status=status, mimetype='application/json', headers=headers) })+"\n", status=status, mimetype='application/json', headers=headers)
return newview return newview
@@ -61,7 +80,7 @@ def unauthorized(error):
return auth_service.make_unauthorized_response() return auth_service.make_unauthorized_response()
def json_response(data): def json_response(data):
return Response(json.dumps(data), status=200, mimetype='application/json') return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
################################### ###################################
@@ -71,43 +90,64 @@ def json_response(data):
def index(): def index():
# Render the control panel. This route does not require user authentication # Render the control panel. This route does not require user authentication
# so it must be safe! # so it must be safe!
no_users_exist = (len(get_mail_users(env)) == 0)
no_admins_exist = (len(get_admins(env)) == 0)
return render_template('index.html', return render_template('index.html',
hostname=env['PRIMARY_HOSTNAME'], hostname=env['PRIMARY_HOSTNAME'],
storage_root=env['STORAGE_ROOT'],
no_users_exist=no_users_exist,
no_admins_exist=no_admins_exist,
) )
@app.route('/me') @app.route('/me')
def me(): def me():
# Is the caller authorized? # Is the caller authorized?
authorized_status = auth_service.is_authenticated(request, env) try:
if authorized_status != "OK": email, privs = auth_service.authenticate(request, env)
except ValueError as e:
return json_response({ return json_response({
"status": "not-authorized", "status": "invalid",
"reason": authorized_status, "reason": str(e),
})
return json_response({
"status": "authorized",
"api_key": auth_service.key,
}) })
resp = {
"status": "ok",
"email": email,
"privileges": privs,
}
# Is authorized as admin? Return an API key for future use.
if "admin" in privs:
resp["api_key"] = auth_service.create_user_key(email)
# Return.
return json_response(resp)
# MAIL # MAIL
@app.route('/mail/users') @app.route('/mail/users')
@authorized_personnel_only @authorized_personnel_only
def mail_users(): def mail_users():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env)) return json_response(get_mail_users_ex(env, with_archived=True, with_slow_info=True))
else: else:
return "".join(x+"\n" for x in get_mail_users(env)) return "".join(x+"\n" for x in get_mail_users(env))
@app.route('/mail/users/add', methods=['POST']) @app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_users_add(): def mail_users_add():
try:
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env) return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env)
except ValueError as e:
return (str(e), 400)
@app.route('/mail/users/password', methods=['POST']) @app.route('/mail/users/password', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_users_password(): def mail_users_password():
try:
return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env) return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)
except ValueError as e:
return (str(e), 400)
@app.route('/mail/users/remove', methods=['POST']) @app.route('/mail/users/remove', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
@@ -137,7 +177,7 @@ def mail_user_privs_remove():
@authorized_personnel_only @authorized_personnel_only
def mail_aliases(): def mail_aliases():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
return json_response(get_mail_aliases(env, as_json=True)) return json_response(get_mail_aliases_ex(env))
else: else:
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
@@ -163,6 +203,12 @@ def mail_domains():
# DNS # DNS
@app.route('/dns/zones')
@authorized_personnel_only
def dns_zones():
from dns_update import get_dns_zones
return json_response([z[0] for z in get_dns_zones(env)])
@app.route('/dns/update', methods=['POST']) @app.route('/dns/update', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def dns_update(): def dns_update():
@@ -172,14 +218,120 @@ def dns_update():
except Exception as e: except Exception as e:
return (str(e), 500) 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, get_secondary_dns
return json_response({ "hostname": get_secondary_dns(get_custom_dns_config(env)) })
@app.route('/dns/secondary-nameserver', methods=['POST'])
@authorized_personnel_only
def dns_set_secondary_nameserver():
from dns_update import set_secondary_dns
try:
return set_secondary_dns(request.form.get('hostname'), env)
except ValueError as e:
return (str(e), 400)
@app.route('/dns/custom')
@authorized_personnel_only
def dns_get_records(qname=None, rtype=None):
from dns_update import get_custom_dns_config
return json_response([
{
"qname": r[0],
"rtype": r[1],
"value": r[2],
}
for r in get_custom_dns_config(env)
if r[0] != "_secondary_nameserver"
and (not qname or r[0] == qname)
and (not rtype or r[1] == rtype) ])
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
@app.route('/dns/custom/<qname>/<rtype>', methods=['GET', 'POST', 'PUT', 'DELETE'])
@authorized_personnel_only
def dns_set_record(qname, rtype="A"):
from dns_update import do_dns_update, set_custom_dns_record
try:
# Normalize.
rtype = rtype.upper()
# Read the record value from the request BODY, which must be
# ASCII-only. Not used with GET.
value = request.stream.read().decode("ascii", "ignore").strip()
if request.method == "GET":
# Get the existing records matching the qname and rtype.
return dns_get_records(qname, rtype)
elif request.method in ("POST", "PUT"):
# There is a default value for A/AAAA records.
if rtype in ("A", "AAAA") and value == "":
value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy
# Cannot add empty records.
if value == '':
return ("No value for the record provided.", 400)
if request.method == "POST":
# Add a new record (in addition to any existing records
# for this qname-rtype pair).
action = "add"
elif request.method == "PUT":
# In REST, PUT is supposed to be idempotent, so we'll
# make this action set (replace all records for this
# qname-rtype pair) rather than add (add a new record).
action = "set"
elif request.method == "DELETE":
if value == '':
# Delete all records for this qname-type pair.
value = None
else:
# Delete just the qname-rtype-value record exactly.
pass
action = "remove"
if set_custom_dns_record(qname, rtype, value, action, env):
return do_dns_update(env) or "Something isn't right."
return "OK"
except ValueError as e:
return (str(e), 400)
@app.route('/dns/dump') @app.route('/dns/dump')
@authorized_personnel_only @authorized_personnel_only
def dns_get_dump(): def dns_get_dump():
from dns_update import build_recommended_dns from dns_update import build_recommended_dns
return json_response(build_recommended_dns(env)) return json_response(build_recommended_dns(env))
# SSL
@app.route('/ssl/csr/<domain>', methods=['POST'])
@authorized_personnel_only
def ssl_get_csr(domain):
from web_update import get_domain_ssl_files, create_csr
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
return create_csr(domain, ssl_key, env)
@app.route('/ssl/install', methods=['POST'])
@authorized_personnel_only
def ssl_install_cert():
from web_update import install_cert
domain = request.form.get('domain')
ssl_cert = request.form.get('cert')
ssl_chain = request.form.get('chain')
return install_cert(domain, ssl_cert, ssl_chain, env)
# WEB # WEB
@app.route('/web/domains')
@authorized_personnel_only
def web_get_domains():
from web_update import get_web_domains_info
return json_response(get_web_domains_info(env))
@app.route('/web/update', methods=['POST']) @app.route('/web/update', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def web_update(): def web_update():
@@ -191,7 +343,7 @@ def web_update():
@app.route('/system/status', methods=["POST"]) @app.route('/system/status', methods=["POST"])
@authorized_personnel_only @authorized_personnel_only
def system_status(): def system_status():
from whats_next import run_checks from status_checks import run_checks
class WebOutput: class WebOutput:
def __init__(self): def __init__(self):
self.items = [] self.items = []
@@ -201,23 +353,22 @@ def system_status():
self.items.append({ "type": "ok", "text": message, "extra": [] }) self.items.append({ "type": "ok", "text": message, "extra": [] })
def print_error(self, message): def print_error(self, message):
self.items.append({ "type": "error", "text": message, "extra": [] }) self.items.append({ "type": "error", "text": message, "extra": [] })
def print_warning(self, message):
self.items.append({ "type": "warning", "text": message, "extra": [] })
def print_line(self, message, monospace=False): def print_line(self, message, monospace=False):
self.items[-1]["extra"].append({ "text": message, "monospace": monospace }) self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
output = WebOutput() output = WebOutput()
run_checks(env, output) run_checks(False, env, output, pool)
return json_response(output.items) return json_response(output.items)
@app.route('/system/updates') @app.route('/system/updates')
@authorized_personnel_only @authorized_personnel_only
def show_updates(): def show_updates():
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) from status_checks import list_apt_updates
simulated_install = utils.shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"]) return "".join(
pkgs = [] "%s (%s)\n"
for line in simulated_install.split('\n'): % (p["package"], p["version"])
if re.match(r'^Conf .*', line): continue # remove these lines, not informative for p in list_apt_updates())
line = re.sub(r'^Inst (.*) \[(.*)\] \((\S*).*', r'Updated Package Available: \1 (\3)', line) # make these lines prettier
pkgs.append(line)
return "\n".join(pkgs)
@app.route('/system/update-packages', methods=["POST"]) @app.route('/system/update-packages', methods=["POST"])
@authorized_personnel_only @authorized_personnel_only
@@ -227,6 +378,12 @@ def do_updates():
"DEBIAN_FRONTEND": "noninteractive" "DEBIAN_FRONTEND": "noninteractive"
}) })
@app.route('/system/backup/status')
@authorized_personnel_only
def backup_status():
from backup import backup_status
return json_response(backup_status(env))
# APP # APP
if __name__ == '__main__': if __name__ == '__main__':
@@ -244,5 +401,6 @@ if __name__ == '__main__':
# debug console and enter that as the username # debug console and enter that as the username
app.logger.info('API key: ' + auth_service.key) app.logger.info('API key: ' + auth_service.key)
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
app.run(port=10222) app.run(port=10222)

View File

@@ -4,8 +4,10 @@
# and mail aliases and restarts nsd. # and mail aliases and restarts nsd.
######################################################################## ########################################################################
import os, os.path, urllib.parse, datetime, re, hashlib import sys, os, os.path, urllib.parse, datetime, re, hashlib, base64
import ipaddress
import rtyaml import rtyaml
import dns.resolver
from mailconfig import get_mail_domains from mailconfig import get_mail_domains
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
@@ -48,19 +50,13 @@ def get_dns_zones(env):
return zonefiles return zonefiles
def get_custom_dns_config(env):
try:
return rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
except:
return { }
def do_dns_update(env, force=False): def do_dns_update(env, force=False):
# What domains (and their zone filenames) should we build? # What domains (and their zone filenames) should we build?
domains = get_dns_domains(env) domains = get_dns_domains(env)
zonefiles = get_dns_zones(env) zonefiles = get_dns_zones(env)
# Custom records to add to zones. # Custom records to add to zones.
additional_records = get_custom_dns_config(env) additional_records = list(get_custom_dns_config(env))
# Write zone files. # Write zone files.
os.makedirs('/etc/nsd/zones', exist_ok=True) os.makedirs('/etc/nsd/zones', exist_ok=True)
@@ -104,7 +100,7 @@ def do_dns_update(env, force=False):
zonefiles[i][1] += ".signed" zonefiles[i][1] += ".signed"
# Write the main nsd.conf file. # Write the main nsd.conf file.
if write_nsd_conf(zonefiles, env): if write_nsd_conf(zonefiles, additional_records, env):
# Make sure updated_domains contains *something* if we wrote an updated # Make sure updated_domains contains *something* if we wrote an updated
# nsd.conf so that we know to restart nsd. # nsd.conf so that we know to restart nsd.
if len(updated_domains) == 0: if len(updated_domains) == 0:
@@ -115,7 +111,7 @@ def do_dns_update(env, force=False):
shell('check_call', ["/usr/sbin/service", "nsd", "restart"]) shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
# Write the OpenDKIM configuration tables. # Write the OpenDKIM configuration tables.
if write_opendkim_tables(zonefiles, env): if write_opendkim_tables(domains, env):
# Settings changed. Kick opendkim. # Settings changed. Kick opendkim.
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"]) shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
if len(updated_domains) == 0: if len(updated_domains) == 0:
@@ -133,12 +129,22 @@ def do_dns_update(env, force=False):
def build_zone(domain, all_domains, additional_records, env, is_zone=True): def build_zone(domain, all_domains, additional_records, env, is_zone=True):
records = [] records = []
# For top-level zones, define ourselves as the authoritative name server. # For top-level zones, define the authoritative name servers.
#
# Normally we are our own nameservers. Some TLDs require two distinct IP addresses,
# so we allow the user to override the second nameserver definition so that
# secondary DNS can be set up elsewhere.
#
# 'False' in the tuple indicates these records would not be used if the zone # 'False' in the tuple indicates these records would not be used if the zone
# is managed outside of the box. # is managed outside of the box.
if is_zone: if is_zone:
# Obligatory definition of ns1.PRIMARY_HOSTNAME.
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False)) records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
records.append((None, "NS", "ns2.%s." % env["PRIMARY_HOSTNAME"], False))
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
secondary_ns = get_secondary_dns(additional_records) or ("ns2." + env["PRIMARY_HOSTNAME"])
records.append((None, "NS", secondary_ns+'.', False))
# In PRIMARY_HOSTNAME... # In PRIMARY_HOSTNAME...
if domain == env["PRIMARY_HOSTNAME"]: if domain == env["PRIMARY_HOSTNAME"]:
@@ -159,19 +165,19 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
# Add a DANE TLSA record for SMTP. # Add a DANE TLSA record for SMTP.
records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used.")) records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used."))
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
for value in build_sshfp_records():
records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh."))
# The MX record says where email for the domain should be delivered: Here! # The MX record says where email for the domain should be delivered: Here!
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain)) records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else.
records.append((None, "TXT", '"v=spf1 mx -all"', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
# Add DNS records for any subdomains of this domain. We should not have a zone for # Add DNS records for any subdomains of this domain. We should not have a zone for
# both a domain and one of its subdomains. # both a domain and one of its subdomains.
subdomains = [d for d in all_domains if d.endswith("." + domain)] subdomains = [d for d in all_domains if d.endswith("." + domain)]
for subdomain in subdomains: for subdomain in subdomains:
subdomain_qname = subdomain[0:-len("." + domain)] subdomain_qname = subdomain[0:-len("." + domain)]
subzone = build_zone(subdomain, [], {}, env, is_zone=False) subzone = build_zone(subdomain, [], additional_records, env, is_zone=False)
for child_qname, child_rtype, child_value, child_explanation in subzone: for child_qname, child_rtype, child_value, child_explanation in subzone:
if child_qname == None: if child_qname == None:
child_qname = subdomain_qname child_qname = subdomain_qname
@@ -179,20 +185,36 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
child_qname += "." + subdomain_qname child_qname += "." + subdomain_qname
records.append((child_qname, child_rtype, child_value, child_explanation)) records.append((child_qname, child_rtype, child_value, child_explanation))
def has_rec(qname, rtype): has_rec_base = list(records) # clone current state
for rec in records: def has_rec(qname, rtype, prefix=None):
if rec[0] == qname and rec[1] == rtype: for rec in has_rec_base:
if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)):
return True return True
return False return False
# The user may set other records that don't conflict with our settings. # The user may set other records that don't conflict with our settings.
for qname, rtype, value in get_custom_records(domain, additional_records, env): # Don't put any TXT records above this line, or it'll prevent any custom TXT records.
for qname, rtype, value in filter_custom_records(domain, additional_records):
# Don't allow custom records for record types that override anything above.
# But allow multiple custom records for the same rtype --- see how has_rec_base is used.
if has_rec(qname, rtype): continue if has_rec(qname, rtype): continue
# The "local" keyword on A/AAAA records are short-hand for our own IP.
# This also flags for web configuration that the user wants a website here.
if rtype == "A" and value == "local":
value = env["PUBLIC_IP"]
if rtype == "AAAA" and value == "local":
if "PUBLIC_IPV6" in env:
value = env["PUBLIC_IPV6"]
else:
continue
records.append((qname, rtype, value, "(Set by user.)")) records.append((qname, rtype, value, "(Set by user.)"))
# Add defaults if not overridden by the user's custom settings. # Add defaults if not overridden by the user's custom settings (and not otherwise configured).
# Any "CNAME" record on the qname overrides A and AAAA.
has_rec_base = records
defaults = [ defaults = [
(None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain), (None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain), ("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain), (None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain),
("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to, e.g. for web hosting." % domain), ("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to, e.g. for web hosting." % domain),
@@ -200,19 +222,42 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
for qname, rtype, value, explanation in defaults: for qname, rtype, value, explanation in defaults:
if value is None or value.strip() == "": continue # skip IPV6 if not set if value is None or value.strip() == "": continue # skip IPV6 if not set
if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains
if not has_rec(qname, rtype): # Set the default record, but not if:
# (1) there is not a user-set record of the same type already
# (2) there is not a CNAME record already, since you can't set both and who knows what takes precedence
# (2) there is not an A record already (if this is an A record this is a dup of (1), and if this is an AAAA record then don't set a default AAAA record if the user sets a custom A record, since the default wouldn't make sense and it should not resolve if the user doesn't provide a new AAAA record)
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"):
records.append((qname, rtype, value, explanation)) records.append((qname, rtype, value, explanation))
# If OpenDKIM is in use.. # SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else.
# Skip if the user has set a custom SPF record.
if not has_rec(None, "TXT", prefix="v=spf1 "):
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
# Skip if the user has set a DKIM record already.
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
if os.path.exists(opendkim_record_file):
# Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above.
with open(opendkim_record_file) as orf: with open(opendkim_record_file) as orf:
m = re.match(r"(\S+)\s+IN\s+TXT\s+(\(.*\))\s*;", orf.read(), re.S) m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
records.append((m.group(1), "TXT", m.group(2), "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain)) val = m.group(2) + m.group(3)
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
# Append a DMARC record. # 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)) # Skip if the user has set a DMARC record already.
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature 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 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % (qname + "." + domain)))
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', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % (qname + "." + domain)))
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter. # Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else "")) records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
@@ -221,50 +266,6 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
######################################################################## ########################################################################
def get_custom_records(domain, additional_records, env):
for qname, value in additional_records.items():
# Is this record for the domain or one of its subdomains?
if qname != domain and not qname.endswith("." + domain): continue
# Turn the fully qualified domain name in the YAML file into
# our short form (None => domain, or a relative QNAME).
if qname == domain:
qname = None
else:
qname = qname[0:len(qname)-len("." + domain)]
# Short form. Mapping a domain name to a string is short-hand
# for creating A records.
if isinstance(value, str):
values = [("A", value)]
if value == "local" and env.get("PUBLIC_IPV6"):
values.appnd( ("AAAA", value) )
# A mapping creates multiple records.
elif isinstance(value, dict):
values = value.items()
# No other type of data is allowed.
else:
raise ValueError()
for rtype, value2 in values:
# The "local" keyword on A/AAAA records are short-hand for our own IP.
# This also flags for web configuration that the user wants a website here.
if rtype == "A" and value2 == "local":
value2 = env["PUBLIC_IP"]
if rtype == "AAAA" and value2 == "local":
if "PUBLIC_IPV6" not in env: continue # no IPv6 address is available so don't set anything
value2 = env["PUBLIC_IPV6"]
# For typical zone file output, quote a text record.
if rtype == "TXT":
value2 = "\"" + value2 + "\""
yield (qname, rtype, value2)
########################################################################
def build_tlsa_record(env): def build_tlsa_record(env):
# A DANE TLSA record in DNS specifies that connections on a port # A DANE TLSA record in DNS specifies that connections on a port
# must use TLS and the certificate must match a particular certificate. # must use TLS and the certificate must match a particular certificate.
@@ -288,26 +289,67 @@ def build_tlsa_record(env):
# 1: The certificate is SHA256'd here. # 1: The certificate is SHA256'd here.
return "3 0 1 " + certhash return "3 0 1 " + certhash
def build_sshfp_records():
# The SSHFP record is a way for us to embed this server's SSH public
# key fingerprint into the DNS so that remote hosts have an out-of-band
# method to confirm the fingerprint. See RFC 4255 and RFC 6594. This
# depends on DNSSEC.
#
# On the client side, set SSH's VerifyHostKeyDNS option to 'ask' to
# include this info in the key verification prompt or 'yes' to trust
# the SSHFP record.
#
# See https://github.com/xelerance/sshfp for inspiriation.
algorithm_number = {
"ssh-rsa": 1,
"ssh-dss": 2,
"ecdsa-sha2-nistp256": 3,
}
# Get our local fingerprints by running ssh-keyscan. The output looks
# 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 sorted(keys.split("\n")):
if key.strip() == "" or key[0] == "#": continue
try:
host, keytype, pubkey = key.split(" ")
yield "%d %d ( %s )" % (
algorithm_number[keytype],
2, # specifies we are using SHA-256 on next line
hashlib.sha256(base64.b64decode(pubkey)).hexdigest().upper(),
)
except:
# Lots of things can go wrong. Don't let it disturb the DNS
# zone.
pass
######################################################################## ########################################################################
def write_nsd_zone(domain, zonefile, records, env, force): def write_nsd_zone(domain, zonefile, records, env, force):
# We set the administrative email address for every domain to domain_contact@[domain.com].
# You should probably create an alias to your email address.
# On the $ORIGIN line, there's typically a ';' comment at the end explaining # On the $ORIGIN line, there's typically a ';' comment at the end explaining
# what the $ORIGIN line does. Any further data after the domain confuses # what the $ORIGIN line does. Any further data after the domain confuses
# ldns-signzone, however. It used to say '; default zone domain'. # ldns-signzone, however. It used to say '; default zone domain'.
# The SOA contact address for all of the domains on this system is hostmaster
# @ the PRIMARY_HOSTNAME. Hopefully that's legit.
# For the refresh through TTL fields, a good reference is:
# http://www.peerwisdom.org/2013/05/15/dns-understanding-the-soa-record/
zone = """ zone = """
$ORIGIN {domain}. $ORIGIN {domain}.
$TTL 86400 ; default time to live $TTL 1800 ; default time to live
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. ( @ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
__SERIAL__ ; serial number __SERIAL__ ; serial number
28800 ; Refresh 7200 ; Refresh (secondary nameserver update interval)
7200 ; Retry 1800 ; Retry (when refresh fails, how often to try again)
864000 ; Expire 1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)
86400 ; Min TTL 1800 ; Negative TTL (how long negative responses are cached)
) )
""" """
@@ -319,6 +361,10 @@ $TTL 86400 ; default time to live
if subdomain: if subdomain:
zone += subdomain zone += subdomain
zone += "\tIN\t" + querytype + "\t" zone += "\tIN\t" + querytype + "\t"
if querytype == "TXT":
value = value.replace('\\', '\\\\') # escape backslashes
value = value.replace('"', '\\"') # escape quotes
value = '"' + value + '"' # wrap in quotes
zone += value + "\n" zone += value + "\n"
# DNSSEC requires re-signing a zone periodically. That requires # DNSSEC requires re-signing a zone periodically. That requires
@@ -385,26 +431,10 @@ $TTL 86400 ; default time to live
######################################################################## ########################################################################
def write_nsd_conf(zonefiles, env): def write_nsd_conf(zonefiles, additional_records, env):
# Basic header. # Write the list of zones to a configuration file.
nsdconf = """ nsd_conf_file = "/etc/nsd/zones.conf"
server: nsdconf = ""
hide-version: yes
# identify the server (CH TXT ID.SERVER entry).
identity: ""
# The directory for zonefile: files.
zonesdir: "/etc/nsd/zones"
"""
# Since we have bind9 listening on localhost for locally-generated
# DNS queries that require a recursive nameserver, and the system
# might have other network interfaces for e.g. tunnelling, we have
# to be specific about the network interfaces that nsd binds to.
for ipaddr in (env.get("PRIVATE_IP", "") + " " + env.get("PRIVATE_IPV6", "")).split(" "):
if ipaddr == "": continue
nsdconf += " ip-address: %s\n" % ipaddr
# Append the zones. # Append the zones.
for domain, zonefile in zonefiles: for domain, zonefile in zonefiles:
@@ -414,21 +444,49 @@ zone:
zonefile: %s zonefile: %s
""" % (domain, zonefile) """ % (domain, zonefile)
# Check if the nsd.conf is changing. If it isn't changing, # If a custom secondary nameserver has been set, allow zone transfers
# and notifies to that nameserver.
if get_secondary_dns(additional_records):
# Get the IP address of the nameserver by resolving it.
hostname = get_secondary_dns(additional_records)
resolver = dns.resolver.get_default_resolver()
response = dns.resolver.query(hostname+'.', "A")
ipaddr = str(response[0])
nsdconf += """\tnotify: %s NOKEY
provide-xfr: %s NOKEY
""" % (ipaddr, ipaddr)
# Check if the file is changing. If it isn't changing,
# return False to flag that no change was made. # return False to flag that no change was made.
with open("/etc/nsd/nsd.conf") as f: if os.path.exists(nsd_conf_file):
with open(nsd_conf_file) as f:
if f.read() == nsdconf: if f.read() == nsdconf:
return False return False
with open("/etc/nsd/nsd.conf", "w") as f: # Write out new contents and return True to signal that
# configuration changed.
with open(nsd_conf_file, "w") as f:
f.write(nsdconf) f.write(nsdconf)
return True return True
######################################################################## ########################################################################
def dnssec_choose_algo(domain, env):
if '.' in domain and domain.rsplit('.')[-1] in \
("email", "guide", "fund"):
# At GoDaddy, RSASHA256 is the only algorithm supported
# for .email and .guide.
# A variety of algorithms are supported for .fund. This
# is preferred.
return "RSASHA256"
# For any domain we were able to sign before, don't change the algorithm
# on existing users. We'll probably want to migrate to SHA256 later.
return "RSASHA1-NSEC3-SHA1"
def sign_zone(domain, zonefile, env): def sign_zone(domain, zonefile, env):
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/keys.conf')) algo = dnssec_choose_algo(domain, env)
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
# In order to use the same keys for all domains, we have to generate # In order to use the same keys for all domains, we have to generate
# a new .key file with a DNSSEC record for the specific domain. We # a new .key file with a DNSSEC record for the specific domain. We
@@ -499,8 +557,9 @@ def sign_zone(domain, zonefile, env):
######################################################################## ########################################################################
def write_opendkim_tables(zonefiles, env): def write_opendkim_tables(domains, env):
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain. # Append a record to OpenDKIM's KeyTable and SigningTable for each domain
# that we send mail from (zones and all subdomains).
opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private') opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')
@@ -519,7 +578,7 @@ def write_opendkim_tables(zonefiles, env):
"SigningTable": "SigningTable":
"".join( "".join(
"*@{domain} {domain}\n".format(domain=domain) "*@{domain} {domain}\n".format(domain=domain)
for domain, zonefile in zonefiles for domain in domains
), ),
# The KeyTable specifies the signing domain, the DKIM selector, and the # The KeyTable specifies the signing domain, the DKIM selector, and the
@@ -528,7 +587,7 @@ def write_opendkim_tables(zonefiles, env):
"KeyTable": "KeyTable":
"".join( "".join(
"{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file) "{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file)
for domain, zonefile in zonefiles for domain in domains
), ),
} }
@@ -551,6 +610,197 @@ def write_opendkim_tables(zonefiles, env):
######################################################################## ########################################################################
def get_custom_dns_config(env):
try:
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
if not isinstance(custom_dns, dict): raise ValueError() # caught below
except:
return [ ]
for qname, value in custom_dns.items():
# Short form. Mapping a domain name to a string is short-hand
# for creating A records.
if isinstance(value, str):
values = [("A", value)]
# A mapping creates multiple records.
elif isinstance(value, dict):
values = value.items()
# No other type of data is allowed.
else:
raise ValueError()
for rtype, value2 in values:
if isinstance(value2, str):
yield (qname, rtype, value2)
elif isinstance(value2, list):
for value3 in value2:
yield (qname, rtype, value3)
# No other type of data is allowed.
else:
raise ValueError()
def filter_custom_records(domain, custom_dns_iter):
for qname, rtype, value in custom_dns_iter:
# We don't count the secondary nameserver config (if present) as a record - that would just be
# confusing to users. Instead it is accessed/manipulated directly via (get/set)_custom_dns_config.
if qname == "_secondary_nameserver": continue
# Is this record for the domain or one of its subdomains?
# If `domain` is None, return records for all domains.
if domain is not None and qname != domain and not qname.endswith("." + domain): continue
# Turn the fully qualified domain name in the YAML file into
# our short form (None => domain, or a relative QNAME) if
# domain is not None.
if domain is not None:
if qname == domain:
qname = None
else:
qname = qname[0:len(qname)-len("." + domain)]
yield (qname, rtype, value)
def write_custom_dns_config(config, env):
# We get a list of (qname, rtype, value) triples. Convert this into a
# nice dictionary format for storage on disk.
from collections import OrderedDict
config = list(config)
dns = OrderedDict()
seen_qnames = set()
# Process the qnames in the order we see them.
for qname in [rec[0] for rec in config]:
if qname in seen_qnames: continue
seen_qnames.add(qname)
records = [(rec[1], rec[2]) for rec in config if rec[0] == qname]
if len(records) == 1 and records[0][0] == "A":
dns[qname] = records[0][1]
else:
dns[qname] = OrderedDict()
seen_rtypes = set()
# Process the rtypes in the order we see them.
for rtype in [rec[0] for rec in records]:
if rtype in seen_rtypes: continue
seen_rtypes.add(rtype)
values = [rec[1] for rec in records if rec[0] == rtype]
if len(values) == 1:
values = values[0]
dns[qname][rtype] = values
# Write.
config_yaml = rtyaml.dump(dns)
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
f.write(config_yaml)
def set_custom_dns_record(qname, rtype, value, action, env):
# validate qname
for zone, fn in get_dns_zones(env):
# It must match a zone apex or be a subdomain of a zone
# that we are otherwise hosting.
if qname == zone or qname.endswith("."+zone):
break
else:
# No match.
if qname != "_secondary_nameserver":
raise ValueError("%s is not a domain name or a subdomain of a domain name managed by this box." % qname)
# validate rtype
rtype = rtype.upper()
if value is not None and qname != "_secondary_nameserver":
if rtype in ("A", "AAAA"):
if value != "local": # "local" is a special flag for us
v = ipaddress.ip_address(value) # raises a ValueError if there's a problem
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
elif rtype in ("CNAME", "TXT", "SRV", "MX"):
# anything goes
pass
else:
raise ValueError("Unknown record type '%s'." % rtype)
# load existing config
config = list(get_custom_dns_config(env))
# update
newconfig = []
made_change = False
needs_add = True
for _qname, _rtype, _value in config:
if action == "add":
if (_qname, _rtype, _value) == (qname, rtype, value):
# Record already exists. Bail.
return False
elif action == "set":
if (_qname, _rtype) == (qname, rtype):
if _value == value:
# Flag that the record already exists, don't
# need to add it.
needs_add = False
else:
# Drop any other values for this (qname, rtype).
made_change = True
continue
elif action == "remove":
if (_qname, _rtype, _value) == (qname, rtype, value):
# Drop this record.
made_change = True
continue
if value == None and (_qname, _rtype) == (qname, rtype):
# Drop all qname-rtype records.
made_change = True
continue
else:
raise ValueError("Invalid action: " + action)
# Preserve this record.
newconfig.append((_qname, _rtype, _value))
if action in ("add", "set") and needs_add and value is not None:
newconfig.append((qname, rtype, value))
made_change = True
if made_change:
# serialize & save
write_custom_dns_config(newconfig, env)
return made_change
########################################################################
def get_secondary_dns(custom_dns):
for qname, rtype, value in custom_dns:
if qname == "_secondary_nameserver":
return value
return None
def set_secondary_dns(hostname, env):
if hostname in (None, ""):
# Clear.
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
else:
# Validate.
hostname = hostname.strip().lower()
resolver = dns.resolver.get_default_resolver()
try:
response = dns.resolver.query(hostname, "A")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
raise ValueError("Could not resolve the IP address of %s." % hostname)
# Set.
set_custom_dns_record("_secondary_nameserver", "A", hostname, "set", env)
# Apply.
return do_dns_update(env)
########################################################################
def justtestingdotemail(domain, records): def justtestingdotemail(domain, records):
# If the domain is a subdomain of justtesting.email, which we own, # If the domain is a subdomain of justtesting.email, which we own,
# automatically populate the zone where it is set up on dns4e.com. # automatically populate the zone where it is set up on dns4e.com.
@@ -596,8 +846,9 @@ def build_recommended_dns(env):
ret = [] ret = []
domains = get_dns_domains(env) domains = get_dns_domains(env)
zonefiles = get_dns_zones(env) zonefiles = get_dns_zones(env)
additional_records = list(get_custom_dns_config(env))
for domain, zonefile in zonefiles: for domain, zonefile in zonefiles:
records = build_zone(domain, domains, {}, env) records = build_zone(domain, domains, additional_records, env)
# remove records that we don't dislay # remove records that we don't dislay
records = [r for r in records if r[3] is not False] records = [r for r in records if r[3] is not False]
@@ -626,6 +877,9 @@ def build_recommended_dns(env):
if __name__ == "__main__": if __name__ == "__main__":
from utils import load_environment from utils import load_environment
env = load_environment() env = load_environment()
if sys.argv[-1] == "--lint":
write_custom_dns_config(get_custom_dns_config(env), env)
else:
for zone, records in build_recommended_dns(env): for zone, records in build_recommended_dns(env):
for record in records: for record in records:
print("; " + record['explanation']) print("; " + record['explanation'])

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

@@ -2,42 +2,81 @@
import subprocess, shutil, os, sqlite3, re import subprocess, shutil, os, sqlite3, re
import utils import utils
from email_validator import validate_email as validate_email_, EmailNotValidError
def validate_email(email, mode=None): def validate_email(email, mode=None):
# Checks that an email address is syntactically valid. Returns True/False.
# Until Postfix supports SMTPUTF8, an email address may contain ASCII
# characters only; IDNs must be IDNA-encoded.
#
# When mode=="user", we're checking that this can be a user account name.
# Dovecot has tighter restrictions - letters, numbers, underscore, and
# dash only!
#
# When mode=="alias", we're allowing anything that can be in a Postfix
# alias table, i.e. omitting the local part ("@domain.tld") is OK.
# Check the syntax of the address.
try:
validate_email_(email,
allow_smtputf8=False,
check_deliverability=False,
allow_empty_local=(mode=="alias")
)
except EmailNotValidError:
return False
if mode == 'user':
# There are a lot of characters permitted in email addresses, but # There are a lot of characters permitted in email addresses, but
# Dovecot's sqlite driver seems to get confused if there are any # Dovecot's sqlite auth driver seems to get confused if there are any
# unusual characters in the address. Bah. Also note that since # unusual characters in the address. Bah. Also note that since
# the mailbox path name is based on the email address, the address # the mailbox path name is based on the email address, the address
# shouldn't be absurdly long and must not have a forward slash. # shouldn't be absurdly long and must not have a forward slash.
# Our database is case sensitive (oops), which affects mail delivery
# (Postfix always queries in lowercase?), so also only permit lowercase
# letters.
if len(email) > 255: return False if len(email) > 255: return False
if re.search(r'[^\@\.a-z0-9_\-]+', email):
return False
if mode == 'user': # Everything looks good.
# For Dovecot's benefit, only allow basic characters. return True
ATEXT = r'[\w\-]'
elif mode == 'alias':
# For aliases, we can allow any valid email address.
# Based on RFC 2822 and https://github.com/SyrusAkbary/validate_email/blob/master/validate_email.py,
# these characters are permitted in email addresses.
ATEXT = r'[\w!#$%&\'\*\+\-/=\?\^`\{\|\}~]' # see 3.2.4
else:
raise ValueError(mode)
# per RFC 2822 3.2.4 def sanitize_idn_email_address(email):
DOT_ATOM_TEXT_LOCAL = ATEXT + r'+(?:\.' + ATEXT + r'+)*' # The user may enter Unicode in an email address. Convert the domain part
if mode == 'alias': # to IDNA before going into our database. Leave the local part alone ---
# For aliases, Postfix accepts '@domain.tld' format for # although validate_email will reject non-ASCII characters.
# catch-all addresses. Make the local part optional. #
DOT_ATOM_TEXT_LOCAL = '(?:' + DOT_ATOM_TEXT_LOCAL + ')?' # The domain name system only exists in ASCII, so it doesn't make sense
# to store domain names in Unicode. We want to store what is meaningful
# to the underlying protocols.
try:
localpart, domainpart = email.split("@")
domainpart = domainpart.encode("idna").decode('ascii')
return localpart + "@" + domainpart
except:
# Domain part is not IDNA-valid, so leave unchanged. If there
# are non-ASCII characters it will be filtered out by
# validate_email.
return email
# as above, but we can require that the host part have at least def prettify_idn_email_address(email):
# one period in it, so use a "+" rather than a "*" at the end # This is the opposite of sanitize_idn_email_address. We store domain
DOT_ATOM_TEXT_HOST = ATEXT + r'+(?:\.' + ATEXT + r'+)+' # names in IDNA in the database, but we want to show Unicode to the user.
try:
localpart, domainpart = email.split("@")
domainpart = domainpart.encode("ascii").decode('idna')
return localpart + "@" + domainpart
except:
# Failed to decode IDNA. Should never happen.
return email
# per RFC 2822 3.4.1 def is_dcv_address(email):
ADDR_SPEC = '^%s@%s$' % (DOT_ATOM_TEXT_LOCAL, DOT_ATOM_TEXT_HOST) email = email.lower()
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster"):
return re.match(ADDR_SPEC, email) if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
return True
return False
def open_database(env, with_connection=False): def open_database(env, with_connection=False):
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite") conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
@@ -46,106 +85,186 @@ def open_database(env, with_connection=False):
else: else:
return conn, conn.cursor() return conn, conn.cursor()
def get_mail_users(env, as_json=False): def get_mail_users(env):
# Returns a flat, sorted list of all user accounts.
c = open_database(env)
c.execute('SELECT email FROM users')
users = [ row[0] for row in c.fetchall() ]
return utils.sort_email_addresses(users, env)
def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
# Returns a complex data structure of all user accounts, optionally
# including archived (status="inactive") accounts.
#
# [
# {
# domain: "domain.tld",
# users: [
# {
# email: "name@domain.tld",
# privileges: [ "priv1", "priv2", ... ],
# status: "active" | "inactive",
# },
# ...
# ]
# },
# ...
# ]
# Get users and their privileges.
users = []
active_accounts = set()
c = open_database(env) c = open_database(env)
c.execute('SELECT email, privileges FROM users') c.execute('SELECT email, privileges FROM users')
for email, privileges in c.fetchall():
active_accounts.add(email)
# turn into a list of tuples, but sorted by domain & email address user = {
users = { row[0]: row[1] for row in c.fetchall() } # make dict
users = [ (email, users[email]) for email in utils.sort_email_addresses(users.keys(), env) ]
if not as_json:
return [email for email, privileges in users]
else:
aliases = get_mail_alias_map(env)
return [
{
"email": email, "email": email,
"privileges": parse_privs(privileges), "privileges": parse_privs(privileges),
"status": "active", "status": "active",
"aliases": [
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
for alias in aliases.get(email.lower(), [])
]
} }
for email, privileges in users users.append(user)
]
def get_archived_mail_users(env): if with_slow_info:
real_users = set(get_mail_users(env)) user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
# Add in archived accounts.
if with_archived:
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes') root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
ret = [] for domain in os.listdir(root):
for domain_enc in os.listdir(root): for user in os.listdir(os.path.join(root, domain)):
for user_enc in os.listdir(os.path.join(root, domain_enc)): email = user + "@" + domain
email = utils.unsafe_domain_name(user_enc) + "@" + utils.unsafe_domain_name(domain_enc) mbox = os.path.join(root, domain, user)
if email in real_users: continue if email in active_accounts: continue
ret.append({ user = {
"email": email, "email": email,
"privileges": "", "privileges": "",
"status": "inactive" "status": "inactive",
}) "mailbox": mbox,
return ret }
users.append(user)
if with_slow_info:
user["mailbox_size"] = utils.du(mbox)
def get_mail_aliases(env, as_json=False): # Group by domain.
domains = { }
for user in users:
domain = get_domain(user["email"])
if domain not in domains:
domains[domain] = {
"domain": domain,
"users": []
}
domains[domain]["users"].append(user)
# Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
# Sort users within each domain first by status then lexicographically by email address.
for domain in domains:
domain["users"].sort(key = lambda user : (user["status"] != "active", user["email"]))
return domains
def get_admins(env):
# Returns a set of users with admin privileges.
users = set()
for domain in get_mail_users_ex(env):
for user in domain["users"]:
if "admin" in user["privileges"]:
users.add(user["email"])
return users
def get_mail_aliases(env):
# Returns a sorted list of tuples of (alias, forward-to string).
c = open_database(env) c = open_database(env)
c.execute('SELECT source, destination FROM aliases') c.execute('SELECT source, destination FROM aliases')
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
# put in a canonical order: sort by domain, then by email address lexicographically # put in a canonical order: sort by domain, then by email address lexicographically
aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ] # sort aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ]
return aliases
# but put automatic aliases to administrator@ last def get_mail_aliases_ex(env):
aliases.sort(key = lambda x : x[1] == get_system_administrator(env)) # Returns a complex data structure of all mail aliases, similar
# to get_mail_users_ex.
#
# [
# {
# domain: "domain.tld",
# alias: [
# {
# source: "name@domain.tld", # IDNA-encoded
# source_display: "name@domain.tld", # full Unicode
# destination: ["target1@domain.com", "target2@domain.com", ...],
# required: True|False
# },
# ...
# ]
# },
# ...
# ]
if as_json:
required_aliases = get_required_aliases(env) required_aliases = get_required_aliases(env)
aliases = [ domains = {}
{ for source, destination in get_mail_aliases(env):
"source": alias[0], # get alias info
"destination": [d.strip() for d in alias[1].split(",")], domain = get_domain(source)
"required": alias[0] in required_aliases or alias[0] == get_system_administrator(env), required = ((source in required_aliases) or (source == get_system_administrator(env)))
# add to list
if not domain in domains:
domains[domain] = {
"domain": domain,
"aliases": [],
} }
for alias in aliases domains[domain]["aliases"].append({
] "source": source,
return aliases "source_display": prettify_idn_email_address(source),
"destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")],
"required": required,
})
def get_mail_alias_map(env): # Sort domains.
aliases = { } domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
for alias, targets in get_mail_aliases(env):
for em in targets.split(","):
em = em.strip().lower()
aliases.setdefault(em, []).append(alias)
return aliases
def evaluate_mail_alias_map(email, aliases, env): # Sort aliases within each domain first by required-ness then lexicographically by source address.
ret = set() for domain in domains:
for alias in aliases.get(email.lower(), []): domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
ret.add(alias) return domains
ret |= evaluate_mail_alias_map(alias, aliases, env)
def get_domain(emailaddr, as_unicode=True):
# Gets the domain part of an email address. Turns IDNA
# back to Unicode for display.
ret = emailaddr.split('@', 1)[1]
if as_unicode: ret = ret.encode('ascii').decode('idna')
return ret return ret
def get_mail_domains(env, filter_aliases=lambda alias : True): def get_mail_domains(env, filter_aliases=lambda alias : True):
def get_domain(emailaddr): # Returns the domain names (IDNA-encoded) of all of the email addresses
return emailaddr.split('@', 1)[1] # configured on the system.
return set( return set(
[get_domain(addr) for addr in get_mail_users(env)] [get_domain(addr, as_unicode=False) for addr in get_mail_users(env)]
+ [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ] + [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
) )
def add_mail_user(email, pw, privs, env): def add_mail_user(email, pw, privs, env):
# validate email # validate email
if email.strip() == "": if email.strip() == "":
return ("No email address provided.", 400) return ("No email address provided.", 400)
if not validate_email(email, mode='user'): elif not validate_email(email):
return ("Invalid email address.", 400) return ("Invalid email address.", 400)
elif not validate_email(email, mode='user'):
return ("User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
elif is_dcv_address(email) and len(get_mail_users(env)) > 0:
# Make domain control validation hijacking a little harder to mess up by preventing the usual
# addresses used for DCV from being user accounts. Except let it be the first account because
# during box setup the user won't know the rules.
return ("You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.", 400)
# validate password # validate password
if pw.strip() == "": validate_password(pw)
return ("No password provided.", 400)
if re.search(r"[\s]", pw):
return ("Passwords cannot contain spaces.", 400)
if len(pw) < 4:
return ("Passwords must be at least four characters.", 400)
# validate privileges # validate privileges
if privs is None or privs.strip() == "": if privs is None or privs.strip() == "":
@@ -160,7 +279,7 @@ def add_mail_user(email, pw, privs, env):
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
# hash the password # hash the password
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() pw = hash_password(pw)
# add the user to the database # add the user to the database
try: try:
@@ -172,8 +291,10 @@ def add_mail_user(email, pw, privs, env):
# write databasebefore next step # write databasebefore next step
conn.commit() conn.commit()
# Create the user's INBOX, Spam, and Drafts folders, and subscribe them. # Create & subscribe the user's INBOX, Trash, Spam, and Drafts folders.
# K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it # * Our sieve rule for spam expects that the Spam folder exists.
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
# to avoid unnecessary polling. # to avoid unnecessary polling.
# Check if the mailboxes exist before creating them. When creating a user that had previously # Check if the mailboxes exist before creating them. When creating a user that had previously
@@ -185,7 +306,7 @@ def add_mail_user(email, pw, privs, env):
conn.commit() conn.commit()
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400) return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
for folder in ("INBOX", "Spam", "Drafts"): for folder in ("INBOX", "Trash", "Spam", "Drafts"):
if folder not in existing_mboxes: if folder not in existing_mboxes:
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder]) utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
@@ -193,8 +314,11 @@ def add_mail_user(email, pw, privs, env):
return kick(env, "mail user added") return kick(env, "mail user added")
def set_mail_password(email, pw, env): def set_mail_password(email, pw, env):
# validate that password is acceptable
validate_password(pw)
# hash the password # hash the password
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() pw = hash_password(pw)
# update the database # update the database
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
@@ -204,7 +328,26 @@ def set_mail_password(email, pw, env):
conn.commit() conn.commit()
return "OK" return "OK"
def hash_password(pw):
# Turn the plain password into a Dovecot-format hashed password, meaning
# something like "{SCHEME}hashedpassworddata".
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
def get_mail_password(email, env):
# Gets the hashed password for a user. Passwords are stored in Dovecot's
# password format, with a prefixed scheme.
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
# update the database
c = open_database(env)
c.execute('SELECT password FROM users WHERE email=?', (email,))
rows = c.fetchall()
if len(rows) != 1:
raise ValueError("That's not a user (%s)." % email)
return rows[0][0]
def remove_mail_user(email, env): def remove_mail_user(email, env):
# remove
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM users WHERE email=?", (email,)) c.execute("DELETE FROM users WHERE email=?", (email,))
if c.rowcount != 1: if c.rowcount != 1:
@@ -217,11 +360,13 @@ def remove_mail_user(email, env):
def parse_privs(value): def parse_privs(value):
return [p for p in value.split("\n") if p.strip() != ""] return [p for p in value.split("\n") if p.strip() != ""]
def get_mail_user_privileges(email, env): def get_mail_user_privileges(email, env, empty_on_error=False):
# get privs
c = open_database(env) c = open_database(env)
c.execute('SELECT privileges FROM users WHERE email=?', (email,)) c.execute('SELECT privileges FROM users WHERE email=?', (email,))
rows = c.fetchall() rows = c.fetchall()
if len(rows) != 1: if len(rows) != 1:
if empty_on_error: return []
return ("That's not a user (%s)." % email, 400) return ("That's not a user (%s)." % email, 400)
return parse_privs(rows[0][0]) return parse_privs(rows[0][0])
@@ -258,25 +403,57 @@ def add_remove_mail_user_privilege(email, priv, action, env):
return "OK" return "OK"
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True): def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
# convert Unicode domain to IDNA
source = sanitize_idn_email_address(source)
# Our database is case sensitive (oops), which affects mail delivery
# (Postfix always queries in lowercase?), so force lowercase.
source = source.lower()
# validate source # validate source
if source.strip() == "": source = source.strip()
if source == "":
return ("No incoming email address provided.", 400) return ("No incoming email address provided.", 400)
if not validate_email(source, mode='alias'): if not validate_email(source, mode='alias'):
return ("Invalid incoming email address (%s)." % source, 400) return ("Invalid incoming email address (%s)." % source, 400)
# parse comma and \n-separated destination emails & validate # extra checks for email addresses used in domain control validation
is_dcv_source = is_dcv_address(source)
# validate destination
dests = [] dests = []
destination = destination.strip()
# Postfix allows a single @domain.tld as the destination, which means
# the local part on the address is preserved in the rewrite. We must
# try to convert Unicode to IDNA first before validating that it's a
# legitimate alias address. Don't allow this sort of rewriting for
# DCV source addresses.
d1 = sanitize_idn_email_address(destination)
if validate_email(d1, mode='alias') and not is_dcv_source:
dests.append(d1)
else:
# Parse comma and \n-separated destination emails & validate. In this
# case, the recipients must be complete email addresses.
for line in destination.split("\n"): for line in destination.split("\n"):
for email in line.split(","): for email in line.split(","):
email = email.strip() email = email.strip()
if email == "": continue if email == "": continue
if not validate_email(email, mode='alias'): email = sanitize_idn_email_address(email) # Unicode => IDNA
if not validate_email(email):
return ("Invalid destination email address (%s)." % email, 400) return ("Invalid destination email address (%s)." % email, 400)
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(email, env, empty_on_error=True):
# Make domain control validation hijacking a little harder to mess up by
# requiring aliases for email addresses typically used in DCV to forward
# only to accounts that are administrators on this system.
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
dests.append(email) dests.append(email)
if len(destination) == 0: if len(destination) == 0:
return ("No destination email address(es) provided.", 400) return ("No destination email address(es) provided.", 400)
destination = ",".join(dests) destination = ",".join(dests)
# save to db
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
try: try:
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination)) c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
@@ -295,6 +472,10 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
return kick(env, return_status) return kick(env, return_status)
def remove_mail_alias(source, env, do_kick=True): def remove_mail_alias(source, env, do_kick=True):
# convert Unicode domain to IDNA
source = sanitize_idn_email_address(source)
# remove
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM aliases WHERE source=?", (source,)) c.execute("DELETE FROM aliases WHERE source=?", (source,))
if c.rowcount != 1: if c.rowcount != 1:
@@ -312,16 +493,16 @@ def get_required_aliases(env):
# These are the aliases that must exist. # These are the aliases that must exist.
aliases = set() aliases = set()
# The hostmaster aliase is exposed in the DNS SOA for each zone. # The hostmaster alias is exposed in the DNS SOA for each zone.
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME']) aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
# Get a list of domains we serve mail for, except ones for which the only # Get a list of domains we serve mail for, except ones for which the only
# email on that domain is a postmaster/admin alias to the administrator. # email on that domain is a postmaster/admin alias to the administrator
# or a wildcard alias (since it will forward postmaster/admin).
real_mail_domains = get_mail_domains(env, real_mail_domains = get_mail_domains(env,
filter_aliases = lambda alias : \ filter_aliases = lambda alias :
(not alias[0].startswith("postmaster@") \ ((not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@")) or alias[1] != get_system_administrator(env))
and not alias[0].startswith("admin@")) \ and not alias[0].startswith("@")
or alias[1] != get_system_administrator(env) \
) )
# Create postmaster@ and admin@ for all domains we serve mail on. # Create postmaster@ and admin@ for all domains we serve mail on.
@@ -337,17 +518,22 @@ def get_required_aliases(env):
def kick(env, mail_result=None): def kick(env, mail_result=None):
results = [] results = []
# Inclde the current operation's result in output. # Include the current operation's result in output.
if mail_result is not None: if mail_result is not None:
results.append(mail_result + "\n") results.append(mail_result + "\n")
# Ensure every required alias exists. # Ensure every required alias exists.
existing_users = get_mail_users(env)
existing_aliases = get_mail_aliases(env) existing_aliases = get_mail_aliases(env)
required_aliases = get_required_aliases(env) required_aliases = get_required_aliases(env)
def ensure_admin_alias_exists(source): def ensure_admin_alias_exists(source):
# If a user account exists with that address, we're good.
if source in existing_users:
return
# Does this alias exists? # Does this alias exists?
for s, t in existing_aliases: for s, t in existing_aliases:
if s == source: if s == source:
@@ -381,6 +567,16 @@ def kick(env, mail_result=None):
return "".join(s for s in results if s != "") return "".join(s for s in results if s != "")
def validate_password(pw):
# validate password
if pw.strip() == "":
raise ValueError("No password provided.")
if re.search(r"[\s]", pw):
raise ValueError("Passwords cannot contain spaces.")
if len(pw) < 4:
raise ValueError("Passwords must be at least four characters.")
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
if len(sys.argv) > 2 and sys.argv[1] == "validate-email": if len(sys.argv) > 2 and sys.argv[1] == "validate-email":

937
management/status_checks.py Executable file
View File

@@ -0,0 +1,937 @@
#!/usr/bin/python3
#
# Checks that the upstream DNS has been set correctly and that
# SSL certificates have been signed, etc., and if not tells the user
# what to do next.
__ALL__ = ['check_certificate']
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns
from web_update import get_web_domains, get_domain_ssl_files
from mailconfig import get_mail_domains, get_mail_aliases
from utils import shell, sort_domains, load_env_vars_from_file
def run_checks(rounded_values, env, output, pool):
# run systems checks
output.add_heading("System")
# check that services are running
if not run_services_checks(env, output, pool):
# If critical services are not running, stop. If bind9 isn't running,
# all later DNS checks will timeout and that will take forever to
# go through, and if running over the web will cause a fastcgi timeout.
return
# clear bind9's DNS cache so our DNS checks are up to date
# (ignore errors; if bind9/rndc isn't running we'd already report
# that in run_services checks.)
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
run_system_checks(rounded_values, env, output)
# perform other checks asynchronously
run_network_checks(env, output)
run_domain_checks(rounded_values, env, output, pool)
def get_ssh_port():
# Returns ssh port
output = shell('check_output', ['sshd', '-T'])
returnNext = False
for e in output.split():
if returnNext:
return int(e)
if e == "port":
returnNext = True
def run_services_checks(env, output, pool):
# Check that system services are running.
services = [
{ "name": "Local DNS (bind9)", "port": 53, "public": False, },
#{ "name": "NSD Control", "port": 8952, "public": False, },
{ "name": "Local DNS Control (bind9/rndc)", "port": 953, "public": False, },
{ "name": "Dovecot LMTP LDA", "port": 10026, "public": False, },
{ "name": "Postgrey", "port": 10023, "public": False, },
{ "name": "Spamassassin", "port": 10025, "public": False, },
{ "name": "OpenDKIM", "port": 8891, "public": False, },
{ "name": "OpenDMARC", "port": 8893, "public": False, },
{ "name": "Memcached", "port": 11211, "public": False, },
{ "name": "Sieve (dovecot)", "port": 4190, "public": False, },
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
{ "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, },
{ "name": "Public DNS (nsd4)", "port": 53, "public": True, },
{ "name": "Incoming Mail (SMTP/postfix)", "port": 25, "public": True, },
{ "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, },
#{ "name": "Postfix/master", "port": 10587, "public": True, },
{ "name": "IMAPS (dovecot)", "port": 993, "public": True, },
{ "name": "HTTP Web (nginx)", "port": 80, "public": True, },
{ "name": "HTTPS Web (nginx)", "port": 443, "public": True, },
]
all_running = True
fatal = False
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1)
for i, running, fatal2, output2 in sorted(ret):
all_running = all_running and running
fatal = fatal or fatal2
output2.playback(output)
if all_running:
output.print_ok("All system services are running.")
return not fatal
def check_service(i, service, env):
import socket
output = BufferedOutput()
running = False
fatal = False
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
try:
try:
s.connect((
"127.0.0.1" if not service["public"] else env['PUBLIC_IP'],
service["port"]))
running = True
except OSError as e1:
if service["public"] and service["port"] != 53:
# For public services (except DNS), try the private IP as a fallback.
s1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s1.settimeout(1)
try:
s1.connect(("127.0.0.1", service["port"]))
output.print_error("%s is running but is not publicly accessible at %s:%d (%s)." % (service['name'], env['PUBLIC_IP'], service['port'], str(e1)))
except:
raise e1
finally:
s1.close()
else:
raise
except OSError as e:
output.print_error("%s is not running (%s; port %d)." % (service['name'], str(e), service['port']))
# Why is nginx not running?
if service["port"] in (80, 443):
output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())
# Flag if local DNS is not running.
if service["port"] == 53 and service["public"] == False:
fatal = True
finally:
s.close()
return (i, running, fatal, output)
def run_system_checks(rounded_values, env, output):
check_ssh_password(env, output)
check_software_updates(env, output)
check_system_aliases(env, output)
check_free_disk_space(rounded_values, env, output)
def check_ssh_password(env, output):
# Check that SSH login with password is disabled. The openssh-server
# package may not be installed so check that before trying to access
# the configuration file.
if not os.path.exists("/etc/ssh/sshd_config"):
return
sshd = open("/etc/ssh/sshd_config").read()
if re.search("\nPasswordAuthentication\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\s+no", sshd):
output.print_error("""The SSH server on this machine permits password-based login. A more secure
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
that you can log in without a password, set the option 'PasswordAuthentication no' in
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
else:
output.print_ok("SSH disallows password-based login.")
def check_software_updates(env, output):
# Check for any software package updates.
pkgs = list_apt_updates(apt_update=False)
if os.path.exists("/var/run/reboot-required"):
output.print_error("System updates have been installed and a reboot of the machine is required.")
elif len(pkgs) == 0:
output.print_ok("System software is up to date.")
else:
output.print_error("There are %d software packages that can be updated." % len(pkgs))
for p in pkgs:
output.print_line("%s (%s)" % (p["package"], p["version"]))
def check_system_aliases(env, output):
# Check that the administrator alias exists since that's where all
# admin email is automatically directed.
check_alias_exists("System administrator address", "administrator@" + env['PRIMARY_HOSTNAME'], env, output)
def check_free_disk_space(rounded_values, env, output):
# Check free disk space.
st = os.statvfs(env['STORAGE_ROOT'])
bytes_total = st.f_blocks * st.f_frsize
bytes_free = st.f_bavail * st.f_frsize
if not rounded_values:
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10)
else:
disk_msg = "The disk has less than %s%% space left." % str(round(bytes_free/bytes_total/10 + .5)*10)
if bytes_free > .3 * bytes_total:
output.print_ok(disk_msg)
elif bytes_free > .15 * bytes_total:
output.print_warning(disk_msg)
else:
output.print_error(disk_msg)
def run_network_checks(env, output):
# Also see setup/network-checks.sh.
output.add_heading("Network")
# Stop if we cannot make an outbound connection on port 25. Many residential
# networks block outbound port 25 to prevent their network from sending spam.
# See if we can reach one of Google's MTAs with a 5-second timeout.
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
if ret == 0:
output.print_ok("Outbound mail (SMTP port 25) is not blocked.")
else:
output.print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
will not be able to send any mail. Many residential networks block port 25 to prevent hijacked
machines from being able to send spam. A quick connection test to Google's mail server on port 25
failed.""")
# Stop if the IPv4 address is listed in the ZEN Spamhaus Block List.
# The user might have ended up on an IP address that was previously in use
# by a spammer, or the user may be deploying on a residential network. We
# will not be able to reliably send mail in these cases.
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
if zen is None:
output.print_ok("IP address is not blacklisted by zen.spamhaus.org.")
else:
output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
def run_domain_checks(rounded_time, env, output, pool):
# Get the list of domains we handle mail for.
mail_domains = get_mail_domains(env)
# Get the list of domains we serve DNS zones for (i.e. does not include subdomains).
dns_zonefiles = dict(get_dns_zones(env))
dns_domains = set(dns_zonefiles)
# Get the list of domains we serve HTTPS for.
web_domains = set(get_web_domains(env))
domains_to_check = mail_domains | dns_domains | web_domains
# Serial version:
#for domain in sort_domains(domains_to_check, env):
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
# Parallelize the checks across a worker pool.
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
for domain in domains_to_check)
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
ret = dict(ret) # (domain, output) => { domain: output }
for domain in sort_domains(ret, env):
ret[domain].playback(output)
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
output = BufferedOutput()
# The domain is IDNA-encoded, but for display use Unicode.
output.add_heading(domain.encode('ascii').decode('idna'))
if domain == env["PRIMARY_HOSTNAME"]:
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
if domain in dns_domains:
check_dns_zone(domain, env, output, dns_zonefiles)
if domain in mail_domains:
check_mail_domain(domain, env, output)
if domain in web_domains:
check_web_domain(domain, rounded_time, env, output)
if domain in dns_domains:
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
return (domain, output)
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
# If a DS record is set on the zone containing this domain, check DNSSEC now.
has_dnssec = False
for zone in dns_domains:
if zone == domain or domain.endswith("." + zone):
if query_dns(zone, "DS", nxdomain=None) is not None:
has_dnssec = True
check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True)
ip = query_dns(domain, "A")
ns_ips = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
# 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 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.
if ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s%s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
elif ip == env['PUBLIC_IP']:
# The NS records are not what we expect, but the domain resolves correctly, so
# the user may have set up external DNS. List this discrepancy as a warning.
output.print_warning("""Nameserver glue records (ns1.%s and ns2.%s) should be configured at your domain name
registrar as having the IP address of this box (%s). They currently report addresses of %s. If you have set up External DNS, this may be OK."""
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
else:
output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
public DNS to update after a change."""
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
if ip == env['PUBLIC_IP']:
output.print_ok("Domain resolves to box's IP address. [%s%s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
else:
output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
issues listed here."""
% (env['PUBLIC_IP'], ip))
# Check reverse DNS on the PRIMARY_HOSTNAME. Note that it might not be
# a DNS zone if it is a subdomain of another domain we have a zone for.
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
existing_rdns = query_dns(ipaddr_rev, "PTR")
if existing_rdns == domain:
output.print_ok("Reverse DNS is set correctly at ISP. [%s%s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
else:
output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
# Check the TLSA record.
tlsa_qname = "_25._tcp." + domain
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
tlsa25_expected = build_tlsa_record(env)
if tlsa25 == tlsa25_expected:
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
elif tlsa25 is None:
if has_dnssec:
# Omit a warning about it not being set if DNSSEC isn't enabled,
# since TLSA shouldn't be used without DNSSEC.
output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""")
else:
output.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.
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
def check_alias_exists(alias_name, alias, env, output):
mail_alises = dict(get_mail_aliases(env))
if alias in mail_alises:
output.print_ok("%s exists as a mail alias. [%s%s]" % (alias_name, alias, mail_alises[alias]))
else:
output.print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
def check_dns_zone(domain, env, output, dns_zonefiles):
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
# If it is not set, we suggest it last.
if query_dns(domain, "DS", nxdomain=None) is not None:
check_dnssec(domain, env, output, dns_zonefiles)
# 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. The secondary DNS
# server may be customized. Unfortunately this may not check the domain's
# whois information -- we may be getting the NS records from us rather than
# the TLD, and so we're not actually checking the TLD. For that we'd need
# to do a DNS trace.
ip = query_dns(domain, "A")
secondary_ns = get_secondary_dns(get_custom_dns_config(env)) or "ns2." + env['PRIMARY_HOSTNAME']
existing_ns = query_dns(domain, "NS")
correct_ns = "; ".join(sorted([
"ns1." + env['PRIMARY_HOSTNAME'],
secondary_ns,
]))
if existing_ns.lower() == correct_ns.lower():
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
elif ip == env['PUBLIC_IP']:
# The domain resolves correctly, so maybe the user is using External DNS.
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
If you are using External DNS, this may be OK."""
% (correct_ns, existing_ns) )
else:
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
control panel to set the nameservers to %s."""
% (existing_ns, correct_ns) )
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles):
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
# (If it was set, we did the check earlier.)
if query_dns(domain, "DS", nxdomain=None) is None:
check_dnssec(domain, env, output, dns_zonefiles)
def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
# See if the domain has a DS record set at the registrar. The DS record may have
# 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.
ds_correct = open('/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds').read().strip().split("\n")
digests = { }
for rr_ds in ds_correct:
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
digests[ds_digalg] = ds_digest
# 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).
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] == ds_alg and ds[3] == digests.get(ds[2]):
if is_checking_primary: return
output.print_ok("DNSSEC 'DS' record is set correctly at registrar.")
else:
if ds == None:
if is_checking_primary: return
output.print_warning("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC.
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
else:
if is_checking_primary:
output.print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain)
return
output.print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
provide to them this information:""")
output.print_line("")
output.print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
output.print_line("Key Flags: KSK")
output.print_line(
("Algorithm: %s / %s" % (ds_alg, alg_name_map[ds_alg]))
+ ("" if not ds_looks_valid or ds[1] == ds_alg else " (Got '%s')" % ds[1]))
# see http://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
output.print_line("Digest Type: 2 / SHA-256")
# http://www.ietf.org/assignments/ds-rr-types/ds-rr-types.xml
output.print_line("Digest: " + digests['2'])
if ds_looks_valid and ds[3] != digests.get(ds[2]):
output.print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
output.print_line("Public Key: ")
output.print_line(dnsssec_pubkey, monospace=True)
output.print_line("")
output.print_line("Bulk/Record Format:")
output.print_line("" + ds_correct[0])
output.print_line("")
def check_mail_domain(domain, env, output):
# Check the MX record.
recommended_mx = "10 " + env['PRIMARY_HOSTNAME']
mx = query_dns(domain, "MX", nxdomain=None)
if mx is None:
mxhost = None
else:
# query_dns returns a semicolon-delimited list
# of priority-host pairs.
mxhost = mx.split('; ')[0].split(' ')[1]
if mxhost == None:
# A missing MX record is okay on the primary hostname because
# the primary hostname's A record (the MX fallback) is... itself,
# which is what we want the MX to be.
if domain == env['PRIMARY_HOSTNAME']:
output.print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
# And a missing MX record is okay on other domains if the A record
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
# probably confuse DANE TLSA, but we'll let that slide for now.
else:
domain_a = query_dns(domain, "A", nxdomain=None)
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
if domain_a != None and domain_a == primary_a:
output.print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
else:
output.print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a
change. This problem may result from other issues listed here.""" % (recommended_mx,))
elif mxhost == env['PRIMARY_HOSTNAME']:
good_news = "Domain's email is directed to this domain. [%s%s]" % (domain, mx)
if mx != recommended_mx:
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,)
output.print_ok(good_news)
else:
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
other issues listed here.""" % (mx, recommended_mx))
# Check that the postmaster@ email address exists. Not required if the domain has a
# catch-all address or domain alias.
if "@" + domain not in dict(get_mail_aliases(env)):
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
# Stop if the domain is listed in the Spamhaus Domain Block List.
# The user might have chosen a domain that was previously in use by a spammer
# and will not be able to reliably send mail.
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
if dbl is None:
output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
else:
output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
which may prevent recipients from receiving your mail.
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
def check_web_domain(domain, rounded_time, env, output):
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
# other domains, it is required to access its website.
if domain != env['PRIMARY_HOSTNAME']:
ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']:
output.print_ok("Domain resolves to this box's IP address. [%s%s]" % (domain, env['PUBLIC_IP']))
else:
output.print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
# user will log in with IMAP or webmail. Any other domain we serve a
# website for also needs a signed certificate.
check_ssl_cert(domain, rounded_time, env, output)
def query_dns(qname, rtype, nxdomain='[Not Set]'):
# 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):
# Host did not have an answer for this query; not sure what the
# difference is between the two exceptions.
return nxdomain
except dns.exception.Timeout:
return "[timeout]"
# There may be multiple answers; concatenate the response. Remove trailing
# periods from responses since that's how qnames are encoded in DNS but is
# confusing for us. The order of the answers doesn't matter, so sort so we
# can compare to a well known order.
return "; ".join(sorted(str(r).rstrip('.') for r in response))
def check_ssl_cert(domain, rounded_time, env, output):
# Check that SSL certificate is signed.
# Skip the check if the A record is not pointed here.
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
# Where is the SSL stored?
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
output.print_error("The SSL certificate file for this domain is missing.")
return
# Check that the certificate is good.
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
if cert_status == "OK":
# The certificate is ok. The details has expiry info.
output.print_ok("SSL certificate is signed & valid. %s %s" % (ssl_via if ssl_via else "", cert_status_details))
elif cert_status == "SELF-SIGNED":
# Offer instructions for purchasing a signed certificate.
fingerprint = shell('check_output', [
"openssl",
"x509",
"-in", ssl_certificate,
"-noout",
"-fingerprint"
])
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
if domain == env['PRIMARY_HOSTNAME']:
output.print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
warning when you check or send email and when visiting this domain in a web browser (for webmail or
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:""")
output.print_line("")
output.print_line(" " + fingerprint, monospace=True)
else:
output.print_warning("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
this domain will get a security warning. If you are not serving a website on this domain, then it is
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:
output.print_error("The SSL certificate has a problem: " + cert_status)
if cert_status_details:
output.print_line("")
output.print_line(cert_status_details)
output.print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=True, rounded_time=False):
# Use openssl verify to check the status of a certificate.
# First check that the certificate is for the right domain. The domain
# must be found in the Subject Common Name (CN) or be one of the
# Subject Alternative Names. A wildcard might also appear as the CN
# or in the SAN list, so check for that tool.
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
while len(cert_dump) > 0:
line = cert_dump.pop(0)
# Grab from the Subject Common Name. We include the indentation
# at the start of the line in case maybe the cert includes the
# common name of some other referenced entity (which would be
# indented, I hope).
m = re.match(" Subject: CN=([^,]+)", line)
if m:
certificate_names.add(m.group(1))
# Grab from the Subject Alternative Name, which is a comma-delim
# list of names, like DNS:mydomain.com, DNS:otherdomain.com.
m = re.match(" X509v3 Subject Alternative Name:", line)
if m:
names = re.split(",\s*", cert_dump.pop(0).strip())
for n in names:
m = re.match("DNS:(.*)", n)
if m:
certificate_names.add(m.group(1))
# Grab the expiration date for testing later.
m = re.match(" Not After : (.*)", line)
if m:
cert_expiration_date = dateutil.parser.parse(m.group(1))
wildcard_domain = re.sub("^[^\.]+", "*", domain)
if domain is not None and domain not in certificate_names and wildcard_domain not in 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
# of each command looks like "Modulus=XXXXX".
if ssl_private_key is not None:
private_key_modulus = shell('check_output', [
"openssl", "rsa",
"-inform", "PEM",
"-noout", "-modulus",
"-in", ssl_private_key])
cert_key_modulus = shell('check_output', [
"openssl", "x509",
"-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), 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
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
# expired.
# In order to verify with openssl, we need to split out any
# intermediary certificates in the chain (if any) from our
# certificate (at the top). They need to be passed separately.
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.", None)
mycert, chaincerts = m.groups()
# This command returns a non-zero exit status in most cases, so trap errors.
retcode, verifyoutput = shell('check_output', [
"openssl",
"verify", "-verbose",
"-purpose", "sslserver", "-policy_check",]
+ ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"])
+ [ssl_certificate],
input=chaincerts.encode('ascii'),
trap=True)
if "self signed" in verifyoutput:
# Certificate is 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 ("There is a problem with the SSL certificate.", verifyoutput.strip())
else:
# `openssl verify` returned a zero exit status so the cert is currently
# good.
# But is it expiring soon?
now = datetime.datetime.now(dateutil.tz.tzlocal())
ndays = (cert_expiration_date-now).days
if not rounded_time or ndays < 7:
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
elif ndays <= 14:
expiry_info = "The certificate expires in less than two weeks, on %s." % cert_expiration_date.strftime("%x")
elif ndays <= 31:
expiry_info = "The certificate expires in less than a month, on %s." % cert_expiration_date.strftime("%x")
else:
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
if ndays <= 31 and warn_if_expiring_soon:
return ("The certificate is expiring soon: " + expiry_info, None)
# Return the special OK code.
return ("OK", expiry_info)
_apt_updates = None
def list_apt_updates(apt_update=True):
# See if we have this information cached recently.
# Keep the information for 8 hours.
global _apt_updates
if _apt_updates is not None and _apt_updates[0] > datetime.datetime.now() - datetime.timedelta(hours=8):
return _apt_updates[1]
# Run apt-get update to refresh package list. This should be running daily
# anyway, so on the status checks page don't do this because it is slow.
if apt_update:
shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
# Run apt-get upgrade in simulate mode to get a list of what
# it would do.
simulated_install = shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"])
pkgs = []
for line in simulated_install.split('\n'):
if line.strip() == "":
continue
if re.match(r'^Conf .*', line):
# remove these lines, not informative
continue
m = re.match(r'^Inst (.*) \[(.*)\] \((\S*)', line)
if m:
pkgs.append({ "package": m.group(1), "version": m.group(3), "current_version": m.group(2) })
else:
pkgs.append({ "package": "[" + line + "]", "version": "", "current_version": "" })
# Cache for future requests.
_apt_updates = (datetime.datetime.now(), pkgs)
return pkgs
def run_and_output_changes(env, pool, send_via_email):
import json
from difflib import SequenceMatcher
if not send_via_email:
out = ConsoleOutput()
else:
import io
out = FileOutput(io.StringIO(""), 70)
# Run status checks.
cur = BufferedOutput()
run_checks(True, env, cur, pool)
# Load previously saved status checks.
cache_fn = "/var/cache/mailinabox/status_checks.json"
if os.path.exists(cache_fn):
prev = json.load(open(cache_fn))
# Group the serial output into categories by the headings.
def group_by_heading(lines):
from collections import OrderedDict
ret = OrderedDict()
k = []
ret["No Category"] = k
for line_type, line_args, line_kwargs in lines:
if line_type == "add_heading":
k = []
ret[line_args[0]] = k
else:
k.append((line_type, line_args, line_kwargs))
return ret
prev_status = group_by_heading(prev)
cur_status = group_by_heading(cur.buf)
# Compare the previous to the current status checks
# category by category.
for category, cur_lines in cur_status.items():
if category not in prev_status:
out.add_heading(category + " -- Added")
BufferedOutput(with_lines=cur_lines).playback(out)
else:
# Actual comparison starts here...
prev_lines = prev_status[category]
def stringify(lines):
return [json.dumps(line) for line in lines]
diff = SequenceMatcher(None, stringify(prev_lines), stringify(cur_lines)).get_opcodes()
for op, i1, i2, j1, j2 in diff:
if op == "replace":
out.add_heading(category + " -- Previously:")
elif op == "delete":
out.add_heading(category + " -- Removed")
if op in ("replace", "delete"):
BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)
if op == "replace":
out.add_heading(category + " -- Currently:")
elif op == "insert":
out.add_heading(category + " -- Added")
if op in ("replace", "insert"):
BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)
for category, prev_lines in prev_status.items():
if category not in cur_status:
out.add_heading(category)
out.print_warning("This section was removed.")
if send_via_email:
# If there were changes, send off an email.
buf = out.buf.getvalue()
if len(buf) > 0:
# create MIME message
from email.message import Message
msg = Message()
msg['From'] = "\"%s\" <administrator@%s>" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'])
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
msg.set_payload(buf, "UTF-8")
# send to administrator@
import smtplib
mailserver = smtplib.SMTP('localhost', 25)
mailserver.ehlo()
mailserver.sendmail(
"administrator@%s" % env['PRIMARY_HOSTNAME'], # MAIL FROM
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
msg.as_string())
mailserver.quit()
# Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f:
json.dump(cur.buf, f, indent=True)
class FileOutput:
def __init__(self, buf, width):
self.buf = buf
self.width = width
def add_heading(self, heading):
print(file=self.buf)
print(heading, file=self.buf)
print("=" * len(heading), file=self.buf)
def print_ok(self, message):
self.print_block(message, first_line="")
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='', file=self.buf)
message = re.sub("\n\s*", " ", message)
words = re.split("(\s+)", message)
linelen = 0
for w in words:
if linelen + len(w) > self.width-1-len(first_line):
print(file=self.buf)
print(" ", end="", file=self.buf)
linelen = 0
if linelen == 0 and w.strip() == "": continue
print(w, end="", file=self.buf)
linelen += len(w)
print(file=self.buf)
def print_line(self, message, monospace=False):
for line in message.split("\n"):
self.print_block(line)
class ConsoleOutput(FileOutput):
def __init__(self):
self.buf = sys.stdout
try:
self.width = int(shell('check_output', ['stty', 'size']).split()[1])
except:
self.width = 76
class BufferedOutput:
# Record all of the instance method calls so we can play them back later.
def __init__(self, with_lines=None):
self.buf = [] if not with_lines else with_lines
def __getattr__(self, attr):
if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"):
raise AttributeError
# Return a function that just records the call & arguments to our buffer.
def w(*args, **kwargs):
self.buf.append((attr, args, kwargs))
return w
def playback(self, output):
for attr, args, kwargs in self.buf:
getattr(output, attr)(*args, **kwargs)
if __name__ == "__main__":
from utils import load_environment
env = load_environment()
pool = multiprocessing.pool.Pool(processes=10)
if len(sys.argv) == 1:
run_checks(False, env, ConsoleOutput(), pool)
elif sys.argv[1] == "--show-changes":
run_and_output_changes(env, pool, sys.argv[-1] == "--smtp")
elif sys.argv[1] == "--check-primary-hostname":
# See if the primary hostname appears resolvable and has a signed certificate.
domain = env['PRIMARY_HOSTNAME']
if query_dns(domain, "A") != env['PUBLIC_IP']:
sys.exit(1)
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
sys.exit(1)
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, warn_if_expiring_soon=False)
if cert_status != "OK":
sys.exit(1)
sys.exit(0)

View File

@@ -11,20 +11,34 @@
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;"> <form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
<div class="form-group"> <div class="form-group">
<label for="addaliasEmail" class="col-sm-2 control-label">Email Address</label> <div class="col-sm-offset-1 col-sm-11">
<div class="col-sm-10"> <div id="alias_type_buttons" class="btn-group btn-group-xs">
<input type="email" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address"> <button type="button" class="btn btn-default active" data-mode="regular">Regular</button>
<button type="button" class="btn btn-default" data-mode="catchall">Catch-All</button>
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
</div>
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain. Enter just a part of an email address starting with the @-sign.</span>
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched mail from one domain to another domain, preserving the part before the @-sign.</span>
</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="addaliasTargets" class="col-sm-2 control-label">Forward To</label> <label for="addaliasEmail" class="col-sm-1 control-label">Alias</label>
<div class="col-sm-10"> <div class="col-sm-10">
<textarea class="form-control" rows="3" id="addaliasTargets" placeholder="Forward to these email addresses (one per line or separated by commas)"></textarea> <input type="email" class="form-control" id="addaliasEmail">
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">You may use international (non-ASCII) characters for the domain part of the email address only.</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-2 col-sm-10"> <label for="addaliasTargets" class="col-sm-1 control-label">Forward To</label>
<button id="add-alias-button" type="submit" class="btn btn-primary">Add</button> <div class="col-sm-10">
<textarea class="form-control" rows="3" id="addaliasTargets"></textarea>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button> <button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
</div> </div>
</div> </div>
@@ -35,7 +49,7 @@
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Email Address<br></th> <th>Alias<br></th>
<th>Forwards To</th> <th>Forwards To</th>
</tr> </tr>
</thead> </thead>
@@ -43,13 +57,13 @@
</tbody> </tbody>
</table> </table>
<p style="margin-top: 1.5em"><small>Hostmaster@, postmaster@, and admin@ email addresses are required on some domains.</small></p> <p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, and admin@ email addresses are required on some domains.</small></p>
<div style="display: none"> <div style="display: none">
<table> <table>
<tr id="alias-template"> <tr id="alias-template">
<td class='actions'> <td class='actions'>
<a href="#" onclick="aliases_edit(this); return false;" class='edit' title="Edit Alias"> <a href="#" onclick="aliases_edit(this); scroll_top(); return false;" class='edit' title="Edit Alias">
<span class="glyphicon glyphicon-pencil"></span> <span class="glyphicon glyphicon-pencil"></span>
</a> </a>
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias"> <a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
@@ -73,16 +87,52 @@ function show_aliases() {
function(r) { function(r) {
$('#alias_table tbody').html(""); $('#alias_table tbody').html("");
for (var i = 0; i < r.length; i++) { for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
hdr.find('h4').text(r[i].domain);
$('#alias_table tbody').append(hdr);
for (var k = 0; k < r[i].aliases.length; k++) {
var alias = r[i].aliases[k];
var n = $("#alias-template").clone(); var n = $("#alias-template").clone();
n.attr('id', ''); n.attr('id', '');
if (r[i].required) n.addClass('alias-required'); if (alias.required) n.addClass('alias-required');
n.attr('data-email', r[i].source); n.attr('data-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
n.find('td.email').text(r[i].source) n.find('td.email').text(alias.source_display)
for (var j = 0; j < r[i].destination.length; j++) for (var j = 0; j < alias.destination.length; j++)
n.find('td.target').append($("<div></div>").text(r[i].destination[j])) n.find('td.target').append($("<div></div>").text(alias.destination[j]))
$('#alias_table tbody').append(n); $('#alias_table tbody').append(n);
} }
}
})
$(function() {
$('#alias_type_buttons button').off('click').click(function() {
$('#alias_type_buttons button').removeClass('active');
$(this).addClass('active');
if ($(this).attr('data-mode') == "regular") {
$('#addaliasEmail').attr('type', 'email');
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
$('#alias_mode_info').slideUp();
} else if ($(this).attr('data-mode') == "catchall") {
$('#addaliasEmail').attr('type', 'text');
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
$('#alias_mode_info').slideDown();
$('#alias_mode_info span').addClass('hidden');
$('#alias_mode_info span.catchall').removeClass('hidden');
} else if ($(this).attr('data-mode') == "domainalias") {
$('#addaliasEmail').attr('type', 'text');
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
$('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)');
$('#alias_mode_info').slideDown();
$('#alias_mode_info span').addClass('hidden');
$('#alias_mode_info span.domainalias').removeClass('hidden');
}
})
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
}) })
} }
@@ -116,7 +166,7 @@ function aliases_reset_form() {
$("#addaliasEmail").val('') $("#addaliasEmail").val('')
$("#addaliasTargets").val('') $("#addaliasTargets").val('')
$('#alias-cancel').addClass('hidden'); $('#alias-cancel').addClass('hidden');
$('#add-alias-button').text('Add'); $('#add-alias-button').text('Add Alias');
is_alias_add_update = false; is_alias_add_update = false;
} }
@@ -133,6 +183,12 @@ function aliases_edit(elem) {
$("#addaliasEmail").val(email); $("#addaliasEmail").val(email);
$("#addaliasTargets").val(targets); $("#addaliasTargets").val(targets);
$('#add-alias-button').text('Update'); $('#add-alias-button').text('Update');
if (email.charAt(0) == '@' && targets.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="domainalias"]').click();
else if (email.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="catchall"]').click();
else
$('#alias_type_buttons button[data-mode="regular"]').click();
$('body').animate({ scrollTop: 0 }) $('body').animate({ scrollTop: 0 })
} }
@@ -156,4 +212,10 @@ function aliases_remove(elem) {
}); });
}); });
} }
function scroll_top() {
$('html, body').animate({
scrollTop: $("#panel_aliases").offset().top
}, 1000);
}
</script> </script>

View File

@@ -0,0 +1,253 @@
<style>
#custom-dns-current td.long {
word-break: break-all;
}
</style>
<h2>Custom DNS</h2>
<p class="text-warning">This is an advanced configuration page.</p>
<p>It is possible to set custom DNS records on domains hosted here.</p>
<h3>Set Custom DNS Records</h3>
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>
<form class="form-horizontal" role="form" onsubmit="do_set_custom_dns(); return false;">
<div class="form-group">
<label for="customdnsQname" class="col-sm-1 control-label">Name</label>
<div class="col-sm-10">
<table style="max-width: 400px">
<tr><td>
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
</td><td style="padding: 0 1em; font-weight: bold;">.</td><td>
<select id="customdnsZone" class="form-control"> </select>
</td></tr></table>
<div class="text-info" style="margin-top: .5em">Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</div>
</div>
</div>
<div class="form-group">
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
<div class="col-sm-10">
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012).">A (IPv4 address)</option>
<option value="AAAA" data-hint="Enter an IPv6 address.">AAAA (IPv6 address)</option>
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
<option value="MX" data-hint="Enter record in the form of PRIORIY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
</select>
</div>
</div>
<div class="form-group">
<label for="customdnsValue" class="col-sm-1 control-label">Value</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="customdnsValue" placeholder="">
<div id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<button type="submit" class="btn btn-primary">Set Record</button>
</div>
</div>
</form>
<table id="custom-dns-current" class="table" style="width: auto; display: none">
<thead>
<th>Domain Name</th>
<th>Record Type</th>
<th>Value</th>
<th></th>
</thead>
<tbody>
<tr><td colspan="4">Loading...</td></tr>
</tbody>
</table>
<h3>Using a Secondary Nameserver</h3>
<p>If your TLD requires you to have two separate nameservers, you can either set up a secondary (aka &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>Usage:</p>
<pre>curl -X <b>VERB</b> [-d "<b>value</b>"] --user {email}:{password} https://{{hostname}}/admin/dns/custom[/<b>qname</b>[/<b>rtype</b>]]</pre>
<p>(Brackets denote an optional argument.)</p>
<h4>Verbs</h4>
<table class="table">
<thead><th>Verb</th> <th>Usage</th></thead>
<tr><td>GET</td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d "..."</code>) must be omitted.</td></tr>
<tr><td>PUT</td> <td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and <code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and <code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td></tr>
<tr><td>POST</td> <td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin <code>A</code> records. (PUT would delete previously added records.)</td></tr>
<tr><td>DELETE</td> <td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only the record matching the <code>qname</code>, <code>rtype</code> and value.</td></tr>
</table>
<h4>Parameters</h4>
<table class="table">
<thead><th>Parameter</th> <th>Value</th></thead>
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
<tr><td>password</td> <td>That user&rsquo;s password.</td></tr>
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name &mdash; don&rsquo;t forget the final period), <code>MX</code>, or <code>SRV</code>.</td></tr>
<tr><td>value</td> <td>For PUT, POST, and DELETE, the record&rsquo;s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
</table>
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be added to all custom domains unless you override them.</p>
<h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
curl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
# deletes that record and all A records for that domain name
curl -X DELETE https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
# sets a CNAME alias
curl -X PUT -d "bar.mydomain.com." https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname
# deletes that CNAME and all CNAME records for that domain name
curl -X DELETE https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname
# adds a TXT record using POST to preserve any previous TXT records
curl -X POST -d "some text here" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt
# deletes that one TXT record while preserving other TXT records
curl -X DELETE -d "some text here" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt
</pre>
<script>
function show_custom_dns() {
api(
"/dns/secondary-nameserver",
"GET",
{ },
function(data) {
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
});
api(
"/dns/zones",
"GET",
{ },
function(data) {
$('#customdnsZone').text('');
for (var i = 0; i < data.length; i++) {
$('#customdnsZone').append($('<option/>').text(data[i]));
}
});
show_current_custom_dns();
show_customdns_rtype_hint();
}
function show_current_custom_dns() {
api(
"/dns/custom",
"GET",
{ },
function(data) {
if (data.length > 0)
$('#custom-dns-current').fadeIn();
else
$('#custom-dns-current').fadeOut();
$('#custom-dns-current').find("tbody").text('');
for (var i = 0; i < data.length; i++) {
var tr = $("<tr/>");
$('#custom-dns-current').find("tbody").append(tr);
tr.attr('data-qname', data[i].qname);
tr.attr('data-rtype', data[i].rtype);
tr.attr('data-value', data[i].value);
tr.append($('<td class="long"/>').text(data[i].qname));
tr.append($('<td/>').text(data[i].rtype));
tr.append($('<td class="long"/>').text(data[i].value));
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
}
});
}
function delete_custom_dns_record(elem) {
var qname = $(elem).parents('tr').attr('data-qname');
var rtype = $(elem).parents('tr').attr('data-rtype');
var value = $(elem).parents('tr').attr('data-value');
do_set_custom_dns(qname, rtype, value, "DELETE");
return false;
}
function do_set_secondary_dns() {
api(
"/dns/secondary-nameserver",
"POST",
{
hostname: $('#secondarydnsHostname').val()
},
function(data) {
if (data == "") return; // nothing updated
show_modal_error("Secondary DNS", $("<pre/>").text(data));
$('#secondarydns-clear-instructions').slideDown();
},
function(err) {
show_modal_error("Secondary DNS", $("<pre/>").text(err));
});
}
function do_set_custom_dns(qname, rtype, value, method) {
if (!qname) {
if ($('#customdnsQname').val() != '')
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
else
qname = $('#customdnsZone').val();
rtype = $('#customdnsType').val();
value = $('#customdnsValue').val();
method = 'POST';
}
api(
"/dns/custom/" + qname + "/" + rtype,
method,
value,
function(data) {
if (data == "") return; // nothing updated
show_modal_error("Custom DNS", $("<pre/>").text(data));
show_current_custom_dns();
},
function(err) {
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
});
}
function show_customdns_rtype_hint() {
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
}
</script>

View File

@@ -9,33 +9,30 @@
padding-top: 0; padding-top: 0;
} }
#external_dns_settings .values td { #external_dns_settings .values td {
border: 0;
padding-top: .75em; padding-top: .75em;
padding-bottom: 0; padding-bottom: 0;
max-width: 50vw; }
word-wrap: break-word; #external_dns_settings .value {
word-break: break-all;
} }
#external_dns_settings .explanation td { #external_dns_settings .explanation td {
border: 0; border: 0;
padding-top: .5em; padding-top: .5em;
padding-bottom: .75em; padding-bottom: .75em;
font-style: italic; font-style: italic;
font-size: 95%;
color: #777; color: #777;
} }
</style> </style>
<h2>External DNS</h2> <h2>External DNS</h2>
<p class="text-danger">This is for advanced configurations.</p> <p class="text-warning">This is an advanced configuration page.</p>
<h3>Overview</h3> <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>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere. We do not recommend this.</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>If you do so, you are responsible for keeping your DNS entries up to date. In particular DNSSEC entries must be re-signed periodically. Do not set a DS record at your registrar or publish DNSSEC entries in your DNS zones if you do not intend to keep them up to date.</p>
<h3>DNS Settings</h3>
<p>Enter the following DNS entries at your DNS provider:</p>
<table id="external_dns_settings" class="table"> <table id="external_dns_settings" class="table">
<thead> <thead>
@@ -50,7 +47,7 @@
</table> </table>
<script> <script>
function show_system_external_dns() { function show_external_dns() {
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>") $('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api( api(
"/dns/dump", "/dns/dump",

View File

@@ -1,18 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]--> <html lang="en">
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head> <head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{hostname}} - Mail-in-a-Box Control Panel</title> <title>{{hostname}} - Mail-in-a-Box Control Panel</title>
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css">
<style> <style>
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700); @import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300); @import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
@@ -30,7 +27,7 @@
margin-bottom: 1.25em; margin-bottom: 1.25em;
} }
h1, h2, h3 { h1, h2, h3, h4 {
font-family: Raleway, sans-serif; font-family: Raleway, sans-serif;
font-weight: bold; font-weight: bold;
} }
@@ -44,29 +41,48 @@
border-bottom: 1px solid black; border-bottom: 1px solid black;
padding-bottom: 3px; padding-bottom: 3px;
margin-bottom: 13px; margin-bottom: 13px;
margin-top: 26px; margin-top: 30px;
}
.panel-heading h3 {
border: none;
padding: 0;
margin: 0;
} }
.panel { h4 {
font-size: 110%;
margin-bottom: 13px;
margin-top: 18px;
}
h4:first-child {
margin-top: 6px;
}
.admin_panel {
display: none; display: none;
} }
table.table { table.table {
margin: 1.5em 0; margin: 1.5em 0;
} }
ol li {
margin-bottom: 1em;
}
</style> </style>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap-theme.min.css">
<style> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
</style>
</head> </head>
<body> <body>
<!--[if lt IE 7]>
<p class="chromeframe">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> or <a href="http://www.google.com/chromeframe/?redirect=true">activate Google Chrome Frame</a> to improve your experience.</p> <!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->
<![endif]--> <!--[if gt IE 7]><!-->
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container"> <div class="container">
<div class="navbar-header"> <div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
@@ -75,27 +91,28 @@
</div> </div>
<div class="navbar-collapse collapse"> <div class="navbar-collapse collapse">
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li class="dropdown active"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li> <li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
<li><a href="#system_external_dns" onclick="return show_panel(this);">External DNS (Advanced)</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">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> </ul>
</li> </li>
<li class="dropdown active"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></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="#users" onclick="return show_panel(this);">Users</a></li>
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li> <li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
<!--<li><a href="#">Another action</a></li>
<li><a href="#">Something else here</a></li>
<li class="divider"></li>
<li class="dropdown-header">Nav header</li>
<li><a href="#">Separated link</a></li>
<li><a href="#">One more separated link</a></li>-->
</ul> </ul>
</li> </li>
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
<li><a href="#web" onclick="return show_panel(this);">Web</a></li>
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li> <li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
@@ -104,31 +121,51 @@
</div> </div>
</div> </div>
<div class="container-fluid"> <div class="container">
<div id="panel_system_status" class="container panel"> <div id="panel_system_status" class="admin_panel">
{% include "system-status.html" %} {% include "system-status.html" %}
</div> </div>
<div id="panel_system_external_dns" class="container panel"> <div id="panel_system_backup" class="admin_panel">
{% include "system-external-dns.html" %} {% include "system-backup.html" %}
</div> </div>
<div id="panel_login" class="panel"> <div id="panel_external_dns" class="admin_panel">
{% include "external-dns.html" %}
</div>
<div id="panel_custom_dns" class="admin_panel">
{% include "custom-dns.html" %}
</div>
<div id="panel_login" class="admin_panel">
{% include "login.html" %} {% include "login.html" %}
</div> </div>
<div id="panel_mail-guide" class="container panel"> <div id="panel_mail-guide" class="admin_panel">
{% include "mail-guide.html" %} {% include "mail-guide.html" %}
</div> </div>
<div id="panel_users" class="container panel"> <div id="panel_users" class="admin_panel">
{% include "users.html" %} {% include "users.html" %}
</div> </div>
<div id="panel_aliases" class="container panel"> <div id="panel_aliases" class="admin_panel">
{% include "aliases.html" %} {% include "aliases.html" %}
</div> </div>
<div id="panel_sync_guide" class="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> <hr>
<footer> <footer>
@@ -136,9 +173,9 @@
</footer> </footer>
</div> <!-- /container --> </div> <!-- /container -->
<div id="ajax_loading_indicator" style="display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%; text-align: center; background-color: rgba(255,255,255,.75)"> <div id="ajax_loading_indicator" style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 100000; text-align: center; background-color: rgba(255,255,255,.75)">
<div style="margin: 20% auto"> <div style="margin: 20% auto">
<div><span class="glyphicon glyphicon-time"></span></div> <div><span class="fa fa-spinner fa-pulse"></span></div>
<div>Loading...</div> <div>Loading...</div>
</div> </div>
</div> </div>
@@ -161,14 +198,19 @@
</div> </div>
</div> </div>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>
<script> <script>
var global_modal_state = null; var global_modal_state = null;
var global_modal_funcs = null; var global_modal_funcs = null;
$(function() { $(function() {
$('#global_modal').on('shown.bs.modal', function (e) {
// set focus to first input in the global modal's body
var input = $('#global_modal .modal-body input');
if (input.length > 0) $(input[0]).focus();
})
$('#global_modal .btn-danger').click(function() { $('#global_modal .btn-danger').click(function() {
// Don't take action now. Wait for the modal to be totally hidden // Don't take action now. Wait for the modal to be totally hidden
// so that we don't attempt to show another modal while this one // so that we don't attempt to show another modal while this one
@@ -189,7 +231,7 @@ $(function() {
function show_modal_error(title, message, callback) { function show_modal_error(title, message, callback) {
$('#global_modal h4').text(title); $('#global_modal h4').text(title);
$('#global_modal .modal-body').html("<p/>"); $('#global_modal .modal-body').html("<p/>");
if (typeof question == String) { if (typeof question == 'string') {
$('#global_modal p').text(message); $('#global_modal p').text(message);
$('#global_modal .modal-dialog').addClass("modal-sm"); $('#global_modal .modal-dialog').addClass("modal-sm");
} else { } else {
@@ -201,11 +243,12 @@ function show_modal_error(title, message, callback) {
global_modal_funcs = [callback, callback]; global_modal_funcs = [callback, callback];
global_modal_state = null; global_modal_state = null;
$('#global_modal').modal({}); $('#global_modal').modal({});
return false; // handy when called from onclick
} }
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) { function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
$('#global_modal h4').text(title); $('#global_modal h4').text(title);
if (typeof question == String) { if (typeof question == 'string') {
$('#global_modal .modal-dialog').addClass("modal-sm"); $('#global_modal .modal-dialog').addClass("modal-sm");
$('#global_modal .modal-body').html("<p/>"); $('#global_modal .modal-body').html("<p/>");
$('#global_modal p').text(question); $('#global_modal p').text(question);
@@ -213,19 +256,26 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
$('#global_modal .modal-dialog').removeClass("modal-sm"); $('#global_modal .modal-dialog').removeClass("modal-sm");
$('#global_modal .modal-body').html("").append(question); $('#global_modal .modal-body').html("").append(question);
} }
if (typeof verb == 'string') {
$('#global_modal .btn-default').show().text("Cancel"); $('#global_modal .btn-default').show().text("Cancel");
$('#global_modal .btn-danger').show().text(verb); $('#global_modal .btn-danger').show().text(verb);
} else {
$('#global_modal .btn-default').show().text(verb[1]);
$('#global_modal .btn-danger').show().text(verb[0]);
}
global_modal_funcs = [yes_callback, cancel_callback]; global_modal_funcs = [yes_callback, cancel_callback];
global_modal_state = null; global_modal_state = null;
$('#global_modal').modal({}); $('#global_modal').modal({});
return false; // handy when called from onclick
} }
var is_ajax_loading = false; var ajax_num_executing_requests = 0;
function ajax(options) { function ajax(options) {
setTimeout("if (is_ajax_loading) $('#ajax_loading_indicator').fadeIn()", 100); setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
function hide_loading_indicator() { function hide_loading_indicator() {
is_ajax_loading = false; ajax_num_executing_requests--;
$('#ajax_loading_indicator').hide(); if (ajax_num_executing_requests == 0)
$('#ajax_loading_indicator').stop(true).hide(); // stop() prevents an ongoing fade from causing the thing to be shown again after this call
} }
var old_success = options.success; var old_success = options.success;
var old_error = options.error; var old_error = options.error;
@@ -241,10 +291,11 @@ function ajax(options) {
if (!old_error) if (!old_error)
show_modal_error("Error", "Something went wrong, sorry.") show_modal_error("Error", "Something went wrong, sorry.")
else else
old_error(jqxhr.responseText); old_error(jqxhr.responseText, jqxhr);
}; };
is_ajax_loading = true; ajax_num_executing_requests++;
$.ajax(options); $.ajax(options);
return false; // handy when called from onclick
} }
var api_credentials = ["", ""]; var api_credentials = ["", ""];
@@ -276,10 +327,21 @@ function api(url, method, data, callback, callback_error) {
return output; return output;
} }
function default_error(text, xhr) {
if (xhr.status != 403) // else handled below
show_modal_error("Error", "Something went wrong, sorry.")
}
ajax({ ajax({
url: "/admin" + url, url: "/admin" + url,
method: method, method: method,
cache: false,
data: data, data: data,
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
processData: typeof data != "string",
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
beforeSend: function(xhr) { beforeSend: function(xhr) {
// We don't store user credentials in a cookie to avoid the hassle of CSRF // We don't store user credentials in a cookie to avoid the hassle of CSRF
// attacks. The Authorization header only gets set in our AJAX calls triggered // attacks. The Authorization header only gets set in our AJAX calls triggered
@@ -289,7 +351,7 @@ function api(url, method, data, callback, callback_error) {
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1])); 'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
}, },
success: callback, success: callback,
error: callback_error, error: callback_error || default_error,
statusCode: { statusCode: {
403: function(xhr) { 403: function(xhr) {
// Credentials are no longer valid. Try to login again. // Credentials are no longer valid. Try to login again.
@@ -308,7 +370,7 @@ function show_panel(panelid) {
// we might be passed an HTMLElement <a>. // we might be passed an HTMLElement <a>.
panelid = panelid.getAttribute('href').substring(1); panelid = panelid.getAttribute('href').substring(1);
$('.panel').hide(); $('.admin_panel').hide();
$('#panel_' + panelid).show(); $('#panel_' + panelid).show();
if (typeof localStorage != 'undefined') if (typeof localStorage != 'undefined')
localStorage.setItem("miab-cp-lastpanel", panelid); localStorage.setItem("miab-cp-lastpanel", panelid);

View File

@@ -1,25 +1,43 @@
<div class="row"> <h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-4 col-lg-4">
<center>
<h1 style="margin: 1em">{{hostname}}</h1>
<p style="margin: 2em">Log in here for your Mail-in-a-Box control panel.</p>
</center>
{% if no_users_exist or no_admins_exist %}
<div class="row">
<div class="col-md-offset-2 col-md-8">
{% if no_users_exist %}
<p class="text-danger">There are no users on this system! To make an administrative user,
log into this machine using SSH (like when you first set it up) and run:</p>
<pre>cd mailinabox
sudo tools/mail.py user add me@{{hostname}}
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
{% else %}
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
log into this machine using SSH (like when you first set it up) and run:</p>
<pre>cd mailinabox
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
{% endif %}
<hr>
</div>
</div>
{% endif %}
<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;"> <form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
<div class="form-group"> <div class="form-group">
<label for="inputEmail3" class="col-sm-2 control-label">Email</label> <label for="inputEmail3" class="col-sm-3 control-label">Email</label>
<div class="col-sm-10"> <div class="col-sm-9">
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email"> <input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="inputPassword3" class="col-sm-2 control-label">Password</label> <label for="inputPassword3" class="col-sm-3 control-label">Password</label>
<div class="col-sm-10"> <div class="col-sm-9">
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password"> <input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-2 col-sm-10"> <div class="col-sm-offset-3 col-sm-9">
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input name='remember' type="checkbox" id="loginRemember"> Remember me <input name='remember' type="checkbox" id="loginRemember"> Remember me
@@ -28,13 +46,13 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-2 col-sm-10"> <div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-default">Sign in</button> <button type="submit" class="btn btn-default">Sign in</button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</div>
<script> <script>
function do_login() { function do_login() {
@@ -57,18 +75,25 @@ function do_login() {
function(response){ function(response){
// This API call always succeeds. It returns a JSON object indicating // This API call always succeeds. It returns a JSON object indicating
// whether the request was authenticated or not. // whether the request was authenticated or not.
if (response.status != "authorized") { if (response.status != "ok") {
// Show why the login failed. // Show why the login failed.
show_modal_error("Login Failed", response.reason) show_modal_error("Login Failed", response.reason)
// Reset any saved credentials. // Reset any saved credentials.
do_logout(); do_logout();
} else if (!("api_key" in response)) {
// Login succeeded but user might not be authorized!
show_modal_error("Login Failed", "You are not an administrator on this system.")
// Reset any saved credentials.
do_logout();
} else { } else {
// Login succeeded. // Login succeeded.
// Save the new credentials. // Save the new credentials.
api_credentials = [response.api_key, ""]; api_credentials = [response.email, response.api_key];
// Try to wipe the username/password information. // Try to wipe the username/password information.
$('#loginEmail').val(''); $('#loginEmail').val('');
@@ -85,8 +110,10 @@ function do_login() {
} }
} }
// Open the next panel the user wants to go to. // Open the next panel the user wants to go to. Do this after the XHR response
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel) // is over so that we don't start a new XHR request while this one is finishing,
// which confuses the loading indicator.
setTimeout(function() { show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel) }, 300);
} }
}) })
} }

View File

@@ -1,41 +1,71 @@
<div class="container"> <style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style>
<h2>Checking and Sending Mail</h2>
<h4>App Configuration</h4> <div>
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
<p>You can access your email using webmail, desktop mail clients, or mobile apps.</p> <div class="row">
<div class="col-sm-7">
<h3>Webmail</h3>
<p>Here is what you need to know for webmail:</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>
<style>#panel_mail-guide table.table { width: auto; margin-left: 1.5em; }</style>
<h3>Mobile/desktop apps</h3>
<h4>Automatic configuration</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>
<h4>Manual configuration</h4>
<p>Use the following settings when you set up your email on your phone, desktop, or other device:</p>
<table class="table"> <table class="table">
<tr><th>Webmail Address:</th> <td><a href="https://{{hostname}}/mail"><b>https://{{hostname}}/mail</b></a></td></tr> <thead>
<tr><th>Option</th> <th>Value</th></tr>
</thead>
<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 Security</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>Username:</th> <td>Your whole email address.</td></tr>
<tr><th>Password:</th> <td>Your mail password.</td></tr> <tr><th>Password:</th> <td>Your mail password.</td></tr>
</table> </table>
<p>On mobile devices you might need to install a &ldquo;mail client&rdquo; app. We recommend <a href="https://play.google.com/store/apps/details?id=com.fsck.k9">K-9 Mail</a>. On a desktop you could try <a href="https://www.mozilla.org/en-US/thunderbird/">Mozilla Thunderbird</a>.</p> <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>
<p>Configure your device or desktop mail client as follows:</p> <h4>Exchange/ActiveSync settings</h4>
<table class="table" style="max-width: 30em">
<tr><th>Server Name:</th> <td>{{hostname}}</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>On iOS devices, devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
<table class="table"> <table class="table">
<thead><tr><th>Protocol</th> <th>Port</th> <th>Options</th></tr></thead> <tr><th>Server</th> <td>{{hostname}}</td></tr>
<tr><th>IMAP</th> <td>993</td> <td>SSL</td></tr> <tr><th>Options</th> <td>Secure Connection</td></tr>
<tr><th>SMTP</th> <td>587</td> <td>STARTTLS</td></tr>
<tr><th>Exchange ActiveSync</th> <td>n/a</td> <td>Secure Connection</td></tr>
</table> </table>
<p>Depending on your mail program, you will use either IMAP &amp; SMTP or Exchange ActiveSync. See this <a href="http://z-push.org/compatibility/">list of compatible devices</a> for Exchange ActiveSync.</p> <p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
</div>
<h4>Notes</h4>
<div class="col-sm-5">
<p>Mail-in-a-Box uses <a href="http://en.wikipedia.org/wiki/Greylisting">greylisting</a> to cut down on spam. The first time you receive an email from a recipient, it may be delayed for ten minutes.</p> <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> </div>

View File

@@ -0,0 +1,121 @@
<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 (/^OK($|\n)/.test(status)) {
console.log(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

@@ -0,0 +1,49 @@
<div>
<h2>Contacts &amp; Calendar Synchronization</h2>
<p>This box can hold your contacts and calendar, just like it holds your email.</p>
<hr>
<div class="row">
<div class="col-sm-6">
<h4>In your browser</h4>
<p>You can edit your contacts and calendar from your web browser.</p>
<table class="table">
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
<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
complete email address and your mail password.</p>
</div>
<div class="col-sm-6">
<h4>On your mobile device</h4>
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync,
your contacts and calendar may already appear on your device.</p>
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>
<table class="table">
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
<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>
<table class="table">
<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>
<tr><td>Server Name</td> <td>{{hostname}}</td></tr>
<tr><td>Use SSL</td> <td>Yes</td></tr>
<tr><td>Username</td> <td>Your complete email address.</td></tr>
<tr><td>Password</td> <td>Your mail password.</td></tr>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
<style>
#backup-status th { text-align: center; }
#backup-status tr.full-backup td { font-weight: bold; }
</style>
<h2>Backup Status</h2>
<h3>Copying Backup Files</h3>
<p>The box makes an incremental backup each night. The backup is stored on the machine itself. You are responsible for copying the backup files off of the machine.</p>
<p>Many cloud providers make this easy by allowing you to take snapshots of the machine's disk.</p>
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrypted, so they are safe to store anywhere. Copy the encryption password from <tt id="backup-encpassword-file"></tt> also but keep it in a safe location.</p>
<h3>Current Backups</h3>
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
<table id="backup-status" class="table" style="width: auto">
<thead>
<th colspan="2">When</th>
<th>Type</th>
<th>Size</th>
<th>Deleted in...</th>
</thead>
<tbody>
</tbody>
</table>
<script>
function nice_size(bytes) {
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
while (true) {
if (powers.length == 1) break;
if (bytes < 1000) break;
bytes /= 1024;
powers.shift();
}
// 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() {
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/system/backup/status",
"GET",
{ },
function(r) {
$('#backup-location').text(r.directory);
$('#backup-encpassword-file').text(r.encpwfile);
$('#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/>');
if (b.full) tr.addClass("full-backup");
tr.append( $('<td/>').text(b.date_str + " " + r.tz) );
tr.append( $('<td/>').text(b.date_delta + " ago") );
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.size)) );
if (b.deleted_in)
tr.append( $('<td/>').text(b.deleted_in) );
else
tr.append( $('<td class="text-muted">unknown</td>') );
$('#backup-status tbody').append(tr);
total_disk_size += b.size;
}
$('#backup-total-size').text(nice_size(total_disk_size));
})
}
</script>

View File

@@ -10,11 +10,14 @@
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
} }
#system-checks .error td { #system-checks .status-error td {
color: #733; color: #733;
} }
#system-checks .ok td { #system-checks .status-warning td {
color: #030; color: #770;
}
#system-checks .status-ok td {
color: #040;
} }
#system-checks div.extra { #system-checks div.extra {
display: none; display: none;
@@ -52,9 +55,13 @@ function show_system_status() {
for (var i = 0; i < r.length; i++) { for (var i = 0; i < r.length; i++) {
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>"); var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
if (i == 0) n.addClass('first') if (i == 0) n.addClass('first')
if (r[i].type == "heading")
n.addClass(r[i].type) n.addClass(r[i].type)
else
n.addClass("status-" + r[i].type)
if (r[i].type == "ok") n.find('td.status').text("✓") if (r[i].type == "ok") n.find('td.status').text("✓")
if (r[i].type == "error") n.find('td.status').text("✖") if (r[i].type == "error") n.find('td.status').text("✖")
if (r[i].type == "warning") n.find('td.status').text("?")
n.find('td.message p').text(r[i].text) n.find('td.message p').text(r[i].text)
$('#system-checks tbody').append(n); $('#system-checks tbody').append(n);

View File

@@ -1,19 +1,17 @@
<h2>Users</h2> <h2>Users</h2>
<style> <style>
#user_table h4 { margin: 1em 0 0 0; }
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; } #user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
#user_table .aliases { margin: .25em 0 0 1em; font-size: 95%; } #user_table .actions { margin-top: .33em; font-size: 95%; }
#user_table .aliases div:before { content: "⇖ "; } #user_table .account_inactive .if_active { display: none; }
#user_table .aliases div { } #user_table .account_active .if_inactive { display: none; }
#user_table .actions { margin: .25em 0 0 1em; font-size: 95%; } #user_table .account_active.if_inactive { display: none; }
#user_table .actions > * { display: none; }
#user_table .account_active .actions a.archive { display: inline; }
#user_table .account_inactive .actions .restore { display: inline; }
</style> </style>
<h3>Add a mail user</h3> <h3>Add a mail user</h3>
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p> <p>Add an email address to this system. This will create a new login username/password.</p>
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;"> <form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
<div class="form-group"> <div class="form-group">
@@ -32,18 +30,20 @@
</div> </div>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form>
<p style="margin-top: .5em"><small> <ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
Passwords must be at least four characters and may not contain spaces. <li>Passwords must be at least four characters and may not contain spaces.</li>
Administrators get access to this control panel. <li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
</small></p> <li>Administrators get access to this control panel.</li>
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="javascript:show_panel('aliases')">aliases</a> can.</li>
</ul>
<h3>Existing mail users</h3> <h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto"> <table id="user_table" class="table" style="width: auto">
<thead> <thead>
<tr> <tr>
<th></th> <th width="50%">Email Address</th>
<th>Email Address<br><small style="font-weight: normal">(Also the user&rsquo;s login username.)</small></th> <th>Actions</th>
<th>Privileges</th> <th>Mailbox Size</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,19 +53,33 @@
<div style="display: none"> <div style="display: none">
<table> <table>
<tr id="user-template"> <tr id="user-template">
<td class='address'>
</td>
<td class='actions'> <td class='actions'>
<a href="#" onclick="users_remove(this); return false;" class='archive' title="Archive Account"> <span class='privs'>
<span class="glyphicon glyphicon-trash"></span> </span>
<span class="if_active">
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
set password
</a>
|
</span>
<span class='add-privs'>
</span>
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
archive account
</a> </a>
</td> </td>
<td class='email'> <td class='mailboxsize'>
<div class='address'> </div> </td>
<div class='aliases' style='display: none'> </div> </tr>
<div class='actions'> <tr id="user-extra-template" class="if_inactive">
<span class='restore'>To restore account, create a new account with this email address.</span> <td colspan="3" style="border: 0; padding-top: 0">
</div> <div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
</td> </td>
<td class='privs'> </td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -81,39 +95,46 @@ function show_users() {
function(r) { function(r) {
$('#user_table tbody').html(""); $('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) { for (var i = 0; i < r.length; i++) {
var n = $("#user-template").clone(); var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
n.attr('id', ''); hdr.find('h4').text(r[i].domain);
$('#user_table tbody').append(hdr);
n.addClass("account_" + r[i].status); for (var k = 0; k < r[i].users.length; k++) {
n.attr('data-email', r[i].email); var user = r[i].users[k];
n.find('td.email .address').text(r[i].email)
var n = $("#user-template").clone();
var n2 = $("#user-extra-template").clone();
n.attr('id', '');
n2.attr('id', '');
$('#user_table tbody').append(n);
$('#user_table tbody').append(n2);
n.addClass("account_" + user.status);
n2.addClass("account_" + user.status);
n.attr('data-email', user.email);
n.find('.address').text(user.email)
n.find('.mailboxsize').text(nice_size(user.mailbox_size))
n2.find('.restore_info tt').text(user.mailbox);
if (user.status == 'inactive') continue;
var add_privs = ["admin"]; var add_privs = ["admin"];
for (var j = 0; j < r[i].privileges.length; j++) { for (var j = 0; j < user.privileges.length; j++) {
var p = $("<div><span class='name'></span> <a href='#' onclick='mod_priv(this, \"remove\"); return false;'><span class=\"glyphicon glyphicon-trash\" style='font-size: 90%'></span></a></div>"); 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('td.privs').append(p); n.find('.privs').append(p);
if (add_privs.indexOf(r[i].privileges[j]) >= 0) if (add_privs.indexOf(user.privileges[j]) >= 0)
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1); add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
} }
for (var j = 0; j < add_privs.length; j++) { for (var j = 0; j < add_privs.length; j++) {
var p = $("<div><small><a href='#' onclick='mod_priv(this, \"add\"); return false;'><span class=\"glyphicon glyphicon-plus\" style='font-size: 90%'></span> <span class='name'></span>?</a></small></div>"); var p = $("<span><a href='#' onclick='mod_priv(this, \"add\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>");
p.find('span.name').text(add_privs[j]); p.find('span.name').text(add_privs[j]);
n.find('td.privs').append(p); n.find('.add-privs').append(p);
}
if (r[i].aliases && r[i].aliases.length > 0) {
n.find('.aliases').show();
for (var j = 0; j < r[i].aliases.length; j++) {
n.find('td.email .aliases').append($("<div/>").text(
r[i].aliases[j][0]
+ (r[i].aliases[j][1].length > 0 ? " ⇐ " + r[i].aliases[j][1].join(", ") : "")
))
} }
} }
$('#user_table tbody').append(n);
} }
}) })
} }
@@ -141,11 +162,42 @@ function do_add_user() {
return false; return false;
} }
function users_remove(elem) { function users_set_password(elem) {
var email = $(elem).parents('tr').attr('data-email'); var email = $(elem).parents('tr').attr('data-email');
show_modal_confirm( show_modal_confirm(
"Archive User", "Archive User",
$("<p>Are you sure you want to archive " + email + "?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"), $("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least four characters and may not contain spaces.</small></p>"),
"Set Password",
function() {
api(
"/mail/users/password",
"POST",
{
email: email,
password: $('#users_set_password_pw').val()
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Set Password", $("<pre/>").text(r));
},
function(r) {
show_modal_error("Set Password", r);
});
});
}
function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email');
// can't remove yourself
if (api_credentials != null && email == api_credentials[0]) {
show_modal_error("Archive User", "You cannot archive your own account.");
return;
}
show_modal_confirm(
"Archive User",
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
"Archive", "Archive",
function() { function() {
api( api(
@@ -178,7 +230,7 @@ function mod_priv(elem, add_remove) {
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1); var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
show_modal_confirm( show_modal_confirm(
"Modify Privileges", "Modify Privileges",
"Are you sure you want to " + add_remove + " the " + priv + " privilege for " + email + "?", $("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
add_remove1, add_remove1,
function() { function() {
api( api(

View File

@@ -0,0 +1,89 @@
<style>
</style>
<h2>Static Web Hosting</h2>
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at all domain names that you set up an email user or alias for.</p>
<h3>Uploading web files</h3>
<p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p>
<ol>
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status" onclick="return show_panel(this);">Status Checks</a> page.</li>
<li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.</li>
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li>
<li>The websites set up on this machine are listed in the table below with where to put the files for each website.</li>
<table id="web_domains_existing" class="table" style="margin-bottom: 1em; width: auto;">
<thead>
<tr>
<th>Site</th>
<th>Directory for Files</th>
<th/>
</tr>
</thead>
<tbody>
</tbody>
</table>
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p>
</ol>
<script>
function show_web() {
api(
"/web/domains",
"GET",
{
},
function(domains) {
var tb = $('#web_domains_existing tbody');
tb.text('');
for (var i = 0; i < domains.length; i++) {
if (!domains[i].static_enabled) continue;
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td> <td class='change-root hidden'><button class='btn btn-default btn-xs' onclick='show_change_web_root(this)'>Change</button></td></tr>");
tb.append(row);
row.attr('data-domain', domains[i].domain);
row.attr('data-custom-web-root', domains[i].custom_root);
row.find('.domain a').text('https://' + domains[i].domain);
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
row.find('.directory tt').text(domains[i].root);
if (domains[i].root != domains[i].custom_root)
row.find('.change-root').removeClass('hidden');
}
});
}
function do_web_update() {
api(
"/web/update",
"POST",
{
},
function(data) {
if (data == "")
data = "Nothing changed.";
else
data = $("<pre/>").text(data);
show_modal_error("Web Update", data, function() { show_web() });
});
}
function show_change_web_root(elem) {
var domain = $(elem).parents('tr').attr('data-domain');
var root = $(elem).parents('tr').attr('data-custom-web-root');
show_modal_confirm(
'Change Root Directory for ' + domain,
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.'),
'Update',
function() { do_web_update(); });
}
</script>

View File

@@ -23,10 +23,6 @@ def safe_domain_name(name):
import urllib.parse import urllib.parse
return urllib.parse.quote(name, safe='') return urllib.parse.quote(name, safe='')
def unsafe_domain_name(name_encoded):
import urllib.parse
return urllib.parse.unquote(name_encoded)
def sort_domains(domain_names, env): def sort_domains(domain_names, env):
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME # Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
# must appear first so it becomes the nginx default server. # must appear first so it becomes the nginx default server.
@@ -169,3 +165,38 @@ def create_syslog_handler():
handler = logging.handlers.SysLogHandler(address='/dev/log') handler = logging.handlers.SysLogHandler(address='/dev/log')
handler.setLevel(logging.WARNING) handler.setLevel(logging.WARNING)
return handler return handler
def du(path):
# Computes the size of all files in the path, like the `du` command.
# Based on http://stackoverflow.com/a/17936789. Takes into account
# soft and hard links.
total_size = 0
seen = set()
for dirpath, dirnames, filenames in os.walk(path):
for f in filenames:
fp = os.path.join(dirpath, f)
try:
stat = os.lstat(fp)
except OSError:
continue
if stat.st_ino in seen:
continue
seen.add(stat.st_ino)
total_size += stat.st_size
return total_size
def wait_for_service(port, public, env, timeout):
# Block until a service on a given port (bound privately or publicly)
# is taking connections, with a maximum timeout.
import socket, time
start = time.perf_counter()
while True:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout/3)
try:
s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
return True
except OSError:
if time.perf_counter() > start+timeout:
return False
time.sleep(min(timeout/4, 1))

View File

@@ -2,10 +2,10 @@
# domains for which a mail account has been set up. # domains for which a mail account has been set up.
######################################################################## ########################################################################
import os, os.path, re, rtyaml import os, os.path, shutil, re, tempfile, rtyaml
from mailconfig import get_mail_domains from mailconfig import get_mail_domains
from dns_update import get_custom_dns_config from dns_update import get_custom_dns_config, do_dns_update
from utils import shell, safe_domain_name, sort_domains from utils import shell, safe_domain_name, sort_domains
def get_web_domains(env): def get_web_domains(env):
@@ -17,19 +17,16 @@ def get_web_domains(env):
domains.add(env['PRIMARY_HOSTNAME']) domains.add(env['PRIMARY_HOSTNAME'])
# Also serve web for all mail domains so that we might at least # Also serve web for all mail domains so that we might at least
# provide Webfinger and ActiveSync auto-discover of email settings # provide auto-discover of email settings, and also a static website
# (though the latter isn't really working). These will require that # if the user wants to make one. These will require an SSL cert.
# an SSL cert be installed.
domains |= get_mail_domains(env) domains |= get_mail_domains(env)
# ...Unless the domain has an A/AAAA record that maps it to a different # ...Unless the domain has an A/AAAA record that maps it to a different
# IP address than this box. Remove those domains from our list. # IP address than this box. Remove those domains from our list.
dns = get_custom_dns_config(env) dns = get_custom_dns_config(env)
for domain, value in dns.items(): for domain, rtype, value in dns:
if domain not in domains: continue if domain not in domains: continue
if (isinstance(value, str) and (value != "local")) \ if rtype == "CNAME" or (rtype in ("A", "AAAA") and value != "local"):
or (isinstance(value, dict) and ("A" in value) and (value["A"] != "local")) \
or (isinstance(value, dict) and ("AAAA" in value) and (value["AAAA"] != "local")):
domains.remove(domain) domains.remove(domain)
# Sort the list. Put PRIMARY_HOSTNAME first so it becomes the # Sort the list. Put PRIMARY_HOSTNAME first so it becomes the
@@ -75,11 +72,11 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
root = get_web_root(domain, env) root = get_web_root(domain, env)
# What private key and SSL certificate will we use for this domain? # What private key and SSL certificate will we use for this domain?
ssl_key, ssl_certificate, csr_path = get_domain_ssl_files(domain, env) ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
# For hostnames created after the initial setup, ensure we have an SSL certificate # For hostnames created after the initial setup, ensure we have an SSL certificate
# available. Make a self-signed one now if one doesn't exist. # available. Make a self-signed one now if one doesn't exist.
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env) ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env)
# Put pieces together. # Put pieces together.
nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template) nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template)
@@ -94,7 +91,20 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key) nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate) nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
# Add in any user customizations. # 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") nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn): if os.path.exists(nginx_conf_custom_fn):
yaml = rtyaml.load(open(nginx_conf_custom_fn)) yaml = rtyaml.load(open(nginx_conf_custom_fn))
@@ -102,20 +112,27 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
yaml = yaml[domain] yaml = yaml[domain]
for path, url in yaml.get("proxies", {}).items(): for path, url in yaml.get("proxies", {}).items():
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url) nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
for path, url in yaml.get("redirects", {}).items():
nginx_conf += "\trewrite %s %s permanent;\n" % (path, url)
# Add in any user customizations in the includes/ folder.
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
if os.path.exists(nginx_conf_custom_include):
nginx_conf += "\tinclude %s;\n" % (nginx_conf_custom_include)
# Ending. # Ending.
nginx_conf += nginx_conf_parts[1] nginx_conf += nginx_conf_parts[1]
return nginx_conf return nginx_conf
def get_web_root(domain, env): def get_web_root(domain, env, test_exists=True):
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default. # Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
for test_domain in (domain, 'default'): for test_domain in (domain, 'default'):
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain)) root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
if os.path.exists(root): break if os.path.exists(root) or not test_exists: break
return root return root
def get_domain_ssl_files(domain, env): def get_domain_ssl_files(domain, env, allow_shared_cert=True):
# What SSL private key will we use? Allow the user to override this, but # What SSL private key will we use? Allow the user to override this, but
# in many cases using the same private key for all domains would be fine. # in many cases using the same private key for all domains would be fine.
# Don't allow the user to override the key for PRIMARY_HOSTNAME because # Don't allow the user to override the key for PRIMARY_HOSTNAME because
@@ -129,6 +146,7 @@ def get_domain_ssl_files(domain, env):
# What SSL certificate will we use? # What SSL certificate will we use?
ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem') ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
ssl_via = None
if domain == env['PRIMARY_HOSTNAME']: if domain == env['PRIMARY_HOSTNAME']:
# For PRIMARY_HOSTNAME, use the one we generated at set-up time. # For PRIMARY_HOSTNAME, use the one we generated at set-up time.
ssl_certificate = ssl_certificate_primary ssl_certificate = ssl_certificate_primary
@@ -139,21 +157,22 @@ def get_domain_ssl_files(domain, env):
# But we can be smart and reuse the main SSL certificate if is has # But we can be smart and reuse the main SSL certificate if is has
# a Subject Alternative Name matching this domain. Don't do this if # a Subject Alternative Name matching this domain. Don't do this if
# the user has uploaded a different private key for this domain. # the user has uploaded a different private key for this domain.
if not ssl_key_is_alt: if not ssl_key_is_alt and allow_shared_cert:
from whats_next import check_certificate from status_checks import check_certificate
if check_certificate(domain, ssl_certificate_primary, None) == "OK": if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
ssl_certificate = ssl_certificate_primary ssl_certificate = ssl_certificate_primary
ssl_via = "Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME']
# Where would the CSR go? As with the SSL cert itself, the CSR must be # For a 'www.' domain, see if we can reuse the cert of the parent.
# different for each domain name. elif domain.startswith('www.'):
if domain == env['PRIMARY_HOSTNAME']: ssl_certificate_parent = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain[4:]))
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_cert_sign_req.csr') if os.path.exists(ssl_certificate_parent) and check_certificate(domain, ssl_certificate_parent, None)[0] == "OK":
else: ssl_certificate = ssl_certificate_parent
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/certificate_signing_request.csr' % safe_domain_name(domain)) ssl_via = "Using multi/wildcard certificate of %s." % domain[4:]
return ssl_key, ssl_certificate, csr_path return ssl_key, ssl_certificate, ssl_via
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env): def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env):
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if # For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
# a certificate doesn't already exist. See setup/mail.sh for documentation. # a certificate doesn't already exist. See setup/mail.sh for documentation.
@@ -172,18 +191,104 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
# Generate a new self-signed certificate using the same private key that we already have. # Generate a new self-signed certificate using the same private key that we already have.
# Start with a CSR. # Start with a CSR written to a temporary file.
shell("check_call", [ with tempfile.NamedTemporaryFile(mode="w") as csr_fp:
"openssl", "req", "-new", csr_fp.write(create_csr(domain, ssl_key, env))
"-key", ssl_key, csr_fp.flush() # since we won't close until after running 'openssl x509', since close triggers delete.
"-out", csr_path,
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
# And then make the certificate. # And then make the certificate.
shell("check_call", [ shell("check_call", [
"openssl", "x509", "-req", "openssl", "x509", "-req",
"-days", "365", "-days", "365",
"-in", csr_path, "-in", csr_fp.name,
"-signkey", ssl_key, "-signkey", ssl_key,
"-out", ssl_certificate]) "-out", ssl_certificate])
def create_csr(domain, ssl_key, env):
return shell("check_output", [
"openssl", "req", "-new",
"-key", ssl_key,
"-sha256",
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
def install_cert(domain, ssl_cert, ssl_chain, env):
if domain not in get_web_domains(env):
return "Invalid domain name."
# Write the combined cert+chain to a temporary path and validate that it is OK.
# The certificate always goes above the chain.
import tempfile, os
fd, fn = tempfile.mkstemp('.pem')
os.write(fd, (ssl_cert + '\n' + ssl_chain).encode("ascii"))
os.close(fd)
# Do validation on the certificate before installing it.
from status_checks import check_certificate
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env, allow_shared_cert=False)
cert_status, cert_status_details = check_certificate(domain, fn, ssl_key)
if cert_status != "OK":
if cert_status == "SELF-SIGNED":
cert_status = "This is a self-signed certificate. I can't install that."
os.unlink(fn)
if cert_status_details is not None:
cert_status += " " + cert_status_details
return cert_status
# Copy the certificate to its expected location.
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
shutil.move(fn, ssl_certificate)
ret = ["OK"]
# 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) )
return "\n".join(ret)
def get_web_domains_info(env):
# load custom settings so we can tell what domains have a redirect or proxy set up on '/',
# which means static hosting is not happening
custom_settings = { }
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn):
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
def has_root_proxy_or_redirect(domain):
return custom_settings.get(domain, {}).get('redirects', {}).get('/') or custom_settings.get(domain, {}).get('proxies', {}).get('/')
# for the SSL config panel, get cert status
def check_cert(domain):
from status_checks import check_certificate
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
return ("danger", "No Certificate Installed")
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
if cert_status == "OK":
if not ssl_via:
return ("success", "Signed & valid. " + cert_status_details)
else:
# This is an alternate domain but using the same cert as the primary domain.
return ("success", "Signed & valid. " + ssl_via)
elif cert_status == "SELF-SIGNED":
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
else:
return ("danger", "Certificate has a problem: " + cert_status)
return [
{
"domain": domain,
"root": get_web_root(domain, env),
"custom_root": get_web_root(domain, env, test_exists=False),
"ssl_certificate": check_cert(domain),
"static_enabled": not has_root_proxy_or_redirect(domain),
}
for domain in get_web_domains(env)
]

View File

@@ -1,488 +0,0 @@
#!/usr/bin/python3
#
# Checks that the upstream DNS has been set correctly and that
# SSL certificates have been signed, etc., and if not tells the user
# what to do next.
__ALL__ = ['check_certificate']
import os, os.path, re, subprocess
import dns.reversename, dns.resolver
from dns_update import get_dns_zones, build_tlsa_record
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):
env["out"] = output
run_system_checks(env)
run_network_checks(env)
run_domain_checks(env)
def run_system_checks(env):
env["out"].add_heading("System")
# Check that SSH login with password is disabled.
sshd = open("/etc/ssh/sshd_config").read()
if re.search("\nPasswordAuthentication\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\s+no", sshd):
env['out'].print_error("""The SSH server on this machine permits password-based login. A more secure
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
that you can log in without a password, set the option 'PasswordAuthentication no' in
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
else:
env['out'].print_ok("SSH disallows password-based login.")
# 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 run_network_checks(env):
# Also see setup/network-checks.sh.
env["out"].add_heading("Network")
# Stop if we cannot make an outbound connection on port 25. Many residential
# networks block outbound port 25 to prevent their network from sending spam.
# See if we can reach one of Google's MTAs with a 5-second timeout.
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
if ret == 0:
env['out'].print_ok("Outbound mail (SMTP port 25) is not blocked.")
else:
env['out'].print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
will not be able to send any mail. Many residential networks block port 25 to prevent hijacked
machines from being able to send spam. A quick connection test to Google's mail server on port 25
failed.""")
# Stop if the IPv4 address is listed in the ZEN Spamhouse Block List.
# The user might have ended up on an IP address that was previously in use
# by a spammer, or the user may be deploying on a residential network. We
# will not be able to reliably send mail in these cases.
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
if not query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None):
env['out'].print_ok("IP address is not blacklisted by zen.spamhaus.org.")
else:
env['out'].print_error("""The IP address of this machine %s is listed in the Spamhaus Block List,
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
% (env['PUBLIC_IP'], env['PUBLIC_IP']))
def run_domain_checks(env):
# Get the list of domains we handle mail for.
mail_domains = get_mail_domains(env)
# Get the list of domains we serve DNS zones for (i.e. does not include subdomains).
dns_zonefiles = dict(get_dns_zones(env))
dns_domains = set(dns_zonefiles)
# Get the list of domains we serve HTTPS for.
web_domains = set(get_web_domains(env))
# Check the domains.
for domain in sort_domains(mail_domains | dns_domains | web_domains, env):
env["out"].add_heading(domain)
if domain == env["PRIMARY_HOSTNAME"]:
check_primary_hostname_dns(domain, env)
if domain in dns_domains:
check_dns_zone(domain, env, dns_zonefiles)
if domain in mail_domains:
check_mail_domain(domain, env)
if domain in web_domains:
check_web_domain(domain, env)
def check_primary_hostname_dns(domain, env):
# 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.
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']))
else:
env['out'].print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
public DNS to update after a change."""
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ip))
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']:
env['out'].print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
else:
env['out'].print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
issues listed here."""
% (env['PUBLIC_IP'], ip))
# Check reverse DNS on the PRIMARY_HOSTNAME. Note that it might not be
# a DNS zone if it is a subdomain of another domain we have a zone for.
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
existing_rdns = query_dns(ipaddr_rev, "PTR")
if existing_rdns == domain:
env['out'].print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
else:
env['out'].print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
# Check the TLSA record.
tlsa_qname = "_25._tcp." + domain
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
tlsa25_expected = build_tlsa_record(env)
if tlsa25 == tlsa25_expected:
env['out'].print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
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."""
% (tlsa_qname, tlsa25, tlsa25_expected))
# Check that the hostmaster@ email address exists.
check_alias_exists("hostmaster@" + domain, env)
def check_alias_exists(alias, env):
mail_alises = dict(get_mail_aliases(env))
if alias in mail_alises:
env['out'].print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias]))
else:
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):
# 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.
existing_ns = query_dns(domain, "NS")
correct_ns = "ns1.BOX; ns2.BOX".replace("BOX", 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
control panel to set the nameservers to %s."""
% (existing_ns, correct_ns) )
# 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.
ds_correct = open('/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds').read().strip().split("\n")
digests = { }
for rr_ds in ds_correct:
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
digests[ds_digalg] = ds_digest
# 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'))
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.")
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.
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
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("Digest Type: 2 / SHA-256")
env['out'].print_line("Digest: " + digests['2'])
if ds_looks_valid and ds[3] != digests.get(ds[2]):
env['out'].print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
env['out'].print_line("Public Key: ")
env['out'].print_line(dnsssec_pubkey, monospace=True)
env['out'].print_line("")
env['out'].print_line("Bulk/Record Format:")
env['out'].print_line("" + ds_correct[0])
env['out'].print_line("")
def check_mail_domain(domain, env):
# Check the MX record.
mx = query_dns(domain, "MX", nxdomain=None)
expected_mx = "10 " + env['PRIMARY_HOSTNAME']
if mx == expected_mx:
env['out'].print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx))
elif mx == None:
# A missing MX record is okay on the primary hostname because
# the primary hostname's A record (the MX fallback) is... itself,
# which is what we want the MX to be.
if domain == env['PRIMARY_HOSTNAME']:
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
# And a missing MX record is okay on other domains if the A record
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
# probably confuse DANE TLSA, but we'll let that slide for now.
else:
domain_a = query_dns(domain, "A", nxdomain=None)
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
if domain_a != None and domain_a == primary_a:
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
else:
env['out'].print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a
change. This problem may result from other issues listed here.""" % (expected_mx,))
else:
env['out'].print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
other issues listed here.""" % (mx, expected_mx))
# Check that the postmaster@ email address exists.
check_alias_exists("postmaster@" + domain, env)
# Stop if the domain is listed in the Spamhaus Domain Block List.
# The user might have chosen a domain that was previously in use by a spammer
# and will not be able to reliably send mail.
if not query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None):
env['out'].print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
else:
env['out'].print_error("""This domain is listed in the Spamhaus Domain Block List, which may prevent recipients from receiving your mail.
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % domain)
def check_web_domain(domain, env):
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
# other domains, it is required to access its website.
if domain != env['PRIMARY_HOSTNAME']:
ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']:
env['out'].print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP']))
else:
env['out'].print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
# user will log in with IMAP or webmail. Any other domain we serve a
# website for also needs a signed certificate.
check_ssl_cert(domain, env)
def query_dns(qname, rtype, nxdomain='[Not Set]'):
resolver = dns.resolver.get_default_resolver()
try:
response = dns.resolver.query(qname, rtype)
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# Host did not have an answer for this query; not sure what the
# difference is between the two exceptions.
return nxdomain
# There may be multiple answers; concatenate the response. Remove trailing
# periods from responses since that's how qnames are encoded in DNS but is
# confusing for us. The order of the answers doesn't matter, so sort so we
# can compare to a well known order.
return "; ".join(sorted(str(r).rstrip('.') for r in response))
def check_ssl_cert(domain, env):
# Check that SSL certificate is signed.
# Skip the check if the A record is not pointed here.
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
# Where is the SSL stored?
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
env['out'].print_error("The SSL certificate file for this domain is missing.")
return
# Check that the certificate is good.
cert_status = check_certificate(domain, ssl_certificate, ssl_key)
if cert_status == "SELF-SIGNED":
fingerprint = shell('check_output', [
"openssl",
"x509",
"-in", ssl_certificate,
"-noout",
"-fingerprint"
])
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
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:""")
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
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.")
else:
env['out'].print_error("The SSL certificate has a problem:")
env['out'].print_line("")
env['out'].print_line(cert_status)
env['out'].print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key):
# Use openssl verify to check the status of a certificate.
# First check that the certificate is for the right domain. The domain
# must be found in the Subject Common Name (CN) or be one of the
# Subject Alternative Names.
cert_dump = shell('check_output', [
"openssl", "x509",
"-in", ssl_certificate,
"-noout", "-text", "-nameopt", "rfc2253",
])
cert_dump = cert_dump.split("\n")
certificate_names = set()
while len(cert_dump) > 0:
line = cert_dump.pop(0)
# Grab from the Subject Common Name. We include the indentation
# at the start of the line in case maybe the cert includes the
# common name of some other referenced entity (which would be
# indented, I hope).
m = re.match(" Subject: CN=([^,]+)", line)
if m:
certificate_names.add(m.group(1))
# Grab from the Subject Alternative Name, which is a comma-delim
# list of names, like DNS:mydomain.com, DNS:otherdomain.com.
m = re.match(" X509v3 Subject Alternative Name:", line)
if m:
names = re.split(",\s*", cert_dump.pop(0).strip())
for n in names:
m = re.match("DNS:(.*)", n)
if m:
certificate_names.add(m.group(1))
if domain is not None and domain not in certificate_names:
return "This certificate is for the wrong domain names. It is for %s." % \
", ".join(sorted(certificate_names))
# 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
# of each command looks like "Modulus=XXXXX".
if ssl_private_key is not None:
private_key_modulus = shell('check_output', [
"openssl", "rsa",
"-inform", "PEM",
"-noout", "-modulus",
"-in", ssl_private_key])
cert_key_modulus = shell('check_output', [
"openssl", "x509",
"-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)
# 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
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
# expired.
# In order to verify with openssl, we need to split out any
# intermediary certificates in the chain (if any) from our
# certificate (at the top). They need to be passed separately.
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."
mycert, chaincerts = m.groups()
# This command returns a non-zero exit status in most cases, so trap errors.
retcode, verifyoutput = shell('check_output', [
"openssl",
"verify", "-verbose",
"-purpose", "sslserver", "-policy_check",]
+ ([] if chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"])
+ [ssl_certificate],
input=chaincerts.encode('ascii'),
trap=True)
if "self signed" in verifyoutput:
# Certificate is self-signed.
return "SELF-SIGNED"
elif retcode == 0:
# Certificate is OK.
return "OK"
else:
return verifyoutput.strip()
try:
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
except:
terminal_columns = 76
class ConsoleOutput:
def add_heading(self, heading):
print()
print(heading)
print("=" * len(heading))
def print_ok(self, message):
self.print_block(message, first_line="")
def print_error(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)
words = re.split("(\s+)", message)
linelen = 0
for w in words:
if linelen + len(w) > terminal_columns-1-len(first_line):
print()
print(" ", end="")
linelen = 0
if linelen == 0 and w.strip() == "": continue
print(w, end="")
linelen += len(w)
print()
def print_line(self, message, monospace=False):
for line in message.split("\n"):
self.print_block(line)
if __name__ == "__main__":
import sys
from utils import load_environment
env = load_environment()
if len(sys.argv) == 1:
run_checks(env, ConsoleOutput())
elif sys.argv[1] == "--check-primary-hostname":
# See if the primary hostname appears resolvable and has a signed certificate.
domain = env['PRIMARY_HOSTNAME']
if query_dns(domain, "A") != env['PUBLIC_IP']:
sys.exit(1)
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)
if cert_status != "OK":
sys.exit(1)
sys.exit(0)

54
ppa/Makefile Executable file
View File

@@ -0,0 +1,54 @@
all: clean build_postgrey build_dovecot_lucene
clean:
# Clean.
rm -rf /tmp/build
# Prepare to build source packages.
mkdir -p /tmp/build
build_postgrey:
# Download our fork of the Debian postgrey package.
git clone https://github.com/mail-in-a-box/postgrey /tmp/build/postgrey
# Download the corresponding upstream package.
wget -O /tmp/build/postgrey_1.35.orig.tar.gz http://postgrey.schweikert.ch/pub/postgrey-1.35.tar.gz
# Build the source package.
(cd /tmp/build/postgrey; dpkg-buildpackage -S -us -uc -nc)
# Sign the packages.
debsign /tmp/build/postgrey_1.35-1miab1_source.changes
# Upload to PPA.
dput ppa:mail-in-a-box/ppa /tmp/build/postgrey_1.35-1miab1_source.changes
# Clear the intermediate files.
rm -rf /tmp/build/postgrey
# TESTING BINARY PACKAGE
#sudo apt-get build-dep -y postgrey
#(cd /tmp/build/postgrey; dpkg-buildpackage -us -uc -nc)
build_dovecot_lucene:
# Get the upstream source.
(cd /tmp/build; apt-get source dovecot)
# Patch it so that we build dovecot-lucene (and nothing else).
patch -p1 -d /tmp/build/dovecot-2.2.9 < dovecot_lucene.diff
# Build the source package.
(cd /tmp/build/dovecot-2.2.9; dpkg-buildpackage -S -us -uc -nc)
# Sign the packages.
#debsign /tmp/build/dovecot_2.2.9-1ubuntu2.1_amd64.changes
# Upload it.
#dput ppa:mail-in-a-box/ppa /tmp/build/dovecot_2.2.9-1ubuntu2.1_amd64.changes
# TESTING BINARY PACKAGE
# Install build dependencies and build dependencies we've added in our patch,
# and then build the binary package.
#sudo apt-get build-dep -y dovecot
#sudo apt-get install libclucene-dev liblzma-dev libexttextcat-dev libstemmer-dev
#(cd /tmp/build/dovecot-2.2.9; dpkg-buildpackage -us -uc -nc)

38
ppa/README.md Normal file
View File

@@ -0,0 +1,38 @@
ppa instructions
================
Mail-in-a-Box maintains a Launchpad.net PPA ([Mail-in-a-Box PPA](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa)) for additional deb's that we want to have installed on systems.
Packages
--------
* [postgrey](https://github.com/mail-in-a-box/postgrey), with a modification to whitelist senders that are whitelisted by [dnswl.org](https://www.dnswl.org/) (i.e. don't greylist mail from them).
Building
--------
To rebuild the packages in the PPA, you'll need to be @JoshData.
First:
* You should have an account on Launchpad.net.
* Your account should have your GPG key set (to the fingerprint of a GPG key on your system matching the identity at the top of the debian/changelog files).
* You should have write permission to the PPA.
To build:
# Start a clean VM.
vagrant up
# Put your signing keys (on the host machine) into the VM (so it can sign the debs).
gpg --export-secret-keys | vagrant ssh -- gpg --import
# Build & upload to launchpad.
vagrant ssh -- "cd /vagrant && make"
To use on a Mail-in-a-Box box, add the PPA and then upgrade packages:
apt-add-repository ppa:mail-in-a-box/ppa
apt-get update
apt-get upgrade

12
ppa/Vagrantfile vendored Normal file
View File

@@ -0,0 +1,12 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu14.04"
config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/trusty/current/trusty-server-cloudimg-amd64-vagrant-disk1.box"
config.vm.provision :shell, :inline => <<-SH
sudo apt-get update
sudo apt-get install -y git dpkg-dev devscripts dput
SH
end

294
ppa/dovecot_lucene.diff Normal file
View File

@@ -0,0 +1,294 @@
--- a/debian/control
+++ b/debian/control
@@ -1,210 +1,22 @@
Source: dovecot
Section: mail
Priority: optional
-Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
-XSBC-Original-Maintainer: Dovecot Maintainers <jaldhar-dovecot@debian.org>
-Uploaders: Jaldhar H. Vyas <jaldhar@debian.org>, Fabio Tranchitella <kobold@debian.org>, Joel Johnson <mrjoel@lixil.net>, Marco Nenciarini <mnencia@debian.org>
-Build-Depends: debhelper (>= 7.2.3~), dpkg-dev (>= 1.16.1), pkg-config, libssl-dev, libpam0g-dev, libldap2-dev, libpq-dev, libmysqlclient-dev, libsqlite3-dev, libsasl2-dev, zlib1g-dev, libkrb5-dev, drac-dev (>= 1.12-5), libbz2-dev, libdb-dev, libcurl4-gnutls-dev, libexpat-dev, libwrap0-dev, dh-systemd, po-debconf, lsb-release, hardening-wrapper, dh-autoreconf, autotools-dev
+Maintainer: Joshua Tauberer <jt@occams.info>
+XSBC-Original-Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
+Build-Depends: debhelper (>= 7.2.3~), dpkg-dev (>= 1.16.1), pkg-config, libssl-dev, libpam0g-dev, libldap2-dev, libpq-dev, libmysqlclient-dev, libsqlite3-dev, libsasl2-dev, zlib1g-dev, libkrb5-dev, drac-dev (>= 1.12-5), libbz2-dev, libdb-dev, libcurl4-gnutls-dev, libexpat-dev, libwrap0-dev, dh-systemd, po-debconf, lsb-release, libclucene-dev (>= 2.3), liblzma-dev, libexttextcat-dev, libstemmer-dev, hardening-wrapper, dh-autoreconf, autotools-dev
Standards-Version: 3.9.4
Homepage: http://dovecot.org/
Vcs-Git: git://git.debian.org/git/collab-maint/dovecot.git
Vcs-Browser: http://git.debian.org/?p=collab-maint/dovecot.git
-Package: dovecot-core
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-runtime (>= 0.76-13.1), openssl, adduser, ucf (>= 2.0020), ssl-cert (>= 1.0-11ubuntu1), lsb-base (>= 3.2-12ubuntu3)
-Suggests: ntp, dovecot-gssapi, dovecot-sieve, dovecot-pgsql, dovecot-mysql, dovecot-sqlite, dovecot-ldap, dovecot-imapd, dovecot-pop3d, dovecot-lmtpd, dovecot-managesieved, dovecot-solr, ufw
-Recommends: ntpdate
-Provides: dovecot-common
-Replaces: dovecot-common (<< 1:2.0.14-2~), mailavenger (<< 0.8.1-4)
-Breaks: dovecot-common (<< 1:2.0.14-2~), mailavenger (<< 0.8.1-4)
-Description: secure POP3/IMAP server - core files
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains the Dovecot main server and its command line utility.
-
-Package: dovecot-dev
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
-Replaces: dovecot-common (<< 1:2.0.14-2~)
-Breaks: dovecot-common (<< 1:2.0.14-2~)
-Description: secure POP3/IMAP server - header files
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains header files needed to compile plugins for the Dovecot
- mail server.
-
-Package: dovecot-imapd
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
-Provides: imap-server
-Description: secure POP3/IMAP server - IMAP daemon
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains the Dovecot IMAP server.
-
-Package: dovecot-pop3d
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
-Provides: pop3-server
-Description: secure POP3/IMAP server - POP3 daemon
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains the Dovecot POP3 server.
-
-Package: dovecot-lmtpd
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
-Replaces: dovecot-common (<< 1:2.0.14-2~)
-Breaks: dovecot-common (<< 1:2.0.14-2~)
-Description: secure POP3/IMAP server - LMTP server
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains the Dovecot LMTP server.
-
-Package: dovecot-managesieved
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), dovecot-sieve (= ${binary:Version}), ucf (>= 2.0020)
-Replaces: dovecot-common (<< 1:2.0.14-2~)
-Breaks: dovecot-common (<< 1:2.0.14-2~)
-Description: secure POP3/IMAP server - ManageSieve server
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains the Dovecot ManageSieve server.
-
-Package: dovecot-pgsql
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
-Description: secure POP3/IMAP server - PostgreSQL support
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package provides PostgreSQL support for Dovecot.
-
-Package: dovecot-mysql
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
-Description: secure POP3/IMAP server - MySQL support
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package provides MySQL support for Dovecot.
-
-Package: dovecot-sqlite
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
-Description: secure POP3/IMAP server - SQLite support
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package provides SQLite support for Dovecot.
-
-Package: dovecot-ldap
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
-Description: secure POP3/IMAP server - LDAP support
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package provides LDAP support for Dovecot.
-
-Package: dovecot-gssapi
+Package: dovecot-lucene
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
-Description: secure POP3/IMAP server - GSSAPI support
+Description: secure POP3/IMAP server - Lucene support
Dovecot is a mail server whose major goals are security and extreme
reliability. It tries very hard to handle all error conditions and verify
that all data is valid, making it nearly impossible to crash. It supports
mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
fast, extensible, and portable.
.
- This package provides GSSAPI authentication support for Dovecot.
-
-Package: dovecot-sieve
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
-Description: secure POP3/IMAP server - Sieve filters support
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package provides Sieve filters support for Dovecot.
-
-Package: dovecot-solr
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
-Description: secure POP3/IMAP server - Solr support
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package provides Solr full text search support for Dovecot.
-
-Package: dovecot-dbg
-Section: debug
-Priority: extra
-Architecture: any
-Depends: ${misc:Depends}, dovecot-core (= ${binary:Version})
-Description: secure POP3/IMAP server - debug symbols
- Dovecot is a mail server whose major goals are security and extreme
- reliability. It tries very hard to handle all error conditions and verify
- that all data is valid, making it nearly impossible to crash. It supports
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
- fast, extensible, and portable.
- .
- This package contains debug symbols for Dovecot.
-
-Package: mail-stack-delivery
-Architecture: all
-Depends: dovecot-core, dovecot-imapd, dovecot-pop3d, dovecot-managesieved,
- postfix, ${misc:Depends}
-Replaces: dovecot-postfix (<< 1:1.2.12-0ubuntu1~)
-Description: mail server delivery agent stack provided by Ubuntu server team
- Ubuntu's mail stack provides fully operational delivery with
- safe defaults and additional options. Out of the box it supports IMAP,
- POP3 and SMTP services with SASL authentication and Maildir as default
- storage engine.
- .
- This package contains configuration files for dovecot.
- .
- This package modifies postfix's configuration to integrate with dovecot
+ This package provides Lucene full text search support for Dovecot.
diff --git a/debian/dovecot-lucene.links b/debian/dovecot-lucene.links
new file mode 100644
index 0000000..6ffcbeb
--- /dev/null
+++ b/debian/dovecot-lucene.links
@@ -0,0 +1 @@
+/usr/share/bug/dovecot-core /usr/share/bug/dovecot-lucene
diff --git a/debian/dovecot-lucene.lintian-overrides b/debian/dovecot-lucene.lintian-overrides
new file mode 100644
index 0000000..60d90fd
--- /dev/null
+++ b/debian/dovecot-lucene.lintian-overrides
@@ -0,0 +1,2 @@
+dovecot-lucene: hardening-no-fortify-functions usr/lib/dovecot/modules/lib21_fts_lucene_plugin.so
+
diff --git a/debian/dovecot-lucene.substvars b/debian/dovecot-lucene.substvars
new file mode 100644
index 0000000..ed54f36
--- /dev/null
+++ b/debian/dovecot-lucene.substvars
@@ -0,0 +1,2 @@
+shlibs:Depends=libc6 (>= 2.4), libclucene-core1 (>= 2.3.3.4), libgcc1 (>= 1:4.1.1), libstdc++6 (>= 4.1.1), libstemmer0d (>= 0+svn527)
+misc:Depends=
diff --git a/debian/dovecot-lucene.triggers b/debian/dovecot-lucene.triggers
new file mode 100644
index 0000000..3d933a5
--- /dev/null
+++ b/debian/dovecot-lucene.triggers
@@ -0,0 +1 @@
+activate register-dovecot-plugin
diff --git a/debian/rules b/debian/rules
index dcee2f6..9533a4a 100755
--- a/debian/rules
+++ b/debian/rules
@@ -40,6 +40,7 @@ config-stamp: configure
--with-solr \
--with-ioloop=best \
--with-libwrap \
+ --with-lucene \
--host=$(DEB_HOST_GNU_TYPE) \
--build=$(DEB_BUILD_GNU_TYPE) \
--prefix=/usr \
@@ -95,6 +96,10 @@ install: build
dh_testroot
dh_clean -k
dh_installdirs
+ mkdir -p $(CURDIR)/debian/dovecot-lucene/usr/lib/dovecot/modules
+ mv $(CURDIR)/src/plugins/fts-lucene/.libs/* $(CURDIR)/debian/dovecot-lucene/usr/lib/dovecot/modules/
+
+rest_disabled_by_miab:
$(MAKE) install DESTDIR=$(CURDIR)/debian/dovecot-core
$(MAKE) -C $(PIGEONHOLE_DIR) install DESTDIR=$(CURDIR)/debian/dovecot-core
rm `find $(CURDIR)/debian -name '*.la'`
@@ -209,7 +214,7 @@ binary-arch: build install
dh_installdocs -a
dh_installexamples -a
dh_installpam -a
- mv $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot-core $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot
+ # mv $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot-core $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot
dh_systemd_enable
dh_installinit -pdovecot-core --name=dovecot
dh_systemd_start
@@ -220,10 +225,10 @@ binary-arch: build install
dh_lintian -a
dh_installchangelogs -a ChangeLog
dh_link -a
- dh_strip -a --dbg-package=dovecot-dbg
+ #dh_strip -a --dbg-package=dovecot-dbg
dh_compress -a
dh_fixperms -a
- chmod 0700 debian/dovecot-core/etc/dovecot/private
+ #chmod 0700 debian/dovecot-core/etc/dovecot/private
dh_makeshlibs -a -n
dh_installdeb -a
dh_shlibdeps -a

95
security.md Normal file
View File

@@ -0,0 +1,95 @@
Mail-in-a-Box Security Guide
============================
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
This page documents the security features of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
Threat Model
------------
Nothing is perfectly secure, and an adversary with sufficient resources can always penetrate a system.
The primary goal of Mail-in-a-Box is to make deploying a good mail server easy, so we balance ― as everyone does ― privacy and security concerns with the practicality of actually deploying the system. That means we make certain assumptions about adversaries. We assume that adversaries . . .
* Do not have physical access to the box (i.e., we do not aim to protect the box from physical access).
* Have not been given Unix accounts on the box (i.e., we assume all users with shell access are trusted).
On the other hand, we do assume that adversaries are performing passive surveillance and, possibly, active man-in-the-middle attacks. And so:
* User credentials are always sent through SSH/TLS, never in the clear.
* Outbound mail is sent with the highest level of TLS possible (more on that below).
User Credentials
----------------
The box's administrator and its (non-administrative) mail users must sometimes communicate their credentials to the box.
### Services behind TLS
These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security):
* SMTP Submission (port 587). Mail users submit outbound mail through SMTP with STARTTLS on port 587.
* IMAP/POP (ports 993, 995). Mail users check for incoming mail through IMAP or POP over TLS.
* HTTPS (port 443). Webmail, the Exchange/ActiveSync protocol, the administrative control panel, and any static hosted websites are accessed over HTTPS.
The services all follow these rules:
* SSL certificates are generated with 2048-bit RSA keys and SHA-256 fingerprints. The box provides a self-signed certificate by default. The [setup guide](https://mailinabox.email/guide.html) explains how to verify the certificate fingerprint on first login. Users are encouraged to replace the certificate with a proper CA-signed one. ([source](setup/ssl.sh))
* Only TLSv1, TLSv1.1 and TLSv1.2 are offered (the older SSL protocols are not offered).
* Export-grade ciphers, the anonymous DH/ECDH algorithms (aNULL), and clear-text ciphers (eNULL) are not offered.
* The minimum cipher key length offered is 112 bits. The maximum is 256 bits. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy.
Additionally:
* SMTP Submission (port 587) will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))
* HTTPS (port 443): The HTTPS Strict Transport Security header is set. A redirect from HTTP to HTTPS is offered. The [Qualys SSL Labs test](https://www.ssllabs.com/ssltest) should report an A+ grade. ([source 1](conf/nginx-ssl.conf), [source 2](conf/nginx.conf))
For more details, see the [output of SSLyze for these ports](tests/tls_results.txt).
The cipher and protocol selection are chosen to support the following clients:
* For HTTPS: Firefox 1, Chrome 1, IE 7, Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7.
* For other protocols: TBD.
### Password Storage
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py))
### Console access
Console access (e.g. via SSH) is configured by the system image used to create the box, typically from by a cloud virtual machine provider (e.g. Digital Ocean). Mail-in-a-Box does not set any console access settings, although it will warn the administrator in the System Status Checks if password-based login is turned on.
The [setup guide video](https://mailinabox.email/) explains how to verify the host key fingerprint on first login.
If DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that the box automatically puts into DNS can also be used to verify the host key fingerprint by setting `VerifyHostKeyDNS yes` in your `ssh/.config` file or by logging in with `ssh -o VerifyHostKeyDNS=yes`. ([source](management/dns_update.py))
Outbound Mail
-------------
### Domain Policy Records
Domain policy records allow recipient MTAs to detect when the _domain_ part of incoming mail has been spoofed. All outbound mail is signed with [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) and "quarantine" [DMARC](https://en.wikipedia.org/wiki/DMARC) records are automatically set in DNS. Receiving MTAs that implement DMARC will automatically quarantine mail that is "From:" a domain hosted by the box but which was not sent by the box. (Strong [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) records are also automatically set in DNS.) ([source](management/dns_update.py))
### Encryption
The basic protocols of email delivery did not plan for the need for encryption. For a number of reasons it is not possible in most cases to guarantee that a connection to a recipient server is secure. However, the box --- along with the vast majority of mail servers --- uses [opportunistic encryption](https://en.wikipedia.org/wiki/Opportunistic_encryption), meaning the mail is encrypted in transit and protected from passive eavesdropping, but it is not protected from an active man-in-the-middle attack. Modern encryption settings will be used to the extent the recipient server supports them. ([source](setup/mail-postfix.sh))
### DANE
The box is [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC)-aware (via a locally running DNSSEC-aware nameserver). When sending outbound mail, if the recipient's domain name supports DNSSEC and has published a [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) record, which contains a certificate fingerprint, the receiving MTA (server) must support TLS and its certificate must match the fingerprint. In other words, when a DANE TLSA record is published by the recipient, then on-the-wire encryption is forced between the box and the recipient MTA. ([source](setup/mail-postfix.sh))
Incoming Mail
-------------
### Encryption
As discussed above, there is no way to require on-the-wire encryption of mail. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to SSLv3 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for forward secrecy, however. ([source](setup/mail-postfix.sh))
### DANE
When DNSSEC is enabled at the box's domain name's registrar, [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) records are automatically published in DNS. Senders supporting DANE will enforce encryption on-the-wire between them and the box --- see the section on DANE for outgoing mail above. ([source](management/dns_update.py))
### Filters
Incoming mail is run through several filters. Email is bounced if the sender's IP address is listed in the [Spamhaus Zen blacklist](http://www.spamhaus.org/zen/) or if the sender's domain is listed in the [Spamhaus Domain Block List](http://www.spamhaus.org/dbl/). Greylisting (with [postgrey](http://postgrey.schweikert.ch/)) is also used to cut down on spam. ([source](setup/mail-postfix.sh))

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

@@ -2,39 +2,50 @@
######################################################### #########################################################
# This script is intended to be run like this: # This script is intended to be run like this:
# #
# wget https://raw.githubusercontent.com/mail-in-a-box/mailinabox/master/setup/bootstrap.sh # curl https://.../bootstrap.sh | sudo bash
# sudo bash bootstrap.sh
#
# We can't pipe directly to bash because setup/start.sh
# asks for user input on stdin.
# #
######################################################### #########################################################
if [ -z "$TAG" ]; then
TAG=v0.10
fi
# Are we running as root? # Are we running as root?
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root. Did you leave out sudo?" echo "This script must be run as root. Did you leave out sudo?"
exit exit
fi fi
# Go to root's home directory.
cd
# Clone the Mail-in-a-Box repository if it doesn't exist. # Clone the Mail-in-a-Box repository if it doesn't exist.
if [ ! -d mailinabox ]; then if [ ! -d $HOME/mailinabox ]; then
echo Downloading Mail-in-a-Box . . . echo Installing git . . .
apt-get -q -q install -y git DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
git clone -q --depth 1 -b master https://github.com/mail-in-a-box/mailinabox echo
cd mailinabox
# If it does exist, update it. echo Downloading Mail-in-a-Box $TAG. . .
else git clone \
echo Updating Mail-in-a-Box . . . -b $TAG --depth 1 \
cd mailinabox https://github.com/mail-in-a-box/mailinabox \
if ! git pull -q --ff-only; then $HOME/mailinabox \
< /dev/null 2> /dev/null
echo
fi
# Change directory to it.
cd $HOME/mailinabox
# Update it.
if [ "$TAG" != `git describe` ]; then
echo Updating Mail-in-a-Box to $TAG . . .
git fetch --depth 1 --force --prune origin tag $TAG
if ! git checkout -q $TAG; then
echo "Update failed. Did you modify something in `pwd`?" echo "Update failed. Did you modify something in `pwd`?"
exit exit
fi fi
echo
fi fi
# Start setup script. # Start setup script.
setup/start.sh setup/start.sh

253
setup/csr_country_codes.tsv Normal file
View File

@@ -0,0 +1,253 @@
# This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.
# The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.
# The top 20 countries by number of Internet users are grouped first, see
# https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.
BR Brazil
CA Canada
CN China
EG Egypt
FR France
DE Germany
IN India
ID Indonesia
IT Italy
JP Japan
MX Mexico
NG Nigeria
PH Philippines
RU Russian Federation Russia
ES Spain
KR South Korea
TR Turkey
GB United Kingdom
US United States
VN Vietnam
AD Andorra
AE United Arab Emirates
AF Afghanistan
AG Antigua and Barbuda
AI Anguilla
AL Albania
AM Armenia
AO Angola
AQ Antarctica
AR Argentina
AS American Samoa
AT Austria
AU Australia
AW Aruba
AX Åland Islands
AZ Azerbaijan
BA Bosnia and Herzegovina
BB Barbados
BD Bangladesh
BE Belgium
BF Burkina Faso
BG Bulgaria
BH Bahrain
BI Burundi
BJ Benin
BL Saint Barthélemy
BM Bermuda
BN Brunei
BO Bolivia
BQ Bonaire, Sint Eustatius and Saba Caribbean Netherlands
BS Bahamas The Bahamas
BT Bhutan
BV Bouvet Island
BW Botswana
BY Belarus
BZ Belize
CC Cocos (Keeling) Islands
CD Congo, the Democratic Republic of the Democratic Republic of the Congo
CF Central African Republic
CG Congo Republic of the Congo
CH Switzerland
CI Côte d'Ivoire
CK Cook Islands
CL Chile
CM Cameroon
CO Colombia
CR Costa Rica
CU Cuba
CV Cabo Verde
CW Curaçao
CX Christmas Island
CY Cyprus
CZ Czech Republic
DJ Djibouti
DK Denmark
DM Dominica
DO Dominican Republic
DZ Algeria
EC Ecuador
EE Estonia
EH Western Sahara
ER Eritrea
ET Ethiopia
FI Finland
FJ Fiji
FK Falkland Islands (Malvinas) Falkland Islands
FM Federated States of Micronesia
FO Faroe Islands
GA Gabon
GD Grenada
GE Georgia Georgia (country)
GF French Guiana
GG Guernsey
GH Ghana
GI Gibraltar
GL Greenland
GM Gambia The Gambia
GN Guinea
GP Guadeloupe
GQ Equatorial Guinea
GR Greece
GS South Georgia and the South Sandwich Islands
GT Guatemala
GU Guam
GW Guinea-Bissau
GY Guyana
HK Hong Kong
HM Heard Island and McDonald Islands
HN Honduras
HR Croatia
HT Haiti
HU Hungary
IE Ireland Republic of Ireland
IL Israel
IM Isle of Man
IO British Indian Ocean Territory
IQ Iraq
IR Iran
IS Iceland
JE Jersey
JM Jamaica
JO Jordan
KE Kenya
KG Kyrgyzstan
KH Cambodia
KI Kiribati
KM Comoros
KN Saint Kitts and Nevis
KP North Korea
KW Kuwait
KY Cayman Islands
KZ Kazakhstan
LA Laos
LB Lebanon
LC Saint Lucia
LI Liechtenstein
LK Sri Lanka
LR Liberia
LS Lesotho
LT Lithuania
LU Luxembourg
LV Latvia
LY Libya
MA Morocco
MC Monaco
MD Moldova
ME Montenegro
MF Saint Martin (French part) Collectivity of Saint Martin
MG Madagascar
MH Marshall Islands
MK Macedonia Republic of Macedonia
ML Mali
MM Myanmar
MN Mongolia
MO Macao Macau
MP Northern Mariana Islands
MQ Martinique
MR Mauritania
MS Montserrat
MT Malta
MU Mauritius
MV Maldives
MW Malawi
MY Malaysia
MZ Mozambique
NA Namibia
NC New Caledonia
NE Niger
NF Norfolk Island
NI Nicaragua
NL Netherlands
NO Norway
NP Nepal
NR Nauru
NU Niue
NZ New Zealand
OM Oman
PA Panama
PE Peru
PF French Polynesia
PG Papua New Guinea
PK Pakistan
PL Poland
PM Saint Pierre and Miquelon
PN Pitcairn Pitcairn Islands
PR Puerto Rico
PS Palestine State of Palestine
PT Portugal
PW Palau
PY Paraguay
QA Qatar
RE Réunion
RO Romania
RS Serbia
RW Rwanda
SA Saudi Arabia
SB Solomon Islands
SC Seychelles
SD Sudan
SE Sweden
SG Singapore
SH Saint Helena, Ascension and Tristan da Cunha
SI Slovenia
SJ Svalbard and Jan Mayen
SK Slovakia
SL Sierra Leone
SM San Marino
SN Senegal
SO Somalia
SR Suriname
SS South Sudan
ST Sao Tome and Principe
SV El Salvador
SX Sint Maarten (Dutch part) Sint Maarten
SY Syria
SZ Swaziland
TC Turks and Caicos Islands
TD Chad
TF French Southern Territories French Southern and Antarctic Lands
TG Togo
TH Thailand
TJ Tajikistan
TK Tokelau
TL Timor-Leste East Timor
TM Turkmenistan
TN Tunisia
TO Tonga
TT Trinidad and Tobago
TV Tuvalu
TW Taiwan
TZ Tanzania
UA Ukraine
UG Uganda
UM United States Minor Outlying Islands
UY Uruguay
UZ Uzbekistan
VA Vatican City
VC Saint Vincent and the Grenadines
VE Venezuela
VG Virgin Islands, British British Virgin Islands
VI Virgin Islands, U.S. United States Virgin Islands
VU Vanuatu
WF Wallis and Futuna
WS Samoa
YE Yemen
YT Mayotte
ZA South Africa
ZM Zambia
ZW Zimbabwe
1 # This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.
2 # The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.
3 # The top 20 countries by number of Internet users are grouped first, see
4 # https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.
5 BR Brazil
6 CA Canada
7 CN China
8 EG Egypt
9 FR France
10 DE Germany
11 IN India
12 ID Indonesia
13 IT Italy
14 JP Japan
15 MX Mexico
16 NG Nigeria
17 PH Philippines
18 RU Russian Federation Russia
19 ES Spain
20 KR South Korea
21 TR Turkey
22 GB United Kingdom
23 US United States
24 VN Vietnam
25 AD Andorra
26 AE United Arab Emirates
27 AF Afghanistan
28 AG Antigua and Barbuda
29 AI Anguilla
30 AL Albania
31 AM Armenia
32 AO Angola
33 AQ Antarctica
34 AR Argentina
35 AS American Samoa
36 AT Austria
37 AU Australia
38 AW Aruba
39 AX Åland Islands
40 AZ Azerbaijan
41 BA Bosnia and Herzegovina
42 BB Barbados
43 BD Bangladesh
44 BE Belgium
45 BF Burkina Faso
46 BG Bulgaria
47 BH Bahrain
48 BI Burundi
49 BJ Benin
50 BL Saint Barthélemy
51 BM Bermuda
52 BN Brunei
53 BO Bolivia
54 BQ Bonaire, Sint Eustatius and Saba Caribbean Netherlands
55 BS Bahamas The Bahamas
56 BT Bhutan
57 BV Bouvet Island
58 BW Botswana
59 BY Belarus
60 BZ Belize
61 CC Cocos (Keeling) Islands
62 CD Congo, the Democratic Republic of the Democratic Republic of the Congo
63 CF Central African Republic
64 CG Congo Republic of the Congo
65 CH Switzerland
66 CI Côte d'Ivoire
67 CK Cook Islands
68 CL Chile
69 CM Cameroon
70 CO Colombia
71 CR Costa Rica
72 CU Cuba
73 CV Cabo Verde
74 CW Curaçao
75 CX Christmas Island
76 CY Cyprus
77 CZ Czech Republic
78 DJ Djibouti
79 DK Denmark
80 DM Dominica
81 DO Dominican Republic
82 DZ Algeria
83 EC Ecuador
84 EE Estonia
85 EH Western Sahara
86 ER Eritrea
87 ET Ethiopia
88 FI Finland
89 FJ Fiji
90 FK Falkland Islands (Malvinas) Falkland Islands
91 FM Federated States of Micronesia
92 FO Faroe Islands
93 GA Gabon
94 GD Grenada
95 GE Georgia Georgia (country)
96 GF French Guiana
97 GG Guernsey
98 GH Ghana
99 GI Gibraltar
100 GL Greenland
101 GM Gambia The Gambia
102 GN Guinea
103 GP Guadeloupe
104 GQ Equatorial Guinea
105 GR Greece
106 GS South Georgia and the South Sandwich Islands
107 GT Guatemala
108 GU Guam
109 GW Guinea-Bissau
110 GY Guyana
111 HK Hong Kong
112 HM Heard Island and McDonald Islands
113 HN Honduras
114 HR Croatia
115 HT Haiti
116 HU Hungary
117 IE Ireland Republic of Ireland
118 IL Israel
119 IM Isle of Man
120 IO British Indian Ocean Territory
121 IQ Iraq
122 IR Iran
123 IS Iceland
124 JE Jersey
125 JM Jamaica
126 JO Jordan
127 KE Kenya
128 KG Kyrgyzstan
129 KH Cambodia
130 KI Kiribati
131 KM Comoros
132 KN Saint Kitts and Nevis
133 KP North Korea
134 KW Kuwait
135 KY Cayman Islands
136 KZ Kazakhstan
137 LA Laos
138 LB Lebanon
139 LC Saint Lucia
140 LI Liechtenstein
141 LK Sri Lanka
142 LR Liberia
143 LS Lesotho
144 LT Lithuania
145 LU Luxembourg
146 LV Latvia
147 LY Libya
148 MA Morocco
149 MC Monaco
150 MD Moldova
151 ME Montenegro
152 MF Saint Martin (French part) Collectivity of Saint Martin
153 MG Madagascar
154 MH Marshall Islands
155 MK Macedonia Republic of Macedonia
156 ML Mali
157 MM Myanmar
158 MN Mongolia
159 MO Macao Macau
160 MP Northern Mariana Islands
161 MQ Martinique
162 MR Mauritania
163 MS Montserrat
164 MT Malta
165 MU Mauritius
166 MV Maldives
167 MW Malawi
168 MY Malaysia
169 MZ Mozambique
170 NA Namibia
171 NC New Caledonia
172 NE Niger
173 NF Norfolk Island
174 NI Nicaragua
175 NL Netherlands
176 NO Norway
177 NP Nepal
178 NR Nauru
179 NU Niue
180 NZ New Zealand
181 OM Oman
182 PA Panama
183 PE Peru
184 PF French Polynesia
185 PG Papua New Guinea
186 PK Pakistan
187 PL Poland
188 PM Saint Pierre and Miquelon
189 PN Pitcairn Pitcairn Islands
190 PR Puerto Rico
191 PS Palestine State of Palestine
192 PT Portugal
193 PW Palau
194 PY Paraguay
195 QA Qatar
196 RE Réunion
197 RO Romania
198 RS Serbia
199 RW Rwanda
200 SA Saudi Arabia
201 SB Solomon Islands
202 SC Seychelles
203 SD Sudan
204 SE Sweden
205 SG Singapore
206 SH Saint Helena, Ascension and Tristan da Cunha
207 SI Slovenia
208 SJ Svalbard and Jan Mayen
209 SK Slovakia
210 SL Sierra Leone
211 SM San Marino
212 SN Senegal
213 SO Somalia
214 SR Suriname
215 SS South Sudan
216 ST Sao Tome and Principe
217 SV El Salvador
218 SX Sint Maarten (Dutch part) Sint Maarten
219 SY Syria
220 SZ Swaziland
221 TC Turks and Caicos Islands
222 TD Chad
223 TF French Southern Territories French Southern and Antarctic Lands
224 TG Togo
225 TH Thailand
226 TJ Tajikistan
227 TK Tokelau
228 TL Timor-Leste East Timor
229 TM Turkmenistan
230 TN Tunisia
231 TO Tonga
232 TT Trinidad and Tobago
233 TV Tuvalu
234 TW Taiwan
235 TZ Tanzania
236 UA Ukraine
237 UG Uganda
238 UM United States Minor Outlying Islands
239 UY Uruguay
240 UZ Uzbekistan
241 VA Vatican City
242 VC Saint Vincent and the Grenadines
243 VE Venezuela
244 VG Virgin Islands, British British Virgin Islands
245 VI Virgin Islands, U.S. United States Virgin Islands
246 VU Vanuatu
247 WF Wallis and Futuna
248 WS Samoa
249 YE Yemen
250 YT Mayotte
251 ZA South Africa
252 ZM Zambia
253 ZW Zimbabwe

45
setup/dkim.sh Normal file → Executable file
View File

@@ -1,13 +1,16 @@
# OpenDKIM: Sign outgoing mail with DKIM #!/bin/bash
######################################## # OpenDKIM
# --------
# After this, you'll still need to run dns_update.sh to get the DKIM #
# signature in the DNS zones. # OpenDKIM provides a service that puts a DKIM signature on outbound mail.
#
# The DNS configuration for DKIM is done in the management daemon.
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install DKIM # Install DKIM...
apt_install opendkim opendkim-tools apt_install opendkim opendkim-tools opendmarc
# Make sure configuration directories exist. # Make sure configuration directories exist.
mkdir -p /etc/opendkim; mkdir -p /etc/opendkim;
@@ -18,9 +21,9 @@ mkdir -p $STORAGE_ROOT/mail/dkim
echo "127.0.0.1" > /etc/opendkim/TrustedHosts echo "127.0.0.1" > /etc/opendkim/TrustedHosts
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
true; # already done true # already done #NODOC
else else
# Add various configuration options to the end. # Add various configuration options to the end of `opendkim.conf`.
cat >> /etc/opendkim.conf << EOF; cat >> /etc/opendkim.conf << EOF;
MinimumKeyBits 1024 MinimumKeyBits 1024
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
@@ -32,7 +35,7 @@ RequireSafeKeys false
EOF EOF
fi fi
# Create a new DKIM key if we don't have one already. This creates # Create a new DKIM key. This creates
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former # mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
# is the actual private key and the latter is the suggested DNS TXT # is the actual private key and the latter is the suggested DNS TXT
# entry which we'll want to include in our DNS setup. # entry which we'll want to include in our DNS setup.
@@ -45,15 +48,29 @@ fi
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
chmod go-rwx $STORAGE_ROOT/mail/dkim chmod go-rwx $STORAGE_ROOT/mail/dkim
# Add OpenDKIM as a milter to postfix, which is how it intercepts outgoing tools/editconf.py /etc/opendmarc.conf -s \
# mail to perform the signing (by adding a mail header). "Syslog=true" \
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line. "Socket=inet:8893@[127.0.0.1]"
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
# intercepts outgoing mail to perform the signing (by adding a mail header)
# and how they both intercept incoming mail to add Authentication-Results
# headers. The order possibly/probably matters: OpenDMARC relies on the
# OpenDKIM Authentication-Results header already being present.
#
# Be careful. If we add other milters later, this needs to be concatenated
# on the smtpd_milters line.
#
# The OpenDMARC milter is skipped in the SMTP submission listener by
# configuring smtpd_milters there to only list the OpenDKIM milter
# (see mail-postfix.sh).
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtpd_milters=inet:127.0.0.1:8891 \ "smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\
non_smtpd_milters=\$smtpd_milters \ non_smtpd_milters=\$smtpd_milters \
milter_default_action=accept milter_default_action=accept
# Restart services. # Restart services.
restart_service opendkim restart_service opendkim
restart_service opendmarc
restart_service postfix restart_service postfix

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# DNS: Configure a DNS server using nsd # DNS
####################################### # -----------------------------------------------
# This script installs packages, but the DNS zone files are only # This script installs packages, but the DNS zone files are only
# created by the /dns/update API in the management server because # created by the /dns/update API in the management server because
@@ -8,62 +8,116 @@
# mail users & aliases created by the user later. # mail users & aliases created by the user later.
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install nsd, our DNS server software, and ldnsutils which helps # Install the packages.
# us sign zones for DNSSEC. #
# * nsd: The non-recursive nameserver that publishes our DNS records.
# * ldnsutils: Helper utilities for signing DNSSEC zones.
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
# ...but first, we have to create the user because the apt_install nsd ldnsutils openssh-client
# current Ubuntu forgets to do so in the .deb
# see issue #25 and https://bugs.launchpad.net/ubuntu/+source/nsd/+bug/1311886
if id nsd > /dev/null 2>&1; then
true; #echo "nsd user exists... good";
else
useradd nsd;
fi
# Okay now install the packages.
apt_install nsd ldnsutils
# Prepare nsd's configuration. # Prepare nsd's configuration.
sudo mkdir -p /var/run/nsd mkdir -p /var/run/nsd
cat > /etc/nsd/nsd.conf << EOF;
# No not edit. Overwritten by Mail-in-a-Box setup.
server:
hide-version: yes
# identify the server (CH TXT ID.SERVER entry).
identity: ""
# The directory for zonefile: files.
zonesdir: "/etc/nsd/zones"
# Allows NSD to bind to IP addresses that are not (yet) added to the
# network interface. This allows nsd to start even if the network stack
# isn't fully ready, which apparently happens in some cases.
# See https://www.nlnetlabs.nl/projects/nsd/nsd.conf.5.html.
ip-transparent: yes
EOF
# Since we have bind9 listening on localhost for locally-generated
# DNS queries that require a recursive nameserver, and the system
# might have other network interfaces for e.g. tunnelling, we have
# to be specific about the network interfaces that nsd binds to.
for ip in $PRIVATE_IP $PRIVATE_IPV6; do
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
done
echo "include: /etc/nsd/zones.conf" >> /etc/nsd/nsd.conf;
# Create DNSSEC signing keys. # Create DNSSEC signing keys.
mkdir -p "$STORAGE_ROOT/dns/dnssec"; mkdir -p "$STORAGE_ROOT/dns/dnssec";
if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
echo "Generating DNSSEC signing keys. This may take a few minutes..."
# Create the Key-Signing Key (KSK) (-k) which is the so-called # TLDs don't all support the same algorithms, so we'll generate keys using a few
# Secure Entry Point. Use a NSEC3-compatible algorithm (best # different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used
# practice), and a nice and long keylength. The domain name we # algorithm that supported NSEC3, which is a security best practice. However TLDs
# provide ("_domain_") doesn't matter -- we'll use the same # will probably be moving away from it to a a SHA256-based algorithm.
# keys for all our domains. #
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 2048 -k _domain_); # Supports `RSASHA1-NSEC3-SHA1` (didn't test with `RSASHA256`):
#
# * .info
# * .me
#
# Requires `RSASHA256`
#
# * .email
# * .guide
#
# Supports `RSASHA256` (and defaulting to this)
#
# * .fund
FIRST=1 #NODOC
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
if [ $FIRST == 1 ]; then
echo "Generating DNSSEC signing keys. This may take a few minutes..."
FIRST=0 #NODOC
fi
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
# matter -- we'll use the same keys for all our domains.
#
# `ldns-keygen` outputs the new key's filename to stdout, which
# we're capturing into the `KSK` variable.
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 2048 -k _domain_);
# Now create a Zone-Signing Key (ZSK) which is expected to be # Now create a Zone-Signing Key (ZSK) which is expected to be
# rotated more often than a KSK, although we have no plans to # rotated more often than a KSK, although we have no plans to
# rotate it (and doing so would be difficult to do without # rotate it (and doing so would be difficult to do without
# disturbing DNS availability.) Omit '-k' and use a shorter key. # disturbing DNS availability.) Omit `-k` and use a shorter key length.
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 _domain_); ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 1024 _domain_);
# These generate two sets of files like: # These generate two sets of files like:
# K_domain_.+007+08882.ds <- DS record for adding to NSD configuration files #
# 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.private <- private key (secret!) # * `K_domain_.+007+08882.key`: public key
# * `K_domain_.+007+08882.private`: private key (secret!)
# The filenames are unpredictable and encode the key generation # The filenames are unpredictable and encode the key generation
# options. So we'll store the names of the files we just generated. # options. So we'll store the names of the files we just generated.
# We might have multiple keys down the road. This will identify # We might have multiple keys down the road. This will identify
# what keys are the current keys. # what keys are the current keys.
cat > $STORAGE_ROOT/dns/dnssec/keys.conf << EOF; cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
KSK=$KSK KSK=$KSK
ZSK=$ZSK ZSK=$ZSK
EOF EOF
fi fi
# Force the dns_update script to be run every day to re-sign zones for DNSSEC. # And loop to do the next algorithm...
done
# Force the dns_update script to be run every day to re-sign zones for DNSSEC
# before they expire. When we sign zones (in `dns_update.py`) we specify a
# 30-day validation window, so we had better re-sign before then.
cat > /etc/cron.daily/mailinabox-dnssec << EOF; cat > /etc/cron.daily/mailinabox-dnssec << EOF;
#!/bin/bash #!/bin/bash
# Mail-in-a-Box # Mail-in-a-Box

57
setup/firstuser.sh Normal file
View File

@@ -0,0 +1,57 @@
# If there aren't any mail users yet, create one.
if [ -z "`tools/mail.py user`" ]; then
# The outut of "tools/mail.py user" is a list of mail users. If there
# aren't any yet, it'll be empty.
# If we didn't ask for an email address at the start, do so now.
if [ -z "$EMAIL_ADDR" ]; then
# In an interactive shell, ask the user for an email address.
if [ -z "$NONINTERACTIVE" ]; then
input_box "Mail Account" \
"Let's create your first mail account.
\n\nWhat email address do you want?" \
me@`get_default_hostname` \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
exit
fi
while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
do
input_box "Mail Account" \
"That's not a valid email address.
\n\nWhat email address do you want?" \
$EMAIL_ADDR \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
exit
fi
done
# But in a non-interactive shell, just make something up.
# This is normally for testing.
else
# Use me@PRIMARY_HOSTNAME
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
EMAIL_PW=1234
echo
echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
echo
fi
else
echo
echo "Okay. I'm about to set up $EMAIL_ADDR for you. This account will also"
echo "have access to the box's control panel."
fi
# Create the user's mail account. This will ask for a password if none was given above.
tools/mail.py user add $EMAIL_ADDR $EMAIL_PW
# Make it an admin.
hide_output tools/mail.py user make-admin $EMAIL_ADDR
# Create an alias to which we'll direct all automatically-created administrative aliases.
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
fi

View File

@@ -9,19 +9,35 @@ function hide_output {
$@ &> $OUTPUT $@ &> $OUTPUT
# If the command failed, show the output that was captured in the temporary file. # If the command failed, show the output that was captured in the temporary file.
if [ $? != 0 ]; then E=$?
if [ $E != 0 ]; then
# Something failed. # Something failed.
echo echo
echo FAILED: $@ echo FAILED: $@
echo ----------------------------------------- echo -----------------------------------------
cat $OUTPUT cat $OUTPUT
echo ----------------------------------------- echo -----------------------------------------
exit $E
fi fi
# Remove temporary file. # Remove temporary file.
rm -f $OUTPUT rm -f $OUTPUT
} }
function apt_get_quiet {
# Run apt-get in a totally non-interactive mode.
#
# Somehow all of these options are needed to get it to not ask the user
# questions about a) whether to proceed (-y), b) package options (noninteractive),
# and c) what to do about files changed locally (we don't cause that to happen but
# some VM providers muck with their images; -o).
#
# Although we could pass -qq to apt-get to make output quieter, many packages write to stdout
# and stderr things that aren't really important. Use our hide_output function to capture
# all of that and only show it if there is a problem (i.e. if apt_get returns a failure exit status).
DEBIAN_FRONTEND=noninteractive hide_output apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confnew" "$@"
}
function apt_install { function apt_install {
# Report any packages already installed. # Report any packages already installed.
PACKAGES=$@ PACKAGES=$@
@@ -46,18 +62,10 @@ function apt_install {
echo installing $TO_INSTALL... echo installing $TO_INSTALL...
fi fi
# 'DEBIAN_FRONTEND=noninteractive' is to prevent dbconfig-common from asking you questions. # We still include the whole original package list in the apt-get command in
#
# Although we could pass -qq to apt-get to make output quieter, many packages write to stdout
# and stderr things that aren't really important. Use our hide_output function to capture
# all of that and only show it if there is a problem (i.e. if apt_get returns a failure exit status).
#
# Also note that we still include the whole original package list in the apt-get command in
# case it wants to upgrade anything, I guess? Maybe we can remove it. Doesn't normally make # case it wants to upgrade anything, I guess? Maybe we can remove it. Doesn't normally make
# a difference. # a difference.
DEBIAN_FRONTEND=noninteractive \ apt_get_quiet install $PACKAGES
hide_output \
apt-get -y install $PACKAGES
} }
function get_default_hostname { function get_default_hostname {
@@ -85,6 +93,9 @@ function get_default_privateip {
# Return the IP address of the network interface connected # Return the IP address of the network interface connected
# to the Internet. # to the Internet.
# #
# Pass '4' or '6' as an argument to this function to specify
# what type of address to get (IPv4, IPv6).
#
# We used to use `hostname -I` and then filter for either # We used to use `hostname -I` and then filter for either
# IPv4 or IPv6 addresses. However if there are multiple # IPv4 or IPv6 addresses. However if there are multiple
# network interfaces on the machine, not all may be for # network interfaces on the machine, not all may be for
@@ -99,11 +110,16 @@ function get_default_privateip {
# assigned to an interface. `ip route get` reports the # assigned to an interface. `ip route get` reports the
# preferred. That's good enough for us. See issue #121. # preferred. That's good enough for us. See issue #121.
# #
# With IPv6, the best route may be via an interface that
# only has a link-local address (fe80::*). These addresses
# are only unique to an interface and so need an explicit
# interface specification in order to use them with bind().
# In these cases, we append "%interface" to the address.
# See the Notes section in the man page for getaddrinfo and
# https://discourse.mailinabox.email/t/update-broke-mailinabox/34/9.
#
# Also see ae67409603c49b7fa73c227449264ddd10aae6a9 and # Also see ae67409603c49b7fa73c227449264ddd10aae6a9 and
# issue #3 for why/how we originally added IPv6. # issue #3 for why/how we originally added IPv6.
#
# Pass '4' or '6' as an argument to this function to specify
# what type of address to get (IPv4, IPv6).
target=8.8.8.8 target=8.8.8.8
@@ -112,9 +128,21 @@ function get_default_privateip {
# as it's an address on the public Internet. # as it's an address on the public Internet.
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
ip -$1 -o route get $target \ # Get the route information.
| grep -v unreachable \ route=$(ip -$1 -o route get $target | grep -v unreachable)
| sed "s/.* src \([^ ]*\).*/\1/"
# Parse the address out of the route information.
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
if [[ "$1" == "6" && $address == fe80:* ]]; then
# For IPv6 link-local addresses, parse the interface out
# of the route information and append it with a '%'.
interface=$(echo $route | sed "s/.* dev \([^ ]*\).*/\1/")
address=$address%$interface
fi
echo $address
} }
function ufw_allow { function ufw_allow {
@@ -127,3 +155,69 @@ function ufw_allow {
function restart_service { function restart_service {
hide_output service $1 restart hide_output service $1 restart
} }
## Dialog Functions ##
function message_box {
dialog --title "$1" --msgbox "$2" 0 0
}
function input_box {
# input_box "title" "prompt" "defaultvalue" VARIABLE
# The user's input will be stored in the variable VARIABLE.
# The exit code from dialog will be stored in VARIABLE_EXITCODE.
declare -n result=$4
declare -n result_code=$4_EXITCODE
result=$(dialog --stdout --title "$1" --inputbox "$2" 0 0 "$3")
result_code=$?
}
function input_menu {
# input_menu "title" "prompt" "tag item tag item" VARIABLE
# The user's input will be stored in the variable VARIABLE.
# The exit code from dialog will be stored in VARIABLE_EXITCODE.
declare -n result=$4
declare -n result_code=$4_EXITCODE
local IFS=^$'\n'
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
result_code=$?
}
function wget_verify {
# Downloads a file from the web and checks that it matches
# a provided hash. If the comparison fails, exit immediately.
URL=$1
HASH=$2
DEST=$3
CHECKSUM="$HASH $DEST"
rm -f $DEST
wget -q -O $DEST $URL || exit 1
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
echo "------------------------------------------------------------"
echo "Download of $URL did not match expected checksum."
echo "Found:"
sha1sum $DEST
echo
echo "Expected:"
echo "$CHECKSUM"
rm -f $DEST
exit 1
fi
}
function git_clone {
# Clones a git repository, checks out a particular commit or tag,
# and moves the repository (or a subdirectory in it) to some path.
# We use separate clone and checkout because -b only supports tags
# and branches, but we sometimes want to reference a commit hash
# directly when the repo doesn't provide a tag.
REPO=$1
TREEISH=$2
SUBDIR=$3
TARGETPATH=$4
TMPPATH=/tmp/git-clone-$$
rm -rf $TMPPATH $TARGETPATH
git clone -q $REPO $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1
mv $TMPPATH/$SUBDIR $TARGETPATH
rm -rf $TMPPATH
}

View File

@@ -1,8 +1,9 @@
#!/bin/bash #!/bin/bash
# #
# Dovecot (IMAP and LDA) # Dovecot (IMAP/POP and LDA)
# ----------------------
# #
# Dovecot is *both* the IMAP server (the protocol that email applications # Dovecot is *both* the IMAP/POP server (the protocol that email applications
# use to query a mailbox) as well as the local delivery agent (LDA), # use to query a mailbox) as well as the local delivery agent (LDA),
# meaning it is responsible for writing emails to mailbox storage on disk. # meaning it is responsible for writing emails to mailbox storage on disk.
# You could imagine why these things would be bundled together. # You could imagine why these things would be bundled together.
@@ -17,21 +18,41 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# Install packages. # Install packages...
apt_install \ apt_install \
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite sqlite3 \ dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
dovecot-sieve dovecot-managesieved dovecot-sieve dovecot-managesieved
# The dovecot-imapd dovecot-lmtpd packages automatically enable IMAP and LMTP protocols. # The `dovecot-imapd`, `dovecot-pop3d`, and `dovecot-lmtpd` packages automatically
# enable IMAP, POP and LMTP protocols.
# Set the location where we'll store user mailboxes. # Set basic daemon options.
# The `default_process_limit` is 100, which constrains the total number
# of active IMAP connections (at, say, 5 open connections per user that
# would be 20 users). Set it to 250 times the number of cores this
# machine has, so on a two-core machine that's 500 processes/100 users).
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
default_process_limit=$(echo "`nproc` * 250" | bc)
# The inotify `max_user_instances` default is 128, which constrains
# the total number of watched (IMAP IDLE push) folders by open connections.
# See http://www.dovecot.org/pipermail/dovecot/2013-March/088834.html.
# A reboot is required for this to take effect (which we don't do as
# as a part of setup). Test with `cat /proc/sys/fs/inotify/max_user_instances`.
tools/editconf.py /etc/sysctl.conf \
fs.inotify.max_user_instances=1024
# Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the
# username part of the user's email address. We'll ensure that no bad domains or email addresses
# are created within the management daemon.
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \ mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
mail_privileged_group=mail \ mail_privileged_group=mail \
first_valid_uid=0 first_valid_uid=0
# IMAP # ### IMAP/POP
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms. # Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
# The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess # The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess
@@ -40,16 +61,18 @@ tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
disable_plaintext_auth=yes \ disable_plaintext_auth=yes \
"auth_mechanisms=plain login" "auth_mechanisms=plain login"
# Enable SSL, specify the location of the SSL certificate and private key files, # Enable SSL, specify the location of the SSL certificate and private key files.
# and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/. # Disable obsolete SSL protocols and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \ tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
ssl=required \ ssl=required \
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \ "ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \ "ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
"ssl_protocols=!SSLv3 !SSLv2" \
"ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH" "ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH"
# Disable in-the-clear IMAP and POP because we're paranoid (we haven't even # Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
# enabled POP). # login credentials outside of an encrypted connection. Only the over-TLS versions
# are made available (IMAPS on port 993; POP3S on port 995).
sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf
sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
@@ -62,10 +85,17 @@ sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \ tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
imap_idle_notify_interval="4 mins" imap_idle_notify_interval="4 mins"
# LDA (LMTP) # Set POP3 UIDL
# UIDLs are used by POP3 clients to keep track of what messages they've downloaded.
# For new POP3 servers, the easiest way to set up UIDLs is to use IMAP's UIDVALIDITY
# and UID values, the default in Dovecot.
tools/editconf.py /etc/dovecot/conf.d/20-pop3.conf \
pop3_uidl_format="%08Xu%08Xv"
# ### LDA (LMTP)
# Enable Dovecot's LDA service with the LMTP protocol. It will listen # Enable Dovecot's LDA service with the LMTP protocol. It will listen
# in port 10026, and Spamassassin will be configured to pass mail there. # on port 10026, and Spamassassin will be configured to pass mail there.
# #
# The disabled unix socket listener is normally how Postfix and Dovecot # The disabled unix socket listener is normally how Postfix and Dovecot
# would communicate (see the Postfix setup script for the corresponding # would communicate (see the Postfix setup script for the corresponding
@@ -90,30 +120,32 @@ protocol imap {
} }
EOF EOF
# Setting a postmaster_address seems to be required or LMTP won't start. # Setting a `postmaster_address` is required or LMTP won't start. An alias
# will be created automatically by our management daemon.
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \ tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
postmaster_address=postmaster@$PRIMARY_HOSTNAME postmaster_address=postmaster@$PRIMARY_HOSTNAME
# SIEVE # ### Sieve
# Enable the Dovecot sieve plugin which let's users run scripts that process # Enable the Dovecot sieve plugin which let's users run scripts that process
# mail as it comes in. We'll also set a global script that moves mail marked # mail as it comes in.
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
# Configure sieve. We'll create a global script that moves mail marked
# as spam by Spamassassin into the user's Spam folder. # as spam by Spamassassin into the user's Spam folder.
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf #
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF; #
plugin { # * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic
# 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
# link here to the actual sieve script. It should not be in the mailbox directory # 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 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). # (because then I suppose it might appear to the user as one of their scripts).
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve # * `sieve_dir`: Directory for :personal include scripts for the include extension. This
# Directory for :personal include scripts for the include extension. This
# is also where the ManageSieve service stores the user's scripts. # 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 sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
} }
EOF EOF
@@ -121,7 +153,7 @@ EOF
# Copy the global sieve script into where we've told Dovecot to look for it. Then # Copy the global sieve script into where we've told Dovecot to look for it. Then
# compile it. Global scripts must be compiled now because Dovecot won't have # compile it. Global scripts must be compiled now because Dovecot won't have
# permission later. # permission later.
cp `pwd`/conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve cp conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
sievec /etc/dovecot/sieve-spam.sieve sievec /etc/dovecot/sieve-spam.sieve
# PERMISSIONS # PERMISSIONS
@@ -138,8 +170,9 @@ chown -R mail.mail $STORAGE_ROOT/mail/mailboxes
mkdir -p $STORAGE_ROOT/mail/sieve mkdir -p $STORAGE_ROOT/mail/sieve
chown -R mail.mail $STORAGE_ROOT/mail/sieve chown -R mail.mail $STORAGE_ROOT/mail/sieve
# Allow the IMAP port in the firewall. # Allow the IMAP/POP ports in the firewall.
ufw_allow imaps ufw_allow imaps
ufw_allow pop3s
# Restart services. # Restart services.
restart_service dovecot restart_service dovecot

View File

@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# #
# Postfix (SMTP) # Postfix (SMTP)
# --------------
# #
# Postfix handles the transmission of email between servers # Postfix handles the transmission of email between servers
# using the SMTP protocol. It is a Mail Transfer Agent (MTA). # using the SMTP protocol. It is a Mail Transfer Agent (MTA).
@@ -29,34 +30,55 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# Install packages. # ### 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.
# Basic Settings apt_install postfix postfix-pcre postgrey ca-certificates
# Have postfix listen on all network interfaces, set our name (the Debian default seems to be localhost), # ### Basic Settings
# and set the name of the local machine to localhost for xxx@localhost mail (but I don't think this will have any effect because
# there is no true local mail delivery). Also set the banner (must have the hostname first, then anything). # 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 \ tools/editconf.py /etc/postfix/main.cf \
inet_interfaces=all \ inet_interfaces=all \
myhostname=$PRIMARY_HOSTNAME\ myhostname=$PRIMARY_HOSTNAME\
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \ smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
mydestination=localhost mydestination=localhost
# Outgoing Mail # ### Outgoing Mail
# Enable the 'submission' port 587 smtpd server and tweak its settings. # Enable the 'submission' port 587 smtpd server and tweak its settings.
# a) 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). # * Do not add the OpenDMAC Authentication-Results header. That should only be added
# b) Give it a different name in syslog to distinguish it from the port 25 smtpd server. # on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
# c) Add a new cleanup service specific to the submission service ('authclean') # OpenDKIM milter only. See dkim.sh.
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
# 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 # that filters out privacy-sensitive headers on mail being sent out by
# authenticated users. # authenticated users.
tools/editconf.py /etc/postfix/master.cf -s -w \ tools/editconf.py /etc/postfix/master.cf -s -w \
"submission=inet n - - - - smtpd "submission=inet n - - - - smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3 -o smtpd_milters=inet:127.0.0.1:8891
-o smtpd_tls_security_level=encrypt
-o smtpd_tls_ciphers=high -o smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 -o smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3
-o cleanup_service_name=authclean" \ -o cleanup_service_name=authclean" \
"authclean=unix n - - - 0 cleanup "authclean=unix n - - - 0 cleanup
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters" -o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters"
@@ -64,83 +86,101 @@ tools/editconf.py /etc/postfix/master.cf -s -w \
# Install the `outgoing_mail_header_filters` file required by the new 'authclean' service. # Install the `outgoing_mail_header_filters` file required by the new 'authclean' service.
cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters
# Enable TLS on incoming connections (i.e. ports 25 *and* 587) and # Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
# require TLS before a user is allowed to authenticate. This also makes # require TLS before a user is allowed to authenticate. This also makes
# opportunistic TLS available on *incoming* mail. # opportunistic TLS available on *incoming* mail.
# Set stronger DH parameters, which via openssl tend to default to 1024 bits
# (see ssl.sh).
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtpd_tls_security_level=may\ smtpd_tls_security_level=may\
smtpd_tls_auth_only=yes \ smtpd_tls_auth_only=yes \
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \ smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \ smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
smtpd_tls_ciphers=medium \
smtpd_tls_exclude_ciphers=aNULL \
smtpd_tls_received_header=yes smtpd_tls_received_header=yes
# Prevent non-authenticated users from sending mail that requires being
# 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.)
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. # When connecting to remote SMTP servers, prefer TLS and use DANE if available.
# #
# Prefering ("opportunistic") TLS means Postfix will accept whatever SSL certificate the remote # Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end
# end provides, if the remote end offers STARTTLS during the connection. DANE takes this a # offers it, otherwise it will transmit the message in the clear. Postfix will
# step further: # accept whatever SSL certificate the remote end provides. Opportunistic TLS
# protects against passive easvesdropping (but not man-in-the-middle attacks).
# DANE takes this a step further:
# #
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found, # Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records # then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC # or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also # itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
# relies on our local bind9 server being present and smtp_dns_support_level being set to dnssec # relies on our local bind9 server being present and `smtp_dns_support_level=dnssec`.
# to use it.
# #
# The smtp_tls_CAfile is superflous, but it turns warnings in the logs about untrusted certs # The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
# into notices about trusted certs. Since in these cases Postfix is doing opportunistic TLS, # which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
# it does not care about whether the remote certificate is trusted. But, looking at the logs, # even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
# it's nice to be able to see that the connection was in fact encrypted for the right party. # now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
# The CA file is provided by the package ca-certificates.
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtp_tls_security_level=dane \ smtp_tls_security_level=dane \
smtp_dns_support_level=dnssec \ smtp_dns_support_level=dnssec \
smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \ smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \
smtp_tls_loglevel=2 smtp_tls_loglevel=2
# Incoming Mail # ### Incoming Mail
# Pass any incoming mail over to a local delivery agent. Spamassassin # Pass any incoming mail over to a local delivery agent. Spamassassin
# will act as the LDA agent at first. It is listening on port 10025 # will act as the LDA agent at first. It is listening on port 10025
# with LMTP. Spamassassin will pass the mail over to Dovecot after. # with LMTP. Spamassassin will pass the mail over to Dovecot after.
# #
# In a basic setup we would pass mail directly to Dovecot like so: # In a basic setup we would pass mail directly to Dovecot by setting
# tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:unix:private/dovecot-lmtp # virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
# #
tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025 tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
# Who can send outbound mail? The purpose of this is to prevent
# non-authenticated users from sending mail that requires being
# relayed elsewhere. We don't want to be an "open relay".
#
# 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
# Who can send mail to us? Some basic filters. # Who can send mail to us? Some basic filters.
# #
# reject_non_fqdn_sender: Reject not-nice-looking return paths. # * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
# reject_unknown_sender_domain: Reject return paths with invalid domains. # * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
# reject_rhsbl_sender: Reject return paths that use blacklisted domains. # * `reject_rhsbl_sender`: Reject return paths that use blacklisted domains.
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.
# * `permit_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.
# #
# permit_sasl_authenticated: Authenticated users (i.e. on port 587) can skip further checks. # Notes: #NODOC
# permit_mynetworks: Mail that originates locally can skip further checks. # permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC
# reject_rbl_client: Reject connections from IP addresses blacklisted in zen.spamhaus.org # 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
# reject_unlisted_recipient: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after. # whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC
# check_policy_service: Apply greylisting using postgrey. # "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
#
# 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.
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \ smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023" smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
# Postgrey listens on the same interface (and not IPv6, for instance).
# A lot of legit mail servers try to resend before 300 seconds.
# As a matter of fact RFC is not strict about retry timer so postfix and
# other MTA have their own intervals. To fix the problem of receiving
# e-mails really latter, delay of greylisting has been set to
# 180 seconds (default is 300 seconds).
tools/editconf.py /etc/default/postgrey \
POSTGREY_OPTS=\"'--inet=127.0.0.1:10023 --delay=180'\"
# Increase the message size limit from 10MB to 128MB. # Increase the message size limit from 10MB to 128MB.
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
tools/editconf.py /etc/postfix/main.cf \ tools/editconf.py /etc/postfix/main.cf \
message_size_limit=134217728 message_size_limit=134217728
@@ -152,3 +192,4 @@ ufw_allow submission
# Restart services # Restart services
restart_service postfix restart_service postfix
restart_service postgrey

View File

@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# #
# User Authentication and Destination Validation # User Authentication and Destination Validation
# ----------------------------------------------
# #
# This script configures user authentication for Dovecot # This script configures user authentication for Dovecot
# and Postfix (which relies on Dovecot) and destination # and Postfix (which relies on Dovecot) and destination
@@ -9,6 +10,8 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# ### User and Alias Database
# The database of mail users (i.e. authenticated users, who have mailboxes) # The database of mail users (i.e. authenticated users, who have mailboxes)
# and aliases (forwarders). # and aliases (forwarders).
@@ -21,8 +24,7 @@ if [ ! -f $db_path ]; then
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | sqlite3 $db_path;
fi fi
# User Authentication # ### User Authentication
#####################
# Have Dovecot query our database, and not system users, for authentication. # Have Dovecot query our database, and not system users, for authentication.
sed -i "s/#*\(\!include auth-system.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf sed -i "s/#*\(\!include auth-system.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf
@@ -51,7 +53,6 @@ EOF
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
# Have Dovecot provide an authorization service that Postfix can access & use. # Have Dovecot provide an authorization service that Postfix can access & use.
# Drew Crawford sets the auth-worker process to run as the mail user, but we don't care if it runs as root.
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF; cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
service auth { service auth {
unix_listener /var/spool/postfix/private/auth { unix_listener /var/spool/postfix/private/auth {
@@ -68,8 +69,7 @@ tools/editconf.py /etc/postfix/main.cf \
smtpd_sasl_path=private/auth \ smtpd_sasl_path=private/auth \
smtpd_sasl_auth_enable=yes smtpd_sasl_auth_enable=yes
# Destination Validation # ### Destination Validation
########################
# Use a Sqlite3 database to check whether a destination email address exists, # Use a Sqlite3 database to check whether a destination email address exists,
# and to perform any email alias rewrites in Postfix. # and to perform any email alias rewrites in Postfix.
@@ -92,9 +92,16 @@ query = SELECT 1 FROM users WHERE email='%s'
EOF EOF
# SQL statement to rewrite an email address if an alias is present. # SQL statement to rewrite an email address if an alias is present.
# Aliases have precedence over users, but that's counter-intuitive for
# catch-all aliases ("@domain.com") which should *not* catch mail users.
# To fix this, not only query the aliases table but also the users
# table, i.e. turn users into aliases from themselves to themselves.
# If there is both an alias and a user for the same address either
# might be returned by the UNION, so the whole query is wrapped in
# another select that prioritizes the alias definition.
cat > /etc/postfix/virtual-alias-maps.cf << EOF; cat > /etc/postfix/virtual-alias-maps.cf << EOF;
dbpath=$db_path dbpath=$db_path
query = SELECT destination FROM aliases WHERE source='%s' query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
EOF EOF
# Restart Services # Restart Services

View File

@@ -2,13 +2,14 @@
source setup/functions.sh source setup/functions.sh
apt_install python3-flask links duplicity libyaml-dev python3-dnspython apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
hide_output pip3 install rtyaml hide_output pip3 install rtyaml "email_validator==0.1.0-rc5"
# email_validator is repeated in setup/questions.sh
# Create a backup directory and a random key for encrypting backups. # Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup mkdir -p $STORAGE_ROOT/backup
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt $(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
fi fi
# Link the management server daemon into a well known location. # Link the management server daemon into a well known location.
@@ -30,5 +31,19 @@ $(pwd)/management/backup.py
EOF EOF
chmod +x /etc/cron.daily/mailinabox-backup chmod +x /etc/cron.daily/mailinabox-backup
# Start it. # Perform daily status checks. Compare each day to the previous
# for changes and mail the changes to the administrator.
cat > /etc/cron.daily/mailinabox-statuschecks << EOF;
#!/bin/bash
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
# Run status checks.
$(pwd)/management/status_checks.py --show-changes --smtp
EOF
chmod +x /etc/cron.daily/mailinabox-statuschecks
# Start it. Remove the api key file first so that start.sh
# can wait for it to be created to know that the management
# server is ready.
rm -f /var/lib/mailinabox/api.key
restart_service mailinabox restart_service mailinabox

View File

@@ -56,6 +56,46 @@ def migration_4(env):
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD privileges TEXT NOT NULL DEFAULT ''"]) shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD privileges TEXT NOT NULL DEFAULT ''"])
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 migration_7(env):
# I previously wanted domain names to be stored in Unicode in the database. Now I want them
# to be in IDNA. Affects aliases only.
import sqlite3
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/users.sqlite"))
# Get existing alias source addresses.
c = conn.cursor()
c.execute('SELECT source FROM aliases')
aliases = [ row[0] for row in c.fetchall() ]
# Update to IDNA-encoded domains.
for email in aliases:
try:
localpart, domainpart = email.split("@")
domainpart = domainpart.encode("idna").decode("ascii")
newemail = localpart + "@" + domainpart
if newemail != email:
c = conn.cursor()
c.execute("UPDATE aliases SET source=? WHERE source=?", (newemail, email))
if c.rowcount != 1: raise ValueError("Alias not found.")
print("Updated alias", email, "to", newemail)
except Exception as e:
print("Error updating IDNA alias", email, e)
# Save.
conn.commit()
def get_current_migration(): def get_current_migration():
ver = 0 ver = 0
while True: while True:
@@ -73,13 +113,22 @@ def run_migrations():
env = load_environment() env = load_environment()
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version') migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
migration_id = None
if os.path.exists(migration_id_file): if os.path.exists(migration_id_file):
with open(migration_id_file) as f: with open(migration_id_file) as f:
ourver = int(f.read().strip()) migration_id = f.read().strip();
else:
if migration_id is None:
# Load the legacy location of the migration ID. We'll drop support # Load the legacy location of the migration ID. We'll drop support
# for this eventually. # for this eventually.
ourver = int(env.get("MIGRATIONID", "0")) migration_id = env.get("MIGRATIONID")
if migration_id is None:
print()
print("%s file doesn't exists. Skipping migration..." % (migration_id_file,))
return
ourver = int(migration_id)
while True: while True:
next_ver = (ourver + 1) next_ver = (ourver + 1)

View File

@@ -1,3 +1,7 @@
# Install the 'host', 'sed', and and 'nc' tools. This script is run before
# the rest of the system setup so we may not yet have things installed.
apt_get_quiet install bind9-host sed netcat-openbsd
# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List. # Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
# The user might have chosen a name that was previously in use by a spammer # The user might have chosen a name that was previously in use by a spammer
# and will not be able to reliably send mail. Do this after any automatic # and will not be able to reliably send mail. Do this after any automatic

View File

@@ -5,6 +5,8 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# ### Installing ownCloud
apt_install \ apt_install \
dbconfig-common \ dbconfig-common \
php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \ php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \
@@ -12,23 +14,61 @@ apt_install \
apt-get purge -qq -y owncloud* apt-get purge -qq -y owncloud*
# Install ownCloud from source if it is not already present # Install ownCloud from source of this version:
# TODO: Check version? owncloud_ver=8.0.3
if [ ! -d /usr/local/lib/owncloud ]; then owncloud_hash=3192f3d783f81247eaf2914df63afdd593def4e5
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
if [ ! -d /usr/local/lib/owncloud/ ] \
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
# Clear out the existing ownCloud.
rm -f /tmp/owncloud-config.php
if [ ! -d /usr/local/lib/owncloud/ ]; then
echo installing ownCloud... echo installing ownCloud...
rm -f /tmp/owncloud.zip else
wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-7.0.1.zip echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
unzip -q /tmp/owncloud.zip -d /usr/local/lib cp /usr/local/lib/owncloud/config/config.php /tmp/owncloud-config.php
rm -f /tmp/owncloud.zip mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
fi fi
# Download and extract ownCloud.
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
rm -f /tmp/owncloud.zip
# The two apps we actually want are not in ownCloud core. Clone them from
# their github repositories.
mkdir -p /usr/local/lib/owncloud/apps
git_clone https://github.com/owncloud/contacts v$owncloud_ver '' /usr/local/lib/owncloud/apps/contacts
git_clone https://github.com/owncloud/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
# Fix weird permissions.
chmod 750 /usr/local/lib/owncloud/{apps,config}
# Restore configuration file if we're doing an upgrade.
if [ -f /tmp/owncloud-config.php ]; then
mv /tmp/owncloud-config.php /usr/local/lib/owncloud/config/config.php
fi
# Make sure permissions are correct or the upgrade step won't run.
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
# that error.
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
# Run the upgrade script (if ownCloud is already up-to-date it wont matter).
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
fi
# ### Configuring ownCloud
# Setup ownCloud if the ownCloud database does not yet exist. Running setup when # Setup ownCloud if the ownCloud database does not yet exist. Running setup when
# the database does exist wipes the database and user data. # the database does exist wipes the database and user data.
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
# Create a configuration file. # Create a configuration file.
TIMEZONE=`cat /etc/timezone` TIMEZONE=$(cat /etc/timezone)
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1) instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
cat - > /usr/local/lib/owncloud/config/config.php <<EOF; cat > /usr/local/lib/owncloud/config/config.php <<EOF;
<?php <?php
\$CONFIG = array ( \$CONFIG = array (
'datadirectory' => '$STORAGE_ROOT/owncloud', 'datadirectory' => '$STORAGE_ROOT/owncloud',
@@ -69,8 +109,8 @@ EOF
# Create an auto-configuration file to fill in database settings # Create an auto-configuration file to fill in database settings
# when the install script is run. Make an administrator account # when the install script is run. Make an administrator account
# here or else the install can't finish. # here or else the install can't finish.
adminpassword=$(dd if=/dev/random bs=40 count=1 2>/dev/null | sha1sum | fold -w 30 | head -n 1) adminpassword=$(dd if=/dev/random bs=1 count=40 2>/dev/null | sha1sum | fold -w 30 | head -n 1)
cat - > /usr/local/lib/owncloud/config/autoconfig.php <<EOF; cat > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;
<?php <?php
\$AUTOCONFIG = array ( \$AUTOCONFIG = array (
# storage/database # storage/database
@@ -96,9 +136,12 @@ fi
# Enable/disable apps. Note that this must be done after the ownCloud setup. # Enable/disable apps. Note that this must be done after the ownCloud setup.
# The firstrunwizard gave Josh all sorts of problems, so disabling that. # The firstrunwizard gave Josh all sorts of problems, so disabling that.
# user_external is what allows ownCloud to use IMAP for login. # user_external is what allows ownCloud to use IMAP for login. The contacts
hide_output php /usr/local/lib/owncloud/console.php app:disable firstrunwizard # and calendar apps are the extensions we really care about here.
hide_output php /usr/local/lib/owncloud/console.php app:enable user_external hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable user_external
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable contacts
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar
# Set PHP FPM values to support large file uploads # Set PHP FPM values to support large file uploads
# (semicolon is the comment character in this file, hashes produce deprecation warnings) # (semicolon is the comment character in this file, hashes produce deprecation warnings)
@@ -118,13 +161,15 @@ sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
EOF EOF
chmod +x /etc/cron.hourly/mailinabox-owncloud chmod +x /etc/cron.hourly/mailinabox-owncloud
## Ensure all system admins are ownCloud admins. # There's nothing much of interest that a user could do as an admin for ownCloud,
## Actually we don't do this. There's nothing much of interest that the user could # and there's a lot they could mess up, so we don't make any users admins of ownCloud.
## change from the ownCloud admin, and there's a lot they could mess up. # But if we wanted to, we would do this:
# ```
# for user in $(tools/mail.py user admins); do # for user in $(tools/mail.py user admins); do
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')" # sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
# done # done
# ```
# Finished. # Enable PHP modules and restart PHP.
php5enmod imap php5enmod imap
restart_service php5-fpm restart_service php5-fpm

35
setup/preflight.sh Normal file
View File

@@ -0,0 +1,35 @@
# Are we running as root?
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root. Please re-run like this:"
echo
echo "sudo $0"
echo
exit
fi
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx).
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "Ubuntu 14.04 LTS" ]; then
echo "Mail-in-a-Box only supports being installed on Ubuntu 14.04, sorry. You are running:"
echo
lsb_release -d | sed 's/.*:\s*//'
echo
echo "We can't write scripts that run on every possible setup, sorry."
exit
fi
# Check that we have enough memory.
#
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 KB,
# which is 750000 kibibytes.
#
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
if [ ! -d /vagrant ]; then
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
exit
fi
fi

195
setup/questions.sh Normal file
View File

@@ -0,0 +1,195 @@
if [ -z "$NONINTERACTIVE" ]; then
# Install 'dialog' so we can ask the user questions. The original motivation for
# this was being able to ask the user for input even if stdin has been redirected,
# e.g. if we piped a bootstrapping install script to bash to get started. In that
# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we
# use a shell flag instead. Really supress any output from installing dialog.
#
# Also install depencies needed to validate the email address.
echo Installing packages needed for setup...
apt_get_quiet install dialog python3 python3-pip || exit 1
# email_validator is repeated in setup/management.sh
hide_output pip3 install "email_validator==0.1.0-rc5" || exit 1
message_box "Mail-in-a-Box Installation" \
"Hello and thanks for deploying a Mail-in-a-Box!
\n\nI'm going to ask you a few questions.
\n\nTo change your answers later, just run 'sudo mailinabox' from the command line."
fi
# The box needs a name.
if [ -z "$PRIMARY_HOSTNAME" ]; then
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then
# We recommend to use box.example.com as this hosts name. The
# domain the user possibly wants to use is example.com then.
# We strip the string "box." from the hostname to get the mail
# domain. If the hostname differs, nothing happens here.
DEFAULT_DOMAIN_GUESS=$(echo $(get_default_hostname) | sed -e 's/^box\.//')
# This is the first run. Ask the user for his email address so we can
# provide the best default for the box's hostname.
input_box "Your Email Address" \
"What email address are you setting this box up to manage?
\n\nThe part after the @-sign must be a domain name or subdomain
that you control. You can add other email addresses to this
box later (including email addresses on other domain names
or subdomains you control).
\n\nWe've guessed an email address. Backspace it and type in what
you really want.
\n\nEmail Address:" \
"me@$DEFAULT_DOMAIN_GUESS" \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
exit
fi
while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
do
input_box "Your Email Address" \
"That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \
$EMAIL_ADDR \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
exit
fi
done
# Take the part after the @-sign as the user's domain name, and add
# 'box.' to the beginning to create a default hostname for this machine.
DEFAULT_PRIMARY_HOSTNAME=box.$(echo $EMAIL_ADDR | sed 's/.*@//')
fi
input_box "Hostname" \
"This box needs a name, called a 'hostname'. The name will form a part of the box's web address.
\n\nWe recommend that the name be a subdomain of the domain in your email
address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME.
\n\nYou can change it, but we recommend you don't.
\n\nHostname:" \
$DEFAULT_PRIMARY_HOSTNAME \
PRIMARY_HOSTNAME
if [ -z "$PRIMARY_HOSTNAME" ]; then
# user hit ESC/cancel
exit
fi
fi
# If the machine is behind a NAT, inside a VM, etc., it may not know
# its IP address on the public network / the Internet. Ask the Internet
# and possibly confirm with user.
if [ -z "$PUBLIC_IP" ]; then
# Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 4)
# On the first run, if we got an answer from the Internet then don't
# ask the user.
if [[ -z "$DEFAULT_PUBLIC_IP" && ! -z "$GUESSED_IP" ]]; then
PUBLIC_IP=$GUESSED_IP
# Otherwise on the first run at least provide a default.
elif [[ -z "$DEFAULT_PUBLIC_IP" ]]; then
DEFAULT_PUBLIC_IP=$(get_default_privateip 4)
# On later runs, if the previous value matches the guessed value then
# don't ask the user either.
elif [ "$DEFAULT_PUBLIC_IP" == "$GUESSED_IP" ]; then
PUBLIC_IP=$GUESSED_IP
fi
if [ -z "$PUBLIC_IP" ]; then
input_box "Public IP Address" \
"Enter the public IP address of this machine, as given to you by your ISP.
\n\nPublic IP address:" \
$DEFAULT_PUBLIC_IP \
PUBLIC_IP
if [ -z "$PUBLIC_IP" ]; then
# user hit ESC/cancel
exit
fi
fi
fi
# Same for IPv6. But it's optional. Also, if it looks like the system
# doesn't have an IPv6, don't ask for one.
if [ -z "$PUBLIC_IPV6" ]; then
# Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 6)
MATCHED=0
if [[ -z "$DEFAULT_PUBLIC_IPV6" && ! -z "$GUESSED_IP" ]]; then
PUBLIC_IPV6=$GUESSED_IP
elif [[ "$DEFAULT_PUBLIC_IPV6" == "$GUESSED_IP" ]]; then
# No IPv6 entered and machine seems to have none, or what
# the user entered matches what the Internet tells us.
PUBLIC_IPV6=$GUESSED_IP
MATCHED=1
elif [[ -z "$DEFAULT_PUBLIC_IPV6" ]]; then
DEFAULT_PUBLIC_IP=$(get_default_privateip 6)
fi
if [[ -z "$PUBLIC_IPV6" && $MATCHED == 0 ]]; then
input_box "IPv6 Address (Optional)" \
"Enter the public IPv6 address of this machine, as given to you by your ISP.
\n\nLeave blank if the machine does not have an IPv6 address.
\n\nPublic IPv6 address:" \
$DEFAULT_PUBLIC_IPV6 \
PUBLIC_IPV6
if [ ! $PUBLIC_IPV6_EXITCODE ]; then
# user hit ESC/cancel
exit
fi
fi
fi
# Get the IP addresses of the local network interface(s) that are connected
# to the Internet. We need these when we want to have services bind only to
# the public network interfaces (not loopback, not tunnel interfaces).
if [ -z "$PRIVATE_IP" ]; then
PRIVATE_IP=$(get_default_privateip 4)
fi
if [ -z "$PRIVATE_IPV6" ]; then
PRIVATE_IPV6=$(get_default_privateip 6)
fi
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
echo
echo "I could not determine the IP or IPv6 address of the network inteface"
echo "for connecting to the Internet. Setup must stop."
echo
hostname -I
route
echo
exit
fi
# We need a country code to generate a certificate signing request. However
# if a CSR already exists then we won't be generating a new one and there's
# no reason to ask for the country code now. $STORAGE_ROOT has not yet been
# set so we'll check if $DEFAULT_STORAGE_ROOT and $DEFAULT_CSR_COUNTRY are
# set (the values from the current mailinabox.conf) and if the CSR exists
# in the expected location.
if [ ! -z "$DEFAULT_STORAGE_ROOT" ] && [ ! -z "$DEFAULT_CSR_COUNTRY" ] && [ -f $DEFAULT_STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
CSR_COUNTRY=$DEFAULT_CSR_COUNTRY
fi
if [ -z "$CSR_COUNTRY" ]; then
# Get a list of country codes. Separate codes from country names with a ^.
# The input_menu function modifies shell word expansion to ignore spaces
# (since country names can have spaces) and use ^ instead.
country_code_list=$(grep -v "^#" setup/csr_country_codes.tsv | sed "s/\(..\)\t\([^\t]*\).*/\1^\2/")
input_menu "Country Code" \
"Choose the country where you live or where your organization is based.
\n\n(This is used to create an SSL certificate.)
\n\nCountry Code:" \
"$country_code_list" \
CSR_COUNTRY
if [ -z "$CSR_COUNTRY" ]; then
# user hit ESC/cancel
exit
fi
fi

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

@@ -1,16 +1,20 @@
#!/bin/bash
# Spam filtering with spamassassin via spampd # Spam filtering with spamassassin via spampd
############################################# # -------------------------------------------
#
# spampd sits between postfix and dovecot. It takes mail from postfix # spampd sits between postfix and dovecot. It takes mail from postfix
# over the LMTP protocol, runs spamassassin on it, and then passes the # over the LMTP protocol, runs spamassassin on it, and then passes the
# message over LMTP to dovecot for local delivery. # message over LMTP to dovecot for local delivery.
#
# In order to move spam automatically into the Spam folder we use the dovecot sieve # In order to move spam automatically into the Spam folder we use the dovecot sieve
# plugin. The tools/mail.py tool creates the necessary sieve script for each mail # plugin.
# user when the mail user is created.
source /etc/mailinabox.conf # get global vars
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
# Install packages and basic configuration
# ----------------------------------------
# Install packages. # Install packages.
apt_install spampd razor pyzor dovecot-antispam apt_install spampd razor pyzor dovecot-antispam
@@ -21,25 +25,57 @@ tools/editconf.py /etc/default/spamassassin \
# Configure pyzor. # Configure pyzor.
hide_output pyzor discover hide_output pyzor discover
# Pass messages on to docevot on port 10026. # Configure spampd:
# This is actually the default setting but we don't want to lose track of it. # * Pass messages on to docevot on port 10026. This is actually the default setting but we don't
# We've already configured Dovecot to listen on this port. # want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)
tools/editconf.py /etc/default/spampd DESTPORT=10026 # * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which
# is Spamassassin (spamc)'s own default. Specified in KBytes.
tools/editconf.py /etc/default/spampd \
DESTPORT=10026 \
ADDOPTS="\"--maxsize=500\""
# Enable the Dovecot antispam plugin to detect when a message moves between folders so we can # Spamassassin normally wraps spam as an attachment inside a fresh
# pass it to sa-learn for training. (Be careful if we use multiple plugins later.) # email with a report about the message. This also protects the user
sudo sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf # from accidentally openening a message with embedded malware.
#
# It's nice to see what rules caused the message to be marked as spam,
# but it's also annoying to get to the original message when it is an
# attachment, modern mail clients are safer now and don't load remote
# content or execute scripts, and it is probably confusing to most users.
#
# Tell Spamassassin not to modify the original message except for adding
# the X-Spam-Status mail header and related headers.
tools/editconf.py /etc/spamassassin/local.cf -s \
report_safe=0
# When mail is moved in or out of the Dovecot Spam folder, re-train using this script # Bayesean learning
# that sends the mail to spamassassin. # -----------------
# from http://wiki2.dovecot.org/Plugins/Antispam #
cat > /usr/bin/sa-learn-pipe.sh << EOF; # Spamassassin can learn from mail marked as spam or ham, but it needs to be
cat<&0 >> /tmp/sendmail-msg-\$\$.txt # configured. We'll store the learning data in our storage area.
/usr/bin/sa-learn \$* /tmp/sendmail-msg-\$\$.txt > /dev/null #
rm -f /tmp/sendmail-msg-\$\$.txt # These files must be:
exit 0 #
EOF # * Writable by sa-learn-pipe script below, which run as the 'mail' user, for manual tagging of mail as spam/ham.
chmod a+x /usr/bin/sa-learn-pipe.sh # * Readable by the spampd process ('spampd' user) during mail filtering.
# * Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.
#
# We'll have these files owned by spampd and grant access to the other two processes.
tools/editconf.py /etc/spamassassin/local.cf -s \
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes
mkdir -p $STORAGE_ROOT/mail/spamassassin
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
# To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll
# use the Dovecot antispam plugin to detect the message move operation and execute
# a shell script that invokes learning.
# Enable the Dovecot antispam plugin.
# (Be careful if we use multiple plugins later.) #NODOC
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-pop3.conf
# Configure the antispam plugin to call sa-learn-pipe.sh. # Configure the antispam plugin to call sa-learn-pipe.sh.
cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF; cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
@@ -47,12 +83,36 @@ plugin {
antispam_backend = pipe antispam_backend = pipe
antispam_spam_pattern_ignorecase = SPAM antispam_spam_pattern_ignorecase = SPAM
antispam_allow_append_to_spam = yes antispam_allow_append_to_spam = yes
antispam_pipe_program_spam_args = /usr/bin/sa-learn-pipe.sh;--spam antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
antispam_pipe_program_notspam_args = /usr/bin/sa-learn-pipe.sh;--ham antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
antispam_pipe_program = /bin/bash antispam_pipe_program = /bin/bash
} }
EOF EOF
# Have Dovecot run its mail process with a supplementary group (the spampd group)
# so that it can access the learning files.
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
mail_access_groups=spampd
# Here's the script that the antispam plugin executes. It spools the message into
# a temporary file and then runs sa-learn on it.
# from http://wiki2.dovecot.org/Plugins/Antispam
rm -f /usr/bin/sa-learn-pipe.sh # legacy location #NODOC
cat > /usr/local/bin/sa-learn-pipe.sh << EOF;
cat<&0 >> /tmp/sendmail-msg-\$\$.txt
/usr/bin/sa-learn \$* /tmp/sendmail-msg-\$\$.txt > /dev/null
rm -f /tmp/sendmail-msg-\$\$.txt
exit 0
EOF
chmod a+x /usr/local/bin/sa-learn-pipe.sh
# Create empty bayes training data (if it doesn't exist). Once the files exist,
# ensure they are group-writable so that the Dovecot process has access.
sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
chmod -R 660 $STORAGE_ROOT/mail/spamassassin
chmod 770 $STORAGE_ROOT/mail/spamassassin
# Initial training? # Initial training?
# sa-learn --ham storage/mail/mailboxes/*/*/cur/ # sa-learn --ham storage/mail/mailboxes/*/*/cur/
# sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/ # sa-learn --spam storage/mail/mailboxes/*/*/.Spam/cur/

View File

@@ -1,7 +1,8 @@
#!/bin/bash #!/bin/bash
# #
# SSL Certificate # SSL Certificate
# # ---------------
# Create a self-signed SSL certificate if one has not yet been created. # Create a self-signed SSL certificate if one has not yet been created.
# #
# The certificate is for PRIMARY_HOSTNAME specifically and is used for: # The certificate is for PRIMARY_HOSTNAME specifically and is used for:
@@ -21,22 +22,31 @@ source /etc/mailinabox.conf # load global vars
apt_install openssl apt_install openssl
mkdir -p $STORAGE_ROOT/ssl mkdir -p $STORAGE_ROOT/ssl
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then # Generate a new private key.
# Generate a new private key if one doesn't already exist.
# Set the umask so the key file is not world-readable. # Set the umask so the key file is not world-readable.
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
(umask 077; hide_output \ (umask 077; hide_output \
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048) openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
fi fi
# Generate a certificate signing request.
if [ ! -f $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then if [ ! -f $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
# Generate a certificate signing request if one doesn't already exist.
hide_output \ hide_output \
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr \ openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr \
-subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME" -sha256 -subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
fi fi
# Generate a SSL certificate by self-signing.
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
# Generate a SSL certificate by self-signing if a SSL certificate doesn't yet exist.
hide_output \ hide_output \
openssl x509 -req -days 365 \ openssl x509 -req -days 365 \
-in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem -in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem
fi fi
# For nginx and postfix, pre-generate some Diffie-Hellman cipher bits which is
# used when a Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
# provides Perfect Forward Secrecy. openssl's default is 1024 bits, but we'll
# create 2048.
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
fi

View File

@@ -4,53 +4,30 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
# Check system setup. # 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.
source setup/preflight.sh
# Are we running as root? # Ensure Python reads/writes files in UTF-8. If the machine
if [[ $EUID -ne 0 ]]; then # triggers some other locale in Python, like ASCII encoding,
echo "This script must be run as root. Please re-run like this:" # Python may not be able to read/write files. Here and in
echo # the management daemon startup script.
echo "sudo setup/start.sh"
echo if [ -z `locale -a | grep en_US.utf8` ]; then
exit # Generate locale if not exists
hide_output locale-gen en_US.UTF-8
fi fi
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx). export LANGUAGE=en_US.UTF-8
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "Ubuntu 14.04 LTS" ]; then export LC_ALL=en_US.UTF-8
echo "Mail-in-a-Box only supports being installed on Ubuntu 14.04, sorry. You are running:" export LANG=en_US.UTF-8
echo export LC_TYPE=en_US.UTF-8
lsb_release -d | sed 's/.*:\s*//'
echo
echo "We can't write scripts that run on every possible setup, sorry."
exit
fi
# Check that we have enough memory. Skip the check if we appear to be
# running inside of Vagrant, because that's really just for testing.
TOTAL_PHYSICAL_MEM=$(free -m | grep ^Mem: | sed "s/^Mem: *\([0-9]*\).*/\1/")
if [ $TOTAL_PHYSICAL_MEM -lt 768 ]; then
if [ ! -d /vagrant ]; then
echo "Your Mail-in-a-Box needs more than $TOTAL_PHYSICAL_MEM MB RAM."
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
exit
fi
fi
if [ -t 0 ]; then
# In an interactive shell...
echo
echo "Hello and thanks for deploying a Mail-in-a-Box!"
echo "-----------------------------------------------"
echo
echo "I'm going to ask you a few questions. To change your answers later,"
echo "later, just re-run this script."
fi
# Recall the last settings used if we're running this a second time. # Recall the last settings used if we're running this a second time.
if [ -f /etc/mailinabox.conf ]; then if [ -f /etc/mailinabox.conf ]; then
# Run any system migrations before proceeding. Since this is a second run, # Run any system migrations before proceeding. Since this is a second run,
# we assume we have Python already installed. # we assume we have Python already installed.
setup/migrate.py --migrate setup/migrate.py --migrate || exit 1
# Load the old .conf file to get existing configuration options loaded # Load the old .conf file to get existing configuration options loaded
# into variables with a DEFAULT_ prefix. # into variables with a DEFAULT_ prefix.
@@ -59,151 +36,19 @@ if [ -f /etc/mailinabox.conf ]; then
rm -f /tmp/mailinabox.prev.conf rm -f /tmp/mailinabox.prev.conf
fi fi
# The box needs a name. # Put a start script in a global location. We tell the user to run 'mailinabox'
if [ -z "$PRIMARY_HOSTNAME" ]; then # in the first dialog prompt, so we should do this before that starts.
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then cat > /usr/local/bin/mailinabox << EOF;
# This is the first run. Ask the user for his email address so we can #!/bin/bash
# provide the best default for the box's hostname. cd `pwd`
echo source setup/start.sh
echo "What email address are you setting this box up to manage?" EOF
echo "" chmod +x /usr/local/bin/mailinabox
echo "The part after the @-sign must be a domain name or subdomain"
echo "that you control. You can add other email addresses to this"
echo "box later (including email addresses on other domain names"
echo "or subdomains you control)."
echo
echo "We've guessed an email address. Backspace it and type in what"
echo "you really want."
echo
read -e -i "me@`get_default_hostname`" -p "Email Address: " EMAIL_ADDR
while ! management/mailconfig.py validate-email "$EMAIL_ADDR" # Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, PUBLIC_IPV6, and CSR_COUNTRY
do # if values have not already been set in environment variables. When running
echo "That's not a valid email address." # non-interactively, be sure to set values for all!
echo source setup/questions.sh
read -e -i "$EMAIL_ADDR" -p "Email Address: " EMAIL_ADDR
done
# Take the part after the @-sign as the user's domain name, and add
# 'box.' to the beginning to create a default hostname for this machine.
DEFAULT_PRIMARY_HOSTNAME=box.$(echo $EMAIL_ADDR | sed 's/.*@//')
fi
echo
echo "This box needs a name, called a 'hostname'. The name will form a part"
echo "of the box's web address."
echo
echo "We recommend that the name be a subdomain of the domain in your email"
echo "address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME."
echo
echo "You can change it, but we recommend you don't."
echo
read -e -i "$DEFAULT_PRIMARY_HOSTNAME" -p "Hostname: " PRIMARY_HOSTNAME
fi
# If the machine is behind a NAT, inside a VM, etc., it may not know
# its IP address on the public network / the Internet. Ask the Internet
# and possibly confirm with user.
if [ -z "$PUBLIC_IP" ]; then
# Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 4)
# On the first run, if we got an answer from the Internet then don't
# ask the user.
if [[ -z "$DEFAULT_PUBLIC_IP" && ! -z "$GUESSED_IP" ]]; then
PUBLIC_IP=$GUESSED_IP
# Otherwise on the first run at least provide a default.
elif [[ -z "$DEFAULT_PUBLIC_IP" ]]; then
DEFAULT_PUBLIC_IP=$(get_default_privateip 4)
# On later runs, if the previous value matches the guessed value then
# don't ask the user either.
elif [ "$DEFAULT_PUBLIC_IP" == "$GUESSED_IP" ]; then
PUBLIC_IP=$GUESSED_IP
fi
if [ -z "$PUBLIC_IP" ]; then
echo
echo "Enter the public IP address of this machine, as given to you by your ISP."
echo
read -e -i "$DEFAULT_PUBLIC_IP" -p "Public IP: " PUBLIC_IP
fi
fi
# Same for IPv6. But it's optional. Also, if it looks like the system
# doesn't have an IPv6, don't ask for one.
if [ -z "$PUBLIC_IPV6" ]; then
# Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 6)
MATCHED=0
if [[ -z "$DEFAULT_PUBLIC_IPV6" && ! -z "$GUESSED_IP" ]]; then
PUBLIC_IPV6=$GUESSED_IP
elif [[ "$DEFAULT_PUBLIC_IPV6" == "$GUESSED_IP" ]]; then
# No IPv6 entered and machine seems to have none, or what
# the user entered matches what the Internet tells us.
PUBLIC_IPV6=$GUESSED_IP
MATCHED=1
elif [[ -z "$DEFAULT_PUBLIC_IPV6" ]]; then
DEFAULT_PUBLIC_IP=$(get_default_privateip 6)
fi
if [[ -z "$PUBLIC_IPV6" && $MATCHED == 0 ]]; then
echo
echo "Optional:"
echo "Enter the public IPv6 address of this machine, as given to you by your ISP."
echo "Leave blank if the machine does not have an IPv6 address."
echo
read -e -i "$DEFAULT_PUBLIC_IPV6" -p "Public IPv6: " PUBLIC_IPV6
fi
fi
# Get the IP addresses of the local network interface(s) that are connected
# to the Internet. We need these when we want to have services bind only to
# the public network interfaces (not loopback, not tunnel interfaces).
if [ -z "$PRIVATE_IP" ]; then
PRIVATE_IP=$(get_default_privateip 4)
fi
if [ -z "$PRIVATE_IPV6" ]; then
PRIVATE_IPV6=$(get_default_privateip 6)
fi
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
echo
echo "I could not determine the IP or IPv6 address of the network inteface"
echo "for connecting to the Internet. Setup must stop."
echo
hostname -I
route
echo
exit
fi
# We need a country code to generate a certificate signing request. However
# if a CSR already exists then we won't be generating a new one and there's
# no reason to ask for the country code now. $STORAGE_ROOT has not yet been
# set so we'll check if $DEFAULT_STORAGE_ROOT and $DEFAULT_CSR_COUNTRY are
# set (the values from the current mailinabox.conf) and if the CSR exists
# in the expected location.
if [ ! -z "$DEFAULT_STORAGE_ROOT" ] && [ ! -z "$DEFAULT_CSR_COUNTRY" ] && [ -f $DEFAULT_STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
CSR_COUNTRY=$DEFAULT_CSR_COUNTRY
fi
if [ -z "$CSR_COUNTRY" ]; then
echo
echo "Enter the two-letter, uppercase country code for where you"
echo "live or where your organization is based. (This is used to"
echo "create an SSL certificate.)"
echo
#if [ -z "$DEFAULT_CSR_COUNTRY" ]; then
# # set a default on first run
# DEFAULT_CSR_COUNTRY=...?
#fi
read -e -i "$DEFAULT_CSR_COUNTRY" -p "Country Code: " CSR_COUNTRY
fi
# Automatic configuration, e.g. as used in our Vagrant configuration. # Automatic configuration, e.g. as used in our Vagrant configuration.
if [ "$PUBLIC_IP" = "auto" ]; then if [ "$PUBLIC_IP" = "auto" ]; then
@@ -232,6 +77,9 @@ fi
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
echo "Private IPv6 Address: $PRIVATE_IPV6" echo "Private IPv6 Address: $PRIVATE_IPV6"
fi fi
if [ -f .git ]; then
echo "Mail-in-a-Box Version: " $(git describe)
fi
echo echo
# Run some network checks to make sure setup on this machine makes sense. # Run some network checks to make sure setup on this machine makes sense.
@@ -239,17 +87,37 @@ if [ -z "$SKIP_NETWORK_CHECKS" ]; then
. setup/network-checks.sh . setup/network-checks.sh
fi fi
# For the first time (if the config file (/etc/mailinabox.conf) not exists):
# Create the user named "user-data" and store all persistent user # Create the user named "user-data" and store all persistent user
# data (mailboxes, etc.) in that user's home directory. # data (mailboxes, etc.) in that user's home directory.
#
# If the config file exists:
# Apply the existing configuration options for STORAGE_USER/ROOT
if [ -z "$STORAGE_USER" ]; then
STORAGE_USER=$([[ -z "$DEFAULT_STORAGE_USER" ]] && echo "user-data" || echo "$DEFAULT_STORAGE_USER")
fi
if [ -z "$STORAGE_ROOT" ]; then if [ -z "$STORAGE_ROOT" ]; then
STORAGE_USER=user-data STORAGE_ROOT=$([[ -z "$DEFAULT_STORAGE_ROOT" ]] && echo "/home/$STORAGE_USER" || echo "$DEFAULT_STORAGE_ROOT")
if [ ! -d /home/$STORAGE_USER ]; then useradd -m $STORAGE_USER; fi fi
STORAGE_ROOT=/home/$STORAGE_USER
# Create the STORAGE_USER if it not exists
if ! id -u $STORAGE_USER >/dev/null 2>&1; then
useradd -m $STORAGE_USER
fi
# Create the STORAGE_ROOT if it not exists
if [ ! -d $STORAGE_ROOT ]; then
mkdir -p $STORAGE_ROOT mkdir -p $STORAGE_ROOT
fi
# Create mailinabox.version file if not exists
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
fi fi
# Save the global options in /etc/mailinabox.conf so that standalone # Save the global options in /etc/mailinabox.conf so that standalone
# tools know where to look for data. # tools know where to look for data.
cat > /etc/mailinabox.conf << EOF; cat > /etc/mailinabox.conf << EOF;
@@ -264,64 +132,32 @@ CSR_COUNTRY=$CSR_COUNTRY
EOF EOF
# Start service configuration. # Start service configuration.
. setup/system.sh source setup/system.sh
. setup/ssl.sh source setup/ssl.sh
. setup/dns.sh source setup/dns.sh
. setup/mail-postfix.sh source setup/mail-postfix.sh
. setup/mail-dovecot.sh source setup/mail-dovecot.sh
. setup/mail-users.sh source setup/mail-users.sh
. setup/dkim.sh source setup/dkim.sh
. setup/spamassassin.sh source setup/spamassassin.sh
. setup/web.sh source setup/web.sh
. setup/webmail.sh source setup/webmail.sh
. setup/owncloud.sh source setup/owncloud.sh
. setup/zpush.sh source setup/zpush.sh
. setup/management.sh source setup/management.sh
# Write the DNS and nginx configuration files. # Ping the management daemon to write the DNS and nginx configuration files.
sleep 5 # wait for the daemon to start while [ ! -f /var/lib/mailinabox/api.key ]; do
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update echo Waiting for the Mail-in-a-Box management daemon to start...
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update sleep 2
done
tools/dns_update
tools/web_update
# If there aren't any mail users yet, create one. # If there aren't any mail users yet, create one.
if [ -z "`tools/mail.py user`" ]; then source setup/firstuser.sh
# The outut of "tools/mail.py user" is a list of mail users. If there
# aren't any yet, it'll be empty.
# If we didn't ask for an email address at the start, do so now.
if [ -z "$EMAIL_ADDR" ]; then
# In an interactive shell, ask the user for an email address.
if [ -t 0 ]; then
echo
echo "Let's create your first mail user."
read -e -i "user@$PRIMARY_HOSTNAME" -p "Email Address: " EMAIL_ADDR
# But in a non-interactive shell, just make something up. This
# is normally for testing.
else
# Use me@PRIMARY_HOSTNAME
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
EMAIL_PW=1234
echo
echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
echo
fi
else
echo
echo "Okay. I'm about to set up $EMAIL_ADDR for you. This account will also"
echo "have access to the box's control panel."
fi
# Create the user's mail account. This will ask for a password if none was given above.
tools/mail.py user add $EMAIL_ADDR $EMAIL_PW
# Make it an admin.
hide_output tools/mail.py user make-admin $EMAIL_ADDR
# Create an alias to which we'll direct all automatically-created administrative aliases.
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
fi
# Done.
echo echo
echo "-----------------------------------------------" echo "-----------------------------------------------"
echo echo
@@ -329,21 +165,21 @@ echo Your Mail-in-a-Box is running.
echo echo
echo Please log in to the control panel for further instructions at: echo Please log in to the control panel for further instructions at:
echo echo
if management/whats_next.py --check-primary-hostname; then if management/status_checks.py --check-primary-hostname; then
# Show the nice URL if it appears to be resolving and has a valid certificate. # Show the nice URL if it appears to be resolving and has a valid certificate.
echo https://$PRIMARY_HOSTNAME/admin echo https://$PRIMARY_HOSTNAME/admin
echo echo
echo If there are problems with this URL, instead use: echo If you have a DNS problem use the box\'s IP address and check the SSL fingerprint:
echo echo https://$PUBLIC_IP/admin
fi else
echo https://$PUBLIC_IP/admin echo https://$PUBLIC_IP/admin
echo echo
echo You will be alerted that the website has an invalid certificate. Check that echo You will be alerted that the website has an invalid certificate. Check that
echo the certificate fingerprint matches: echo the certificate fingerprint matches:
echo echo
fi
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \ openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
| sed "s/SHA1 Fingerprint=//" | sed "s/SHA1 Fingerprint=//"
echo echo
echo Then you can confirm the security exception and continue. echo Then you can confirm the security exception and continue.
echo echo

View File

@@ -1,60 +1,94 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
# Base system configuration. # Basic System Configuration
# -------------------------
# ### Install Packages
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
echo Updating system packages... echo Updating system packages...
hide_output apt-get update hide_output apt-get update
hide_output apt-get -y upgrade apt_get_quiet upgrade
# Install basic utilities. # Install basic utilities.
# #
# haveged: Provides extra entropy to /dev/random so it doesn't stall # * haveged: Provides extra entropy to /dev/random so it doesn't stall
# when generating random numbers for private keys (e.g. during # when generating random numbers for private keys (e.g. during
# ldns-keygen). # ldns-keygen).
# * unattended-upgrades: Apt tool to install security updates automatically.
# * cron: Runs background processes periodically.
# * ntp: keeps the system time correct
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
# * git: we install some things directly from github
# * sudo: allows privileged users to execute commands as root without being root
# * coreutils: includes `nproc` tool to report number of processors
# * bc: allows us to do math to compute sane defaults
apt_install python3 python3-pip wget curl bind9-host haveged apt_install python3 python3-dev python3-pip \
wget curl git sudo coreutils bc \
haveged unattended-upgrades cron ntp fail2ban
# Turn on basic services: # Allow apt to install system updates automatically every day.
#
# ntp: keeps the system time correct
#
# fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
#
# These services don't need further configuration and are started immediately after installation.
apt_install ntp fail2ban cat > /etc/apt/apt.conf.d/02periodic <<EOF;
APT::Periodic::MaxAge "7";
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Verbose "1";
EOF
# ### 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
if [ -z "$DISABLE_FIREWALL" ]; then if [ -z "$DISABLE_FIREWALL" ]; then
# Turn on the firewall. First allow incoming SSH, then turn on the firewall. # Install `ufw` which provides a simple firewall configuration.
# Other ports will be opened at the point where we set up those services.
#
# Various virtualized environments like Docker and some VPSs don't provide
# a kernel that supports iptables. To avoid error-like output in these cases,
# let us disable the firewall.
apt_install ufw apt_install ufw
# Allow incoming connections to SSH.
ufw_allow ssh; ufw_allow ssh;
ufw --force enable;
# 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. #NODOC
ufw_allow $SSH_PORT #NODOC
fi
fi fi
# Resolve DNS using bind9 locally, rather than whatever DNS server is supplied ufw --force enable;
# by the machine's network configuration. We do this to ensure that DNS queries fi #NODOC
# ### Local DNS Service
# Install a local DNS server, rather than using the DNS server provided by the
# ISP's network configuration.
#
# We do this to ensure that DNS queries
# that *we* make (i.e. looking up other external domains) perform DNSSEC checks. # that *we* make (i.e. looking up other external domains) perform DNSSEC checks.
# We could use Google's Public DNS, but we don't want to create a dependency on # We could use Google's Public DNS, but we don't want to create a dependency on
# Google per our goals of decentralization. bind9, as packaged for Ubuntu, has # Google per our goals of decentralization. `bind9`, as packaged for Ubuntu, has
# DNSSEC enabled by default via "dnssec-validation auto". # DNSSEC enabled by default via "dnssec-validation auto".
# #
# So we'll be running bind9 bound to 127.0.0.1 for locally-issued DNS queries # So we'll be running `bind9` bound to 127.0.0.1 for locally-issued DNS queries
# and nsd bound to the public ethernet interface for remote DNS queries asking # and `nsd` bound to the public ethernet interface for remote DNS queries asking
# about our domain names. nsd is configured in dns.sh. # about our domain names. `nsd` is configured later.
# #
# About the settings: # About the settings:
# #
# * RESOLVCONF=yes will have bind9 take over /etc/resolv.conf to tell # * RESOLVCONF=yes will have `bind9` take over /etc/resolv.conf to tell
# local services that DNS queries are handled on localhost. # local services that DNS queries are handled on localhost.
# * Adding -4 to OPTIONS will have bind9 not listen on IPv6 addresses # * Adding -4 to OPTIONS will have `bind9` not listen on IPv6 addresses
# so that we're sure there's no conflict with nsd, our public domain # so that we're sure there's no conflict with nsd, our public domain
# name server, on IPV6. # name server, on IPV6.
# * The listen-on directive in named.conf.options restricts bind9 to # * The listen-on directive in named.conf.options restricts `bind9` to
# binding to the loopback interface instead of all interfaces. # binding to the loopback interface instead of all interfaces.
apt_install bind9 resolvconf apt_install bind9 resolvconf
tools/editconf.py /etc/default/bind9 \ tools/editconf.py /etc/default/bind9 \
@@ -65,9 +99,19 @@ if ! grep -q "listen-on " /etc/bind/named.conf.options; then
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
fi fi
if [ -f /etc/resolvconf/resolv.conf.d/original ]; then if [ -f /etc/resolvconf/resolv.conf.d/original ]; then
echo "Archiving old resolv.conf (was /etc/resolvconf/resolv.conf.d/original, now /etc/resolvconf/resolv.conf.original)." echo "Archiving old resolv.conf (was /etc/resolvconf/resolv.conf.d/original, now /etc/resolvconf/resolv.conf.original)." #NODOC
mv /etc/resolvconf/resolv.conf.d/original /etc/resolvconf/resolv.conf.original mv /etc/resolvconf/resolv.conf.d/original /etc/resolvconf/resolv.conf.original #NODOC
fi fi
# Restart the DNS services.
restart_service bind9 restart_service bind9
restart_service resolvconf restart_service resolvconf
# ### Fail2Ban Service
# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh
cp conf/fail2ban/jail.local /etc/fail2ban/jail.local
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf
restart_service fail2ban

View File

@@ -5,55 +5,86 @@
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# Some Ubuntu images start off with Apache. Remove it since we
# will use nginx. Use autoremove to remove any Apache depenencies.
if [ -f /usr/sbin/apache2 ]; then
echo Removing apache...
hide_output apt-get -y purge apache2 apache2-*
hide_output apt-get -y --purge autoremove
fi
# Install nginx and a PHP FastCGI daemon.
#
# Turn off nginx's default website.
apt_install nginx php5-fpm apt_install nginx php5-fpm
rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-enabled/default
# copy in a nginx configuration file for common and best-practices # Copy in a nginx configuration file for common and best-practices
# SSL settings from @konklone # SSL settings from @konklone. Replace STORAGE_ROOT so it can find
cp conf/nginx-ssl.conf /etc/nginx/nginx-ssl.conf # the DH params.
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
conf/nginx-ssl.conf > /etc/nginx/nginx-ssl.conf
# Fix some nginx defaults. # Fix some nginx defaults.
# The server_names_hash_bucket_size seems to prevent long domain names? # The server_names_hash_bucket_size seems to prevent long domain names?
tools/editconf.py /etc/nginx/nginx.conf -s \ tools/editconf.py /etc/nginx/nginx.conf -s \
server_names_hash_bucket_size="64;" server_names_hash_bucket_size="64;"
# Bump up PHP's max_children to support more concurrent connections
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
pm.max_children=8
# Other nginx settings will be configured by the management service # Other nginx settings will be configured by the management service
# since it depends on what domains we're serving, which we don't know # since it depends on what domains we're serving, which we don't know
# until mail accounts have been created. # until mail accounts have been created.
# Create the iOS/OS X Mobile Configuration file which is exposed via the
# nginx configuration at /mailinabox-mobileconfig.
mkdir -p /var/lib/mailinabox
chmod a+rx /var/lib/mailinabox
cat conf/ios-profile.xml \
| sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \
| sed "s/UUID1/$(cat /proc/sys/kernel/random/uuid)/" \
| sed "s/UUID2/$(cat /proc/sys/kernel/random/uuid)/" \
| sed "s/UUID3/$(cat /proc/sys/kernel/random/uuid)/" \
| sed "s/UUID4/$(cat /proc/sys/kernel/random/uuid)/" \
> /var/lib/mailinabox/mobileconfig.xml
chmod a+r /var/lib/mailinabox/mobileconfig.xml
# Create the Mozilla Auto-configuration file which is exposed via the
# nginx configuration at /.well-known/autoconfig/mail/config-v1.1.xml.
# The format of the file is documented at:
# https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
# and https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration/FileFormat/HowTo.
cat conf/mozilla-autoconfig.xml \
| sed "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" \
> /var/lib/mailinabox/mozilla-autoconfig.xml
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
# make a default homepage # make a default homepage
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
mkdir -p $STORAGE_ROOT/www/default mkdir -p $STORAGE_ROOT/www/default
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
fi fi
chown -R $STORAGE_USER $STORAGE_ROOT/www chown -R $STORAGE_USER $STORAGE_ROOT/www
# We previously installed a custom init script to start the PHP FastCGI daemon. # We previously installed a custom init script to start the PHP FastCGI daemon. #NODOC
# Remove it now that we're using php5-fpm. # Remove it now that we're using php5-fpm. #NODOC
if [ -L /etc/init.d/php-fastcgi ]; then if [ -L /etc/init.d/php-fastcgi ]; then
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..." echo "Removing /etc/init.d/php-fastcgi, php5-cgi..." #NODOC
rm -f /etc/init.d/php-fastcgi rm -f /etc/init.d/php-fastcgi #NODOC
hide_output update-rc.d php-fastcgi remove hide_output update-rc.d php-fastcgi remove #NODOC
apt-get -y purge php5-cgi apt-get -y purge php5-cgi #NODOC
fi fi
# Put our webfinger script into a well-known location. # Remove obsoleted scripts. #NODOC
for f in webfinger; do # exchange-autodiscover is now handled by Z-Push. #NODOC
cp tools/$f.php /usr/local/bin/mailinabox-$f.php for f in webfinger exchange-autodiscover; do #NODOC
chown www-data.www-data /usr/local/bin/mailinabox-$f.php rm -f /usr/local/bin/mailinabox-$f.php #NODOC
done done #NODOC
# Remove obsoleted scripts.
# exchange-autodiscover is now handled by Z-Push.
for f in exchange-autodiscover; do
rm /usr/local/bin/mailinabox-$f.php
done
# Make some space for users to customize their webfinger responses.
mkdir -p $STORAGE_ROOT/webfinger/acct;
chown -R $STORAGE_USER $STORAGE_ROOT/webfinger
# Start services. # Start services.
restart_service nginx restart_service nginx

View File

@@ -1,49 +1,83 @@
# Webmail: Using roundcube #!/bin/bash
########################## # Webmail with Roundcube
# ----------------------
source setup/functions.sh # load our functions source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars source /etc/mailinabox.conf # load global vars
# Ubuntu's roundcube-core has dependencies on Apache & MySQL, which we don't want, so we can't # ### Installing Roundcube
# install roundcube directly via apt-get install.
# We install Roundcube from sources, rather than from Ubuntu, because:
# #
# Additionally, the Roundcube shipped with Ubuntu is consistently out of date. # 1. Ubuntu's `roundcube-core` package has dependencies on Apache & MySQL, which we don't want.
# #
# And it's packaged incorrectly --- it seems to be missing a directory of files. # 2. The Roundcube shipped with Ubuntu is consistently out of date.
#
# 3. It's packaged incorrectly --- it seems to be missing a directory of files.
# #
# So we'll use apt-get to manually install the dependencies of roundcube that we know we need, # So we'll use apt-get to manually install the dependencies of roundcube that we know we need,
# and then we'll manually install roundcube from source. # and then we'll manually install roundcube from source.
# These dependencies are from 'apt-cache showpkg roundcube-core'. # These dependencies are from `apt-cache showpkg roundcube-core`.
apt_install \ apt_install \
dbconfig-common \ dbconfig-common \
php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-crypt-gpg php5-gd php5-pspell \ php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-crypt-gpg php5-gd php5-pspell \
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1 tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
# We used to install Roundcube from Ubuntu, without triggering the dependencies # We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
# on Apache and MySQL, by downloading the debs and installing them manually. # on Apache and MySQL, by downloading the debs and installing them manually. #NODOC
# Now that we're beyond that, get rid of those debs before installing from source. # Now that we're beyond that, get rid of those debs before installing from source. #NODOC
apt-get purge -qq -y roundcube* apt-get purge -qq -y roundcube* #NODOC
# Install Roundcube from source if it is not already present. # Install Roundcube from source if it is not already present or if it is out of date.
# TODO: Check version? # Combine the Roundcube version number with the commit hash of vacation_sieve to track
if [ ! -d /usr/local/lib/roundcubemail ]; then # whether we have the latest version.
rm -f /tmp/roundcube.tgz VERSION=1.1.1
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/1.0.1/roundcubemail-1.0.1.tar.gz HASH=08222f382a8dd89bba7dbbad595f48443bec0aa2
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
PERSISTENT_LOGIN_VERSION=9a0bc59493beb573d515f82aec443e2098365d11
UPDATE_KEY=$VERSION:$VACATION_SIEVE_VERSION:$PERSISTENT_LOGIN_VERSION
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
# not installed yet #NODOC
needs_update=1 #NODOC
elif [[ "$UPDATE_KEY" != `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
# install roundcube
echo installing Roundcube webmail $VERSION...
wget_verify \
http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz \
$HASH \
/tmp/roundcube.tgz
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
mv /usr/local/lib/roundcubemail-1.0.1/ /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 rm -f /tmp/roundcube.tgz
# install roundcube autoreply/vacation plugin
git_clone https://github.com/arodier/Roundcube-Plugins.git $VACATION_SIEVE_VERSION plugins/vacation_sieve /usr/local/lib/roundcubemail/plugins/vacation_sieve
# install roundcube persistent_login plugin
git_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' /usr/local/lib/roundcubemail/plugins/persistent_login
# record the version we've installed
echo $UPDATE_KEY > /usr/local/lib/roundcubemail/version
fi fi
# ### Configuring Roundcube
# Generate a safe 24-character secret key of safe characters. # Generate a safe 24-character secret key of safe characters.
SECRET_KEY=$(dd if=/dev/random bs=20 count=1 2>/dev/null | base64 | fold -w 24 | head -n 1) SECRET_KEY=$(dd if=/dev/random bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)
# Create a configuration file. # Create a configuration file.
# #
# For security, temp and log files are not stored in the default locations # For security, temp and log files are not stored in the default locations
# which are inside the roundcube sources directory. We put them instead # which are inside the roundcube sources directory. We put them instead
# in normal places. # in normal places.
cat - > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF; cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
<?php <?php
/* /*
* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. * Do not edit. Written by Mail-in-a-Box. Regenerated on updates.
@@ -62,7 +96,7 @@ cat - > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
\$config['support_url'] = 'https://mailinabox.email/'; \$config['support_url'] = 'https://mailinabox.email/';
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail'; \$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
\$config['des_key'] = '$SECRET_KEY'; \$config['des_key'] = '$SECRET_KEY';
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve'); \$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve', 'persistent_login');
\$config['skin'] = 'classic'; \$config['skin'] = 'classic';
\$config['login_autocomplete'] = 2; \$config['login_autocomplete'] = 2;
\$config['password_charset'] = 'UTF-8'; \$config['password_charset'] = 'UTF-8';
@@ -70,6 +104,26 @@ cat - > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
?> ?>
EOF EOF
# Configure vaction_sieve.
cat > /usr/local/lib/roundcubemail/plugins/vacation_sieve/config.inc.php <<EOF;
<?php
/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */
\$rcmail_config['vacation_sieve'] = array(
'date_format' => 'd/m/Y',
'working_hours' => array(8,18),
'msg_format' => 'text',
'logon_transform' => array('#([a-z])[a-z]+(\.|\s)([a-z])#i', '\$1\$3'),
'transfer' => array(
'mode' => 'managesieve',
'ms_activate_script' => true,
'host' => 'localhost',
'port' => '4190',
'usetls' => false,
'path' => 'vacation',
)
);
EOF
# Create writable directories. # Create writable directories.
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube

View File

@@ -1,7 +1,9 @@
#!/bin/bash #!/bin/bash
# #
# Z-Push: The Microsoft Exchange protocol server. # Z-Push: The Microsoft Exchange protocol server
# Mostly for use on iOS which doesn't support IMAP. # ----------------------------------------------
#
# Mostly for use on iOS which doesn't support IMAP IDLE.
# #
# Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache # Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache
# so we won't install it that way. # so we won't install it that way.
@@ -19,30 +21,27 @@ apt_install \
php5enmod imap php5enmod imap
# Copy Z-Push into place. # Copy Z-Push into place.
needs_update=0 TARGETHASH=d0cd5a47c53afac5c3b287006dc8a48a1c4ffcd5
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/z-push/version ]; then if [ ! -f /usr/local/lib/z-push/version ]; then
needs_update=1 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 # checks if the version
needs_update=1 needs_update=1 #NODOC
fi fi
if [ $needs_update == 1 ]; then if [ $needs_update == 1 ]; then
rm -rf /usr/local/lib/z-push
rm -f /tmp/zpush.zip
echo installing z-push \(fmbiete fork\)... echo installing z-push \(fmbiete fork\)...
wget -qO /tmp/zpush.zip https://github.com/fmbiete/Z-Push-contrib/archive/master.zip git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
unzip -q /tmp/zpush.zip -d /usr/local/lib/
mv /usr/local/lib/Z-Push-contrib-master /usr/local/lib/z-push
rm -f /usr/sbin/z-push-{admin,top} rm -f /usr/sbin/z-push-{admin,top}
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
rm /tmp/zpush.zip; echo $TARGETHASH > /usr/local/lib/z-push/version
curl -s https://api.github.com/repos/fmbiete/Z-Push-contrib/git/refs/heads/master > /usr/local/lib/z-push/version
fi fi
# Configure default config. # Configure default config.
sed -i "s/define('TIMEZONE', .*/define('TIMEZONE', 'Etc\/UTC');/" /usr/local/lib/z-push/config.php 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('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 # Configure BACKEND
rm -f /usr/local/lib/z-push/backend/combined/config.php rm -f /usr/local/lib/z-push/backend/combined/config.php

164
tests/tls.py Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/python3
# Runs SSLyze on the TLS endpoints of a box and outputs
# the results so we can inspect the settings and compare
# against a known good version in tls_results.txt.
#
# Make sure you have SSLyze available:
# wget https://github.com/nabla-c0d3/sslyze/releases/download/release-0.11/sslyze-0_11-linux64.zip
# unzip sslyze-0_11-linux64.zip
#
# Then run:
#
# python3 tls.py yourservername
#
# If you are on a residential network that blocks outbound
# port 25 connections, then you can proxy the connections
# through some other host you can ssh into (maybe the box
# itself?):
#
# python3 --proxy user@ssh_host yourservername
#
# (This will launch "ssh -N -L10023:yourservername:testport user@ssh_host"
# to create a tunnel.)
import sys, subprocess, re, time, json, csv, io, urllib.request
######################################################################
# PARSE COMMAND LINE
proxy = None
args = list(sys.argv[1:])
while len(args) > 0:
if args[0] == "--proxy":
args.pop(0)
proxy = args.pop(0)
break
if len(args) == 0:
print("Usage: python3 tls.py [--proxy ssh_host] hostname")
sys.exit(0)
host = args[0]
######################################################################
SSLYZE = "sslyze-0_11-linux64/sslyze/sslyze.py"
common_opts = ["--sslv2", "--sslv3", "--tlsv1", "--tlsv1_1", "--tlsv1_2", "--reneg", "--resum",
"--hide_rejected_ciphers", "--compression", "--heartbleed"]
# Recommendations from Mozilla as of May 20, 2015 at
# https://wiki.mozilla.org/Security/Server_Side_TLS.
#
# The 'modern' ciphers support Firefox 27, Chrome 22, IE 11,
# Opera 14, Safari 7, Android 4.4, Java 8. Assumes TLSv1.1,
# TLSv1.2 only, though we may also be allowing TLSv3.
#
# The 'intermediate' ciphers support Firefox 1, Chrome 1, IE 7,
# Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7.
# Assumes TLSv1, TLSv1.1, TLSv1.2.
#
# The 'old' ciphers bring compatibility back to Win XP IE 6.
MOZILLA_CIPHERS_MODERN = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK"
MOZILLA_CIPHERS_INTERMEDIATE = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA"
MOZILLA_CIPHERS_OLD = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA"
######################################################################
def sslyze(opts, port, ok_ciphers):
# Print header.
header = ("PORT %d" % port)
print(header)
print("-" * (len(header)))
# What ciphers should we expect?
ok_ciphers = subprocess.check_output(["openssl", "ciphers", ok_ciphers]).decode("utf8").strip().split(":")
# Form the SSLyze connection string.
connection_string = host + ":" + str(port)
# Proxy via SSH.
proxy_proc = None
if proxy:
connection_string = "localhost:10023"
proxy_proc = subprocess.Popen(["ssh", "-N", "-L10023:%s:%d" % (host, port), proxy])
time.sleep(3)
try:
# Execute SSLyze.
out = subprocess.check_output([SSLYZE] + common_opts + opts + [connection_string])
out = out.decode("utf8")
# Trim output to make better for storing in git.
if "SCAN RESULTS FOR" not in out:
# Failed. Just output the error.
out = re.sub("[\w\W]*CHECKING HOST\(S\) AVAILABILITY\n\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("[\w\W]*SCAN RESULTS FOR.*\n\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("SCAN COMPLETED IN .*", "", out)
out = out.rstrip(" \n-") + "\n"
# Print.
print(out)
# Pull out the accepted ciphers list for each SSL/TLS protocol
# version outputted.
accepted_ciphers = set()
for ciphers in re.findall(" Accepted:([\w\W]*?)\n *\n", out):
accepted_ciphers |= set(re.findall("\n\s*(\S*)", ciphers))
# Compare to what Mozilla recommends, for a given modernness-level.
print(" Should Not Offer: " + (", ".join(sorted(accepted_ciphers-set(ok_ciphers))) or "(none -- good)"))
print(" Could Also Offer: " + (", ".join(sorted(set(ok_ciphers)-accepted_ciphers)) or "(none -- good)"))
# What clients does that mean we support on this protocol?
supported_clients = { }
for cipher in accepted_ciphers:
if cipher in cipher_clients:
for client in cipher_clients[cipher]:
supported_clients[client] = supported_clients.get(client, 0) + 1
print(" Supported Clients: " + (", ".join(sorted(supported_clients.keys(), key = lambda client : -supported_clients[client]))))
# Blank line.
print()
finally:
if proxy_proc:
proxy_proc.terminate()
try:
proxy_proc.wait(5)
except TimeoutExpired:
proxy_proc.kill()
# Get a list of OpenSSL cipher names.
cipher_names = { }
for cipher in csv.DictReader(io.StringIO(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/cipher_names.csv").read().decode("utf8"))):
# not sure why there are some multi-line values, use first line:
cipher["OpenSSL"] = cipher["OpenSSL"].split("\n")[0]
cipher_names[cipher["IANA"]] = cipher["OpenSSL"]
# Get a list of what clients support what ciphers, using OpenSSL cipher names.
client_compatibility = json.loads(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json").read().decode("utf8"))
cipher_clients = { }
for client in client_compatibility:
if len(set(client['protocols']) & set(["TLS 1.0", "TLS 1.1", "TLS 1.2"])) == 0: continue # does not support TLS
for cipher in client['ciphers']:
cipher_clients.setdefault(cipher_names.get(cipher), set()).add("/".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x))
# Run SSLyze on various ports.
# SMTP
sslyze(["--starttls=smtp"], 25, MOZILLA_CIPHERS_OLD)
# SMTP Submission
sslyze(["--starttls=smtp"], 587, MOZILLA_CIPHERS_MODERN)
# HTTPS
sslyze(["--http_get", "--chrome_sha1", "--hsts"], 443, MOZILLA_CIPHERS_INTERMEDIATE)
# IMAP
sslyze([], 993, MOZILLA_CIPHERS_MODERN)
# POP3
sslyze([], 995, MOZILLA_CIPHERS_MODERN)

431
tests/tls_results.txt Normal file
View File

@@ -0,0 +1,431 @@
PORT 25
-------
* Deflate Compression:
OK - Compression disabled
* Session Renegotiation:
Client-initiated Renegotiations: VULNERABLE - Server honors client-initiated renegotiations
Secure Renegotiation: OK - Supported
* OpenSSL Heartbleed:
OK - Not vulnerable to Heartbleed
* Session Resumption:
With Session IDs: OK - Supported (5 successful, 0 failed, 0 errors, 5 total attempts).
With TLS Session Tickets: NOT SUPPORTED - TLS ticket not assigned.
* SSLV2 Cipher Suites:
Server rejected all cipher suites.
* TLSV1_2 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-GCM-SHA384 ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA384 ECDH-256 bits 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES256-GCM-SHA384 ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA256 DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-GCM-SHA384 DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA256 - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
AES256-GCM-SHA384 - 256 bits 250 2.0.0 Ok
ECDHE-RSA-RC4-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA256 ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-GCM-SHA256 ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA256 DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-GCM-SHA256 DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
RC4-SHA - 128 bits 250 2.0.0 Ok
RC4-MD5 - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA256 - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
AES128-GCM-SHA256 - 128 bits 250 2.0.0 Ok
ECDHE-RSA-DES-CBC3-SHA ECDH-256 bits 112 bits 250 2.0.0 Ok
EDH-RSA-DES-CBC3-SHA DH-2048 bits 112 bits 250 2.0.0 Ok
DES-CBC3-SHA - 112 bits 250 2.0.0 Ok
* TLSV1_1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
ECDHE-RSA-RC4-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
RC4-SHA - 128 bits 250 2.0.0 Ok
RC4-MD5 - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
ECDHE-RSA-DES-CBC3-SHA ECDH-256 bits 112 bits 250 2.0.0 Ok
EDH-RSA-DES-CBC3-SHA DH-2048 bits 112 bits 250 2.0.0 Ok
DES-CBC3-SHA - 112 bits 250 2.0.0 Ok
* SSLV3 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
ECDHE-RSA-RC4-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
RC4-SHA - 128 bits 250 2.0.0 Ok
RC4-MD5 - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
ECDHE-RSA-DES-CBC3-SHA ECDH-256 bits 112 bits 250 2.0.0 Ok
EDH-RSA-DES-CBC3-SHA DH-2048 bits 112 bits 250 2.0.0 Ok
DES-CBC3-SHA - 112 bits 250 2.0.0 Ok
* TLSV1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
ECDHE-RSA-RC4-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
RC4-SHA - 128 bits 250 2.0.0 Ok
RC4-MD5 - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
ECDHE-RSA-DES-CBC3-SHA ECDH-256 bits 112 bits 250 2.0.0 Ok
EDH-RSA-DES-CBC3-SHA DH-2048 bits 112 bits 250 2.0.0 Ok
DES-CBC3-SHA - 112 bits 250 2.0.0 Ok
Should Not Offer: DHE-RSA-SEED-SHA, ECDHE-RSA-RC4-SHA, EDH-RSA-DES-CBC3-SHA, RC4-MD5, RC4-SHA, SEED-SHA
Could Also Offer: DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES128-SHA, DHE-DSS-AES128-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-DSS-AES256-SHA, DHE-DSS-AES256-SHA256, DHE-DSS-CAMELLIA128-SHA, DHE-DSS-CAMELLIA256-SHA, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-DES-CBC3-SHA, SRP-3DES-EDE-CBC-SHA, SRP-AES-128-CBC-SHA, SRP-AES-256-CBC-SHA, SRP-DSS-3DES-EDE-CBC-SHA, SRP-DSS-AES-128-CBC-SHA, SRP-DSS-AES-256-CBC-SHA, SRP-RSA-3DES-EDE-CBC-SHA, SRP-RSA-AES-128-CBC-SHA, SRP-RSA-AES-256-CBC-SHA
Supported Clients: OpenSSL/1.0.2, Yahoo Slurp/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, YandexBot/Jan 2015, Android/4.4.2, Safari/8/iOS 8.1.2, Safari/7/OS X 10.9, Safari/8/OS X 10.10, Safari/7/iOS 7.1, Safari/6/iOS 6.0.1, Baidu/Jan 2015, Firefox/31.3.0 ESR/Win 7, Android/5.0.0, IE/11/Win 7, Java/8u31, Googlebot/Feb 2015, Chrome/42/OS X, IE Mobile/11/Win Phone 8.1, IE/11/Win 8.1, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, Android/4.3, Android/4.2.2, Safari/5.1.9/OS X 10.6.8, Java/7u25, OpenSSL/0.9.8y, Firefox/37/OS X, IE/7/Vista, IE/8-10/Win 7, IE Mobile/10/Win Phone 8.0, Java/6u45, Android/2.3.7, IE/8/XP
PORT 587
--------
* Deflate Compression:
OK - Compression disabled
* Session Renegotiation:
Client-initiated Renegotiations: VULNERABLE - Server honors client-initiated renegotiations
Secure Renegotiation: OK - Supported
* OpenSSL Heartbleed:
OK - Not vulnerable to Heartbleed
* Session Resumption:
With Session IDs: OK - Supported (5 successful, 0 failed, 0 errors, 5 total attempts).
With TLS Session Tickets: NOT SUPPORTED - TLS ticket not assigned.
* SSLV2 Cipher Suites:
Server rejected all cipher suites.
* TLSV1_2 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-GCM-SHA384 ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA384 ECDH-256 bits 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES256-GCM-SHA384 ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA256 DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-GCM-SHA384 DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA256 - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
AES256-GCM-SHA384 - 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA256 ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-GCM-SHA256 ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA256 DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-GCM-SHA256 DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA256 - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
AES128-GCM-SHA256 - 128 bits 250 2.0.0 Ok
* TLSV1_1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
* SSLV3 Cipher Suites:
Server rejected all cipher suites.
* TLSV1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
DHE-RSA-AES256-SHA DH-2048 bits 256 bits 250 2.0.0 Ok
CAMELLIA256-SHA - 256 bits 250 2.0.0 Ok
AES256-SHA - 256 bits 250 2.0.0 Ok
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits 250 2.0.0 Ok
DHE-RSA-SEED-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-CAMELLIA128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
DHE-RSA-AES128-SHA DH-2048 bits 128 bits 250 2.0.0 Ok
SEED-SHA - 128 bits 250 2.0.0 Ok
CAMELLIA128-SHA - 128 bits 250 2.0.0 Ok
AES128-SHA - 128 bits 250 2.0.0 Ok
Should Not Offer: AES128-GCM-SHA256, AES128-SHA, AES128-SHA256, AES256-GCM-SHA384, AES256-SHA, AES256-SHA256, CAMELLIA128-SHA, CAMELLIA256-SHA, DHE-RSA-CAMELLIA128-SHA, DHE-RSA-CAMELLIA256-SHA, DHE-RSA-SEED-SHA, SEED-SHA
Could Also Offer: DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES128-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-DSS-AES256-SHA, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384
Supported Clients: OpenSSL/1.0.2, Yahoo Slurp/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, YandexBot/Jan 2015, Android/4.4.2, Safari/8/iOS 8.1.2, Safari/7/OS X 10.9, Safari/8/OS X 10.10, Safari/7/iOS 7.1, IE Mobile/11/Win Phone 8.1, IE/11/Win 8.1, IE/11/Win 7, Safari/6/iOS 6.0.1, Firefox/31.3.0 ESR/Win 7, Baidu/Jan 2015, Android/5.0.0, Chrome/42/OS X, Java/8u31, Googlebot/Feb 2015, Firefox/37/OS X, Android/4.0.4, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, Android/4.3, Android/4.2.2, Safari/5.1.9/OS X 10.6.8, OpenSSL/0.9.8y, IE/7/Vista, IE/8-10/Win 7, IE Mobile/10/Win Phone 8.0, Java/7u25, Java/6u45, Android/2.3.7
PORT 443
--------
* Deflate Compression:
OK - Compression disabled
* Session Renegotiation:
Client-initiated Renegotiations: OK - Rejected
Secure Renegotiation: OK - Supported
* HTTP Strict Transport Security:
OK - HSTS header received: max-age=31536000
* Session Resumption:
With Session IDs: OK - Supported (5 successful, 0 failed, 0 errors, 5 total attempts).
With TLS Session Tickets: OK - Supported
* OpenSSL Heartbleed:
OK - Not vulnerable to Heartbleed
* SSLV2 Cipher Suites:
Server rejected all cipher suites.
* Google Chrome SHA-1 Deprecation Status:
OK - Leaf certificate expires before 2016.
* TLSV1_2 Cipher Suites:
Preferred:
ECDHE-RSA-AES128-GCM-SHA256 ECDH-256 bits 128 bits HTTP 200 OK
Accepted:
ECDHE-RSA-AES256-SHA384 ECDH-256 bits 256 bits HTTP 200 OK
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits HTTP 200 OK
ECDHE-RSA-AES256-GCM-SHA384 ECDH-256 bits 256 bits HTTP 200 OK
DHE-RSA-AES256-SHA256 DH-2048 bits 256 bits HTTP 200 OK
DHE-RSA-AES256-SHA DH-2048 bits 256 bits HTTP 200 OK
DHE-RSA-AES256-GCM-SHA384 DH-2048 bits 256 bits HTTP 200 OK
ECDHE-RSA-AES128-SHA256 ECDH-256 bits 128 bits HTTP 200 OK
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits HTTP 200 OK
ECDHE-RSA-AES128-GCM-SHA256 ECDH-256 bits 128 bits HTTP 200 OK
DHE-RSA-AES128-SHA256 DH-2048 bits 128 bits HTTP 200 OK
DHE-RSA-AES128-SHA DH-2048 bits 128 bits HTTP 200 OK
DHE-RSA-AES128-GCM-SHA256 DH-2048 bits 128 bits HTTP 200 OK
DES-CBC3-SHA - 112 bits HTTP 200 OK
* TLSV1_1 Cipher Suites:
Preferred:
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits HTTP 200 OK
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits HTTP 200 OK
DHE-RSA-AES256-SHA DH-2048 bits 256 bits HTTP 200 OK
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits HTTP 200 OK
DHE-RSA-AES128-SHA DH-2048 bits 128 bits HTTP 200 OK
DES-CBC3-SHA - 112 bits HTTP 200 OK
* SSLV3 Cipher Suites:
Server rejected all cipher suites.
* TLSV1 Cipher Suites:
Preferred:
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits HTTP 200 OK
Accepted:
ECDHE-RSA-AES256-SHA ECDH-256 bits 256 bits HTTP 200 OK
DHE-RSA-AES256-SHA DH-2048 bits 256 bits HTTP 200 OK
ECDHE-RSA-AES128-SHA ECDH-256 bits 128 bits HTTP 200 OK
DHE-RSA-AES128-SHA DH-2048 bits 128 bits HTTP 200 OK
DES-CBC3-SHA - 112 bits HTTP 200 OK
Should Not Offer: (none -- good)
Could Also Offer: AES128-GCM-SHA256, AES128-SHA, AES128-SHA256, AES256-GCM-SHA384, AES256-SHA, AES256-SHA256, CAMELLIA128-SHA, CAMELLIA256-SHA, DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES128-SHA, DHE-DSS-AES128-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-DSS-AES256-SHA, DHE-DSS-AES256-SHA256, DHE-DSS-CAMELLIA128-SHA, DHE-DSS-CAMELLIA256-SHA, DHE-RSA-CAMELLIA128-SHA, DHE-RSA-CAMELLIA256-SHA, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384, SRP-AES-128-CBC-SHA, SRP-AES-256-CBC-SHA, SRP-DSS-AES-128-CBC-SHA, SRP-DSS-AES-256-CBC-SHA, SRP-RSA-AES-128-CBC-SHA, SRP-RSA-AES-256-CBC-SHA
Supported Clients: YandexBot/Jan 2015, OpenSSL/1.0.2, Yahoo Slurp/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, Android/4.4.2, Safari/8/iOS 8.1.2, Safari/8/OS X 10.10, Safari/7/OS X 10.9, Safari/7/iOS 7.1, Safari/6/iOS 6.0.1, Android/5.0.0, Chrome/42/OS X, IE/11/Win 8.1, IE/11/Win 7, Java/8u31, IE Mobile/11/Win Phone 8.1, Googlebot/Feb 2015, Firefox/37/OS X, Firefox/31.3.0 ESR/Win 7, Android/4.2.2, Android/4.0.4, Baidu/Jan 2015, Safari/5.1.9/OS X 10.6.8, Android/4.1.1, Safari/6.0.4/OS X 10.8.4, Android/4.3, OpenSSL/0.9.8y, IE/7/Vista, IE/8-10/Win 7, IE Mobile/10/Win Phone 8.0, Java/7u25, Java/6u45, Android/2.3.7, IE/8/XP
PORT 993
--------
* Deflate Compression:
OK - Compression disabled
Unhandled exception when processing --reneg:
_nassl.OpenSSLError - error:140940F5:SSL routines:ssl3_read_bytes:unexpected record
* OpenSSL Heartbleed:
OK - Not vulnerable to Heartbleed
* SSLV2 Cipher Suites:
Server rejected all cipher suites.
* Session Resumption:
With Session IDs: NOT SUPPORTED (0 successful, 5 failed, 0 errors, 5 total attempts).
With TLS Session Tickets: NOT SUPPORTED - TLS ticket assigned but not accepted.
* TLSV1_2 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
Accepted:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
DHE-RSA-CAMELLIA256-SHA DH-1024 bits 256 bits
DHE-RSA-AES256-SHA DH-1024 bits 256 bits
CAMELLIA256-SHA - 256 bits
AES256-SHA - 256 bits
ECDHE-RSA-AES128-SHA ECDH-384 bits 128 bits
DHE-RSA-CAMELLIA128-SHA DH-1024 bits 128 bits
DHE-RSA-AES128-SHA DH-1024 bits 128 bits
CAMELLIA128-SHA - 128 bits
AES128-SHA - 128 bits
* TLSV1_1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
Accepted:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
DHE-RSA-CAMELLIA256-SHA DH-1024 bits 256 bits
DHE-RSA-AES256-SHA DH-1024 bits 256 bits
CAMELLIA256-SHA - 256 bits
AES256-SHA - 256 bits
ECDHE-RSA-AES128-SHA ECDH-384 bits 128 bits
DHE-RSA-CAMELLIA128-SHA DH-1024 bits 128 bits
DHE-RSA-AES128-SHA DH-1024 bits 128 bits
CAMELLIA128-SHA - 128 bits
AES128-SHA - 128 bits
* SSLV3 Cipher Suites:
Server rejected all cipher suites.
* TLSV1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
Accepted:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
DHE-RSA-CAMELLIA256-SHA DH-1024 bits 256 bits
DHE-RSA-AES256-SHA DH-1024 bits 256 bits
CAMELLIA256-SHA - 256 bits
AES256-SHA - 256 bits
ECDHE-RSA-AES128-SHA ECDH-384 bits 128 bits
DHE-RSA-CAMELLIA128-SHA DH-1024 bits 128 bits
DHE-RSA-AES128-SHA DH-1024 bits 128 bits
CAMELLIA128-SHA - 128 bits
AES128-SHA - 128 bits
Should Not Offer: AES128-SHA, AES256-SHA, CAMELLIA128-SHA, CAMELLIA256-SHA, DHE-RSA-CAMELLIA128-SHA, DHE-RSA-CAMELLIA256-SHA
Could Also Offer: DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES128-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-DSS-AES256-SHA, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-RSA-AES256-SHA384
Supported Clients: OpenSSL/1.0.2, Baidu/Jan 2015, Yahoo Slurp/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, Firefox/31.3.0 ESR/Win 7, Googlebot/Feb 2015, Android/4.2.2, Android/5.0.0, Android/4.0.4, Safari/8/iOS 8.1.2, Safari/7/OS X 10.9, YandexBot/Jan 2015, Safari/8/OS X 10.10, Safari/7/iOS 7.1, Chrome/42/OS X, Safari/5.1.9/OS X 10.6.8, Android/4.1.1, Firefox/37/OS X, Safari/6.0.4/OS X 10.8.4, Android/4.3, Safari/6/iOS 6.0.1, Android/4.4.2, OpenSSL/0.9.8y, IE Mobile/11/Win Phone 8.1, IE/7/Vista, IE/11/Win 8.1, IE/11/Win 7, IE/8-10/Win 7, IE Mobile/10/Win Phone 8.0, Java/8u31, Java/7u25, Java/6u45, Android/2.3.7
PORT 995
--------
* Deflate Compression:
OK - Compression disabled
Unhandled exception when processing --reneg:
_nassl.OpenSSLError - error:140940F5:SSL routines:ssl3_read_bytes:unexpected record
* OpenSSL Heartbleed:
OK - Not vulnerable to Heartbleed
* SSLV2 Cipher Suites:
Server rejected all cipher suites.
* Session Resumption:
With Session IDs: NOT SUPPORTED (0 successful, 5 failed, 0 errors, 5 total attempts).
With TLS Session Tickets: NOT SUPPORTED - TLS ticket assigned but not accepted.
* TLSV1_2 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
Accepted:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
DHE-RSA-CAMELLIA256-SHA DH-1024 bits 256 bits
DHE-RSA-AES256-SHA DH-1024 bits 256 bits
CAMELLIA256-SHA - 256 bits
AES256-SHA - 256 bits
ECDHE-RSA-AES128-SHA ECDH-384 bits 128 bits
DHE-RSA-CAMELLIA128-SHA DH-1024 bits 128 bits
DHE-RSA-AES128-SHA DH-1024 bits 128 bits
CAMELLIA128-SHA - 128 bits
AES128-SHA - 128 bits
* TLSV1_1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
Accepted:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
DHE-RSA-CAMELLIA256-SHA DH-1024 bits 256 bits
DHE-RSA-AES256-SHA DH-1024 bits 256 bits
CAMELLIA256-SHA - 256 bits
AES256-SHA - 256 bits
ECDHE-RSA-AES128-SHA ECDH-384 bits 128 bits
DHE-RSA-CAMELLIA128-SHA DH-1024 bits 128 bits
DHE-RSA-AES128-SHA DH-1024 bits 128 bits
CAMELLIA128-SHA - 128 bits
AES128-SHA - 128 bits
* SSLV3 Cipher Suites:
Server rejected all cipher suites.
* TLSV1 Cipher Suites:
Preferred:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
Accepted:
ECDHE-RSA-AES256-SHA ECDH-384 bits 256 bits
DHE-RSA-CAMELLIA256-SHA DH-1024 bits 256 bits
DHE-RSA-AES256-SHA DH-1024 bits 256 bits
CAMELLIA256-SHA - 256 bits
AES256-SHA - 256 bits
ECDHE-RSA-AES128-SHA ECDH-384 bits 128 bits
DHE-RSA-CAMELLIA128-SHA DH-1024 bits 128 bits
DHE-RSA-AES128-SHA DH-1024 bits 128 bits
CAMELLIA128-SHA - 128 bits
AES128-SHA - 128 bits
Should Not Offer: AES128-SHA, AES256-SHA, CAMELLIA128-SHA, CAMELLIA256-SHA, DHE-RSA-CAMELLIA128-SHA, DHE-RSA-CAMELLIA256-SHA
Could Also Offer: DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES128-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-DSS-AES256-SHA, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES128-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-AES256-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-RSA-AES256-SHA384
Supported Clients: OpenSSL/1.0.2, Baidu/Jan 2015, Yahoo Slurp/Jan 2015, BingPreview/Jan 2015, OpenSSL/1.0.1l, Firefox/31.3.0 ESR/Win 7, Googlebot/Feb 2015, Android/4.2.2, Android/5.0.0, Android/4.0.4, Safari/8/iOS 8.1.2, Safari/7/OS X 10.9, YandexBot/Jan 2015, Safari/8/OS X 10.10, Safari/7/iOS 7.1, Chrome/42/OS X, Safari/5.1.9/OS X 10.6.8, Android/4.1.1, Firefox/37/OS X, Safari/6.0.4/OS X 10.8.4, Android/4.3, Safari/6/iOS 6.0.1, Android/4.4.2, OpenSSL/0.9.8y, IE Mobile/11/Win Phone 8.1, IE/7/Vista, IE/11/Win 8.1, IE/11/Win 7, IE/8-10/Win 7, IE Mobile/10/Win Phone 8.0, Java/8u31, Java/7u25, Java/6u45, Android/2.3.7

View File

@@ -54,6 +54,14 @@ while settings[0][0] == "-" and settings[0] != "--":
print("Invalid option.") print("Invalid option.")
sys.exit(1) sys.exit(1)
# sanity check command line
for setting in settings:
try:
name, value = setting.split("=", 1)
except:
import subprocess
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
# create the new config file in memory # create the new config file in memory
found = set() found = set()

View File

@@ -1,9 +1,10 @@
#!/usr/bin/python3 #!/usr/bin/python3
import sys, getpass, urllib.request, urllib.error, json import sys, getpass, urllib.request, urllib.error, json, re
def mgmt(cmd, data=None, is_json=False): def mgmt(cmd, data=None, is_json=False):
mgmt_uri = 'http://localhost:10222' # The base URL for the management daemon. (Listens on IPv4 only.)
mgmt_uri = 'http://127.0.0.1:10222'
setup_key_auth(mgmt_uri) setup_key_auth(mgmt_uri)
@@ -27,12 +28,19 @@ def mgmt(cmd, data=None, is_json=False):
return resp return resp
def read_password(): def read_password():
while True:
first = getpass.getpass('password: ') first = getpass.getpass('password: ')
if len(first) < 4:
print("Passwords must be at least four characters.")
continue
if re.search(r'[\s]', first):
print("Passwords cannot contain spaces.")
continue
second = getpass.getpass(' (again): ') second = getpass.getpass(' (again): ')
while first != second: if first != second:
print('Passwords not the same. Try again.') print("Passwords not the same. Try again.")
first = getpass.getpass('password: ') continue
second = getpass.getpass(' (again): ') break
return first return first
def setup_key_auth(mgmt_uri): def setup_key_auth(mgmt_uri):
@@ -58,6 +66,7 @@ if len(sys.argv) < 2:
print(" tools/mail.py user admins (lists admins)") print(" tools/mail.py user admins (lists admins)")
print(" tools/mail.py alias (lists aliases)") print(" tools/mail.py alias (lists aliases)")
print(" tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com") print(" tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com")
print(" tools/mail.py alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'")
print(" tools/mail.py alias remove incoming.name@domain.com") print(" tools/mail.py alias remove incoming.name@domain.com")
print() print()
print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.") print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.")
@@ -66,7 +75,9 @@ if len(sys.argv) < 2:
elif sys.argv[1] == "user" and len(sys.argv) == 2: elif sys.argv[1] == "user" and len(sys.argv) == 2:
# Dump a list of users, one per line. Mark admins with an asterisk. # Dump a list of users, one per line. Mark admins with an asterisk.
users = mgmt("/mail/users?format=json", is_json=True) users = mgmt("/mail/users?format=json", is_json=True)
for user in users: for domain in users:
for user in domain["users"]:
if user['status'] == 'inactive': continue
print(user['email'], end='') print(user['email'], end='')
if "admin" in user['privileges']: if "admin" in user['privileges']:
print("*", end='') print("*", end='')
@@ -100,7 +111,8 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and
elif sys.argv[1] == "user" and sys.argv[2] == "admins": elif sys.argv[1] == "user" and sys.argv[2] == "admins":
# Dump a list of admin users. # Dump a list of admin users.
users = mgmt("/mail/users?format=json", is_json=True) users = mgmt("/mail/users?format=json", is_json=True)
for user in users: for domain in users:
for user in domain["users"]:
if "admin" in user['privileges']: if "admin" in user['privileges']:
print(user['email']) print(user['email'])

23
tools/owncloud-unlockadmin.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
#
# This script will give you administrative access to the ownCloud
# instance running here.
#
# Run this at your own risk. This is for testing & experimentation
# purpopses only. After this point you are on your own.
source /etc/mailinabox.conf # load global vars
ADMIN=$(./mail.py user admins | head -n 1)
test -z "$1" || ADMIN=$1
echo I am going to unlock admin features for $ADMIN.
echo You can provide another user to unlock as the first argument of this script.
echo
echo WARNING: you could break mail-in-a-box when fiddling around with owncloud\'s admin interface
echo If in doubt, press CTRL-C to cancel.
echo
echo Press enter to continue.
read
sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$ADMIN')" && echo Done.

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)

481
tools/readable_bash.py Normal file
View File

@@ -0,0 +1,481 @@
#!/usr/bin/python3
#
# Generate documentation for how this machine works by
# parsing our bash scripts!
import cgi, re
import markdown
from modgrammar import *
def generate_documentation():
print("""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width">
<title>Build Your Own Mail Server From Scratch</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
<style>
@import url(https://fonts.googleapis.com/css?family=Iceland);
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,500);
body {
font-family: Raleway, sans-serif;
font-size: 16px;
color: #555;
}
h2, h3 {
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 {
color: black;
border: 0;
background: none;
font-size: 100%;
}
div.write-to {
margin: 0 0 1em .5em;
}
div.write-to p {
padding: .5em;
margin: 0;
}
div.write-to .filename {
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 {
margin: 0;
padding: .5em;
border: 1px solid #999;
border-radius: 0;
font-size: 90%;
}
pre.shell > div:before {
content: "$ ";
color: #666;
}
</style>
</head>
<body>
<div class="container">
<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.</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()
for line in open("setup/start.sh"):
try:
fn = parser.parse_string(line).filename()
except:
continue
if fn in ("setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
continue
import sys
print(fn, file=sys.stderr)
print(BashScript.parse(fn))
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>
""")
class HashBang(Grammar):
grammar = (L('#!'), REST_OF_LINE, EOL)
def value(self):
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)
class Comment(Grammar):
grammar = ONE_OR_MORE(ZERO_OR_MORE(SPACE), L('#'), REST_OF_LINE, EOL)
def value(self):
if self.string.replace("#", "").strip() == "":
return "\n"
lines = [x[2].string for x in self[0]]
content = "\n".join(lines)
content = strip_indent(content)
return markdown.markdown(content, output_format="html4") + "\n\n"
FILENAME = WORD('a-z0-9-/.')
class Source(Grammar):
grammar = ((L('.') | L('source')), L(' '), FILENAME, Comment | EOL)
def filename(self):
return self[2].string.strip()
def value(self):
return BashScript.parse(self.filename())
class CatEOF(Grammar):
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[9].string
content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
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 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>" + recode_bash(self.string.strip()) + "</div></pre>\n"
return ""
class EditConf(Grammar):
grammar = (
L('tools/editconf.py '),
FILENAME,
SPACE,
OPTIONAL((LIST_OF(
L("-w") | L("-s") | L("-c ;"),
sep=SPACE,
), SPACE)),
REST_OF_LINE,
OPTIONAL(SPACE),
EOL
)
def value(self):
conffile = self[1]
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
def value(self):
cmd = self[3].string
cmd = cmd.replace("; ", "\n")
return "<div class='write-to'><div class='filename'>$" + self[1].string + "=</div><pre>" + cgi.escape(cmd) + "</pre></div>\n"
class SedReplace(Grammar):
grammar = OPTIONAL(SPACE), L('sed -i "s/'), OPTIONAL(L('^')), ONE_OR_MORE(WORD("-A-Za-z0-9 #=\\{};.*$_!()")), L('/'), ONE_OR_MORE(WORD("-A-Za-z0-9 #=\\{};.*$_!()")), L('/"'), SPACE, FILENAME, EOL
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>" + recode_bash(bash.strip()) + "</div></pre>\n"
class AptGet(Grammar):
grammar = (ZERO_OR_MORE(SPACE), L("apt_install "), REST_OF_LINE, EOL)
def value(self):
return shell_line("apt-get install -y " + re.sub(r"\s+", " ", self[2].string))
class UfwAllow(Grammar):
grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL)
def value(self):
return shell_line("ufw allow " + self[2].string)
class RestartService(Grammar):
grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL)
def value(self):
return shell_line("service " + self[2].string + " restart")
class OtherLine(Grammar):
grammar = (REST_OF_LINE, EOL)
def value(self):
if self.string.strip() == "": return ""
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 | 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):
return [line.value() for line in self[1]]
@staticmethod
def parse(fn):
if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
string = open(fn).read()
# tokenize
string = re.sub(".* #NODOC\n", "", 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='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", 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
def wrap_lines(text, cols=60):
ret = ""
words = re.split("(\s+)", text)
linelen = 0
for w in words:
if linelen + len(w) > cols-1:
ret += " \\\n"
ret += " "
linelen = 0
if linelen == 0 and w.strip() == "": continue
ret += w
linelen += len(w)
return ret
if __name__ == '__main__':
generate_documentation()

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