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

Compare commits

..

67 Commits
2fa ... v0.08

Author SHA1 Message Date
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
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
37 changed files with 799 additions and 699 deletions

View File

@@ -1,24 +1,72 @@
CHANGELOG
=========
Development
-----------
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.
* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.
* 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.
Miscellaneous:
* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.
* 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.
* 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)
-----------------------

View File

@@ -1,53 +0,0 @@
#!/usr/bin/python3
#
# This script implement's Dovecot's checkpassword authentication mechanism:
# http://wiki2.dovecot.org/AuthDatabase/CheckPassword?action=show&redirect=PasswordDatabase%2FCheckPassword
#
# This allows us to perform our own password validation, such as for two-factor authentication,
# which Dovecot does not have any native support for.
#
# We will issue an HTTP request to our management server to perform authentication.
import sys, os, urllib.request, base64, json, traceback
try:
# Read fd 3 which provides the username and password separated
# by NULLs and two other undocumented/empty fields.
creds = b''
while True:
b = os.read(3, 1024)
if len(b) == 0: break
creds += b
email, pw, dummy, dummy = creds.split(b'\x00')
# Call the management server's "/me" method with the
# provided credentials
req = urllib.request.Request('http://127.0.0.1:10222/me')
req.add_header(b'Authorization', b'Basic ' + base64.b64encode(email + b':' + pw))
response = urllib.request.urlopen(req)
# The response is always success and always a JSON object
# indicating the authentication result.
resp = response.read().decode('utf8')
resp = json.loads(resp)
if not isinstance(resp, dict): raise ValueError("Response is not a JSON object.")
except:
# Handle all exceptions. Print what happens (ends up in syslog, thanks
# to dovecot) and return an exit status that indicates temporary failure,
# which is passed on to the authenticating client.
traceback.print_exc()
print(json.dumps(dict(os.environ), indent=2), file=sys.stderr)
sys.exit(111)
if resp.get('status') != 'authorized':
# Indicates login failure.
# (sys.exit should not be inside the try block.)
sys.exit(1)
# Signal ok by executing the indicated process, per the Dovecot
# protocol. (Note that the second parameter is the 0th argument
# to the called process, which is required and is typically the
# file itself.)
os.execl(sys.argv[1], sys.argv[1])

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

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

@@ -7,7 +7,15 @@ server {
server_name $HOSTNAME;
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.
@@ -17,6 +25,10 @@ server {
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_key $SSL_KEY;
include /etc/nginx/nginx-ssl.conf;
@@ -38,6 +50,16 @@ server {
location = /mailinabox.mobileconfig {
alias /var/lib/mailinabox/mobileconfig.xml;
}
location = /.well-known/autoconfig/mail/config-v1.1.xml {
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
}
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
location ~ /\.(ht|svn|git|hg|bzr) {
log_not_found off;
access_log off;
deny all;
}
# Roundcube Webmail configuration.
rewrite ^/mail$ /mail/ redirect;

View File

@@ -1,4 +1,4 @@
import base64, os, os.path, hmac, json
import base64, os, os.path, hmac
from flask import make_response
@@ -97,17 +97,6 @@ class KeyAuthService:
# email address does not correspond to a user.
pw_hash = get_mail_password(email, env)
# If 2FA is set up, get the first factor and authenticate against
# that first.
twofa = None
if pw_hash.startswith("{TOTP}"):
twofa = json.loads(pw_hash[6:])
pw_hash = twofa["first_factor"]
try:
pw, twofa_code = pw.split(" ", 1)
except:
twofa_code = ""
# Authenticate.
try:
# Use 'doveadm pw' to check credentials. doveadm will return
@@ -122,14 +111,6 @@ class KeyAuthService:
# Login failed.
raise ValueError("Invalid password.")
# Check second factor.
if twofa:
import oath
ok, drift = oath.accept_totp(twofa["secret"], twofa_code, drift=twofa["drift"])
if not ok:
raise ValueError("Invalid 2FA code.")
# Get privileges for authorization.
# (This call should never fail on a valid user. But if it did fail, it would

View File

@@ -67,9 +67,29 @@ def backup_status(env):
# 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
deleted_in = None
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
for bak in backups:
if deleted_in:

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 status_checks import check_certificate
def buy_ssl_certificate(api_key, domain, command, env):
if domain != env['PRIMARY_HOSTNAME'] \
and domain not in get_web_domains(env):
raise ValueError("Domain is not %s or a domain we're serving a website for." % env['PRIMARY_HOSTNAME'])
# Initialize.
gandi = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/')
try:
existing_certs = gandi.cert.list(api_key)
except Exception as e:
if "Invalid API key" in str(e):
print("Invalid API key. Check that you copied the API Key correctly from https://www.gandi.net/admin/api_key.")
sys.exit(1)
else:
raise
# Where is the SSL cert stored?
ssl_key, ssl_certificate = 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, cert_status_details = check_certificate(None, ssl_certificate, None)
if cert_status != "SELF-SIGNED":
print("Please back up and delete the file %s so I can save your new certificate." % ssl_certificate)
sys.exit(1)
# 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,10 +7,16 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response
import auth, utils
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user, get_mail_password
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
from mailconfig import get_mail_aliases, get_mail_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()
auth_service = auth.KeyAuthService()
@@ -40,7 +46,6 @@ def authorized_personnel_only(viewfunc):
# Authorized to access an API view?
if "admin" in privs:
# Call view func.
request.user_email = email
return viewfunc(*args, **kwargs)
elif not error:
error = "You are not an administrator."
@@ -116,81 +121,6 @@ def me():
# Return.
return json_response(resp)
# ME
@app.route('/me/2fa')
@authorized_personnel_only
def twofa_status():
pw = get_mail_password(request.user_email, env)
if pw.startswith("{SHA512-CRYPT}"):
method = "password-only"
elif pw.startswith("{TOTP}"):
method = "TOTP 2FA"
else:
method = "unknown"
return json_response({
"method": method
})
@app.route('/me/2fa/totp/initialize', methods=['POST'])
@authorized_personnel_only
def twofa_initialize():
# Generate a Google Authenticator URI that encodes TOTP info.
import urllib.parse, base64, qrcode, io, binascii
secret = os.urandom(32)
uri = "otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&algorithm=%s" % (
urllib.parse.quote(env['PRIMARY_HOSTNAME']),
urllib.parse.quote(request.user_email),
base64.b32encode(secret).decode("ascii").lower().replace("=", ""),
urllib.parse.quote(env['PRIMARY_HOSTNAME']),
6,
"sha1"
)
image_buffer = io.BytesIO()
im = qrcode.make(uri)
im.save(image_buffer, 'png')
return json_response({
"uri": uri,
"secret": binascii.hexlify(secret).decode('ascii'),
"qr": base64.b64encode(image_buffer.getvalue()).decode('ascii')
})
@app.route('/me/2fa/totp/activate', methods=['POST'])
@authorized_personnel_only
def twofa_activate():
import oath
ok, drift = oath.accept_totp(request.form['secret'], request.form['code'])
if ok:
# use the user's current plain password as the first_factor
# of 2FA.
existing_pw = get_mail_password(request.user_email, env)
if existing_pw.startswith("{TOTP}"):
existing_pw = json.loads(existing_pw)["first_factor"]
pw = "{TOTP}" + json.dumps({
"secret": request.form['secret'],
"drift": drift,
"first_factor": existing_pw,
})
set_mail_password(request.user_email, pw, env, already_hashed=True)
return json_response({
"status": "ok",
"message": "TOTP 2FA installed."
})
else:
return json_response({
"status": "fail",
"message": "The activation code was not right. Try again?"
})
# MAIL
@app.route('/mail/users')
@@ -348,7 +278,7 @@ def dns_get_dump():
@authorized_personnel_only
def ssl_get_csr(domain):
from web_update import get_domain_ssl_files, create_csr
ssl_key, ssl_certificate = get_domain_ssl_files(domain, env)
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'])
@@ -394,7 +324,7 @@ def system_status():
def print_line(self, message, monospace=False):
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
output = WebOutput()
run_checks(env, output)
run_checks(False, env, output, pool)
return json_response(output.items)
@app.route('/system/updates')

View File

@@ -122,7 +122,7 @@ def do_dns_update(env, force=False):
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
# Write the OpenDKIM configuration tables.
if write_opendkim_tables(zonefiles, env):
if write_opendkim_tables(domains, env):
# Settings changed. Kick opendkim.
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
if len(updated_domains) == 0:
@@ -183,10 +183,6 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
# The MX record says where email for the domain should be delivered: Here!
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
# both a domain and one of its subdomains.
subdomains = [d for d in all_domains if d.endswith("." + domain)]
@@ -207,6 +203,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
return False
# The user may set other records that don't conflict with our settings.
# Don't put any TXT records above this line, or it'll prevent any custom TXT records.
for qname, rtype, value in get_custom_records(domain, additional_records, env):
if has_rec(qname, rtype): continue
records.append((qname, rtype, value, "(Set by user.)"))
@@ -222,18 +219,32 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
for qname, rtype, value, explanation in defaults:
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 has_rec(qname, rtype) and not has_rec(qname, "CNAME"):
# 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))
# 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')
with open(opendkim_record_file) as orf:
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
val = m.group(2) + m.group(3)
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
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.
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', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain))
# For any subdomain with an A record but no SPF or DMARC record, add strict policy records.
all_resolvable_qnames = set(r[0] for r in records if r[1] in ("A", "AAAA"))
@@ -254,6 +265,10 @@ 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():
# 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
@@ -612,8 +627,9 @@ def sign_zone(domain, zonefile, env):
########################################################################
def write_opendkim_tables(zonefiles, env):
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
def write_opendkim_tables(domains, env):
# 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')
@@ -632,7 +648,7 @@ def write_opendkim_tables(zonefiles, env):
"SigningTable":
"".join(
"*@{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
@@ -641,7 +657,7 @@ def write_opendkim_tables(zonefiles, env):
"KeyTable":
"".join(
"{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file)
for domain, zonefile in zonefiles
for domain in domains
),
}
@@ -682,7 +698,7 @@ def set_custom_dns_record(qname, rtype, value, env):
v = ipaddress.ip_address(value)
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
elif rtype in ("CNAME", "TXT", "SRV"):
elif rtype in ("CNAME", "TXT", "SRV", "MX"):
# anything goes
pass
else:

View File

@@ -91,10 +91,6 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
# email: "name@domain.tld",
# privileges: [ "priv1", "priv2", ... ],
# status: "active",
# aliases: [
# ("alias@domain.tld", ["indirect.alias@domain.tld", ...]),
# ...
# ]
# },
# ...
# ]
@@ -102,9 +98,6 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
# ...
# ]
# Pre-load all aliases.
aliases = get_mail_alias_map(env)
# Get users and their privileges.
users = []
active_accounts = set()
@@ -121,10 +114,6 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
users.append(user)
if with_slow_info:
user["aliases"] = [
(alias, sorted(evaluate_mail_alias_map(alias, aliases, env)))
for alias in aliases.get(email.lower(), [])
]
user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
# Add in archived accounts.
@@ -230,21 +219,6 @@ def get_mail_aliases_ex(env):
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"]))
return domains
def get_mail_alias_map(env):
aliases = { }
for alias, targets in get_mail_aliases(env):
for em in targets.split(","):
em = em.strip().lower()
aliases.setdefault(em, []).append(alias)
return aliases
def evaluate_mail_alias_map(email, aliases, env):
ret = set()
for alias in aliases.get(email.lower(), []):
ret.add(alias)
ret |= evaluate_mail_alias_map(alias, aliases, env)
return ret
def get_domain(emailaddr):
return emailaddr.split('@', 1)[1]
@@ -261,8 +235,10 @@ def add_mail_user(email, pw, privs, env):
# validate email
if email.strip() == "":
return ("No email address provided.", 400)
if not validate_email(email, mode='user'):
elif not validate_email(email):
return ("Invalid email address.", 400)
elif not validate_email(email, mode='user'):
return ("User account email addresses may only use the ASCII letters A-Z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
validate_password(pw)
@@ -291,9 +267,11 @@ def add_mail_user(email, pw, privs, env):
# write databasebefore next step
conn.commit()
# Create the user's INBOX, Spam, and Drafts folders, and subscribe them.
# K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
# to avoid unnecessary polling.
# Create & subscribe the user's INBOX, Trash, Spam, and Drafts folders.
# * 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.
# Check if the mailboxes exist before creating them. When creating a user that had previously
# been deleted, the mailboxes will still exist because they are still on disk.
@@ -304,23 +282,22 @@ def add_mail_user(email, pw, privs, env):
conn.commit()
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:
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
# Update things in case any new domains are added.
return kick(env, "mail user added")
def set_mail_password(email, pw, env, already_hashed=False):
def set_mail_password(email, pw, env):
# accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email)
# validate that password is acceptable
if not already_hashed:
# Validate and hash the password. Skip if we're providing
# a raw hashed password value.
validate_password(pw)
pw = hash_password(pw)
validate_password(pw)
# hash the password
pw = hash_password(pw)
# update the database
conn, c = open_database(env, with_connection=True)

View File

@@ -6,7 +6,7 @@
__ALL__ = ['check_certificate']
import os, os.path, re, subprocess, datetime, multiprocessing.pool
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz
@@ -17,12 +17,12 @@ 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):
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):
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.
@@ -33,17 +33,25 @@ def run_checks(env, output):
# that in run_services checks.)
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
run_system_checks(env, output)
run_system_checks(rounded_values, env, output)
# perform other checks asynchronously
pool = multiprocessing.pool.Pool(processes=1)
r1 = pool.apply_async(run_network_checks, [env])
r2 = run_domain_checks(env)
r1.get().playback(output)
r2.playback(output)
run_network_checks(env, output)
run_domain_checks(rounded_values, env, output, pool)
def run_services_checks(env, output):
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 = [
@@ -54,11 +62,12 @@ def run_services_checks(env, output):
{ "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": True, },
{ "name": "Sieve (dovecot)", "port": 4190, "public": False, },
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
{ "name": "SSH Login (ssh)", "port": 22, "public": True, },
{ "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, },
@@ -70,7 +79,6 @@ def run_services_checks(env, output):
all_running = True
fatal = False
pool = multiprocessing.pool.Pool(processes=10)
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
@@ -90,13 +98,28 @@ def check_service(i, service, env):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
try:
s.connect((
"127.0.0.1" if not service["public"] else env['PUBLIC_IP'],
service["port"]))
running = True
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)." % (service['name'], str(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):
@@ -110,11 +133,11 @@ def check_service(i, service, env):
return (i, running, fatal, output)
def run_system_checks(env, 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(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
@@ -149,12 +172,15 @@ def check_system_aliases(env, output):
# admin email is automatically directed.
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env, output)
def check_free_disk_space(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
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10.0)
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:
@@ -162,10 +188,9 @@ def check_free_disk_space(env, output):
else:
output.print_error(disk_msg)
def run_network_checks(env):
def run_network_checks(env, output):
# Also see setup/network-checks.sh.
output = BufferedOutput()
output.add_heading("Network")
# Stop if we cannot make an outbound connection on port 25. Many residential
@@ -193,9 +218,7 @@ def run_network_checks(env):
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
return output
def run_domain_checks(env):
def run_domain_checks(rounded_time, env, output, pool):
# Get the list of domains we handle mail for.
mail_domains = get_mail_domains(env)
@@ -210,20 +233,17 @@ def run_domain_checks(env):
# Serial version:
#for domain in sort_domains(domains_to_check, env):
# run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
# 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, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
for domain in domains_to_check)
pool = multiprocessing.pool.Pool(processes=10)
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
ret = dict(ret) # (domain, output) => { domain: output }
output = BufferedOutput()
for domain in sort_domains(ret, env):
ret[domain].playback(output)
return output
def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
output = BufferedOutput()
output.add_heading(domain)
@@ -238,7 +258,7 @@ def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_do
check_mail_domain(domain, env, output)
if domain in web_domains:
check_web_domain(domain, env, output)
check_web_domain(domain, rounded_time, env, output)
if domain in dns_domains:
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
@@ -247,27 +267,38 @@ def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_do
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.
ip = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
if ip == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']:
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'], ip))
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
ip = query_dns(domain, "A")
if ip == env['PUBLIC_IP']:
output.print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
else:
@@ -293,7 +324,10 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
if tlsa25 == tlsa25_expected:
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
elif tlsa25 is None:
output.print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""")
if has_dnssec:
# Omit a warning about it not being set if DNSSEC isn't enabled,
# since TLSA shouldn't be used without DNSSEC.
output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""")
else:
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."""
@@ -321,6 +355,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
# 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")
custom_dns = get_custom_dns_config(env)
existing_ns = query_dns(domain, "NS")
correct_ns = "; ".join(sorted([
@@ -329,6 +364,11 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
]))
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."""
@@ -367,7 +407,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
else:
if ds == None:
if is_checking_primary: return
output.print_error("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC.
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:
@@ -399,13 +439,17 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
def check_mail_domain(domain, env, output):
# Check the MX record.
recommended_mx = "10 " + env['PRIMARY_HOSTNAME']
mx = query_dns(domain, "MX", nxdomain=None)
expected_mx = "10 " + env['PRIMARY_HOSTNAME']
if mx == expected_mx:
output.print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx))
if mx is None:
mxhost = None
else:
# query_dns returns a semicolon-delimited list
# of priority-host pairs.
mxhost = mx.split('; ')[0].split(' ')[1]
elif mx == None:
if mxhost == None:
# A missing MX record is okay on the primary hostname because
# the primary hostname's A record (the MX fallback) is... itself,
# which is what we want the MX to be.
@@ -423,15 +467,22 @@ def check_mail_domain(domain, env, output):
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.""" % (expected_mx,))
change. This problem may result from other issues listed here.""" % (recommended_mx,))
elif mxhost == env['PRIMARY_HOSTNAME']:
good_news = "Domain's email is directed to this domain. [%s => %s]" % (domain, mx)
if mx != recommended_mx:
good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,)
output.print_ok(good_news)
else:
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, expected_mx))
other issues listed here.""" % (mx, recommended_mx))
# Check that the postmaster@ email address exists.
check_alias_exists("postmaster@" + domain, env, output)
# 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@" + 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
@@ -444,7 +495,7 @@ def check_mail_domain(domain, env, output):
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, env, output):
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.
@@ -460,7 +511,7 @@ def check_web_domain(domain, env, output):
# 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, output)
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
@@ -487,14 +538,14 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
# can compare to a well known order.
return "; ".join(sorted(str(r).rstrip('.') for r in response))
def check_ssl_cert(domain, env, output):
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 = get_domain_ssl_files(domain, env)
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
output.print_error("The SSL certificate file for this domain is missing.")
@@ -502,11 +553,11 @@ def check_ssl_cert(domain, env, output):
# Check that the certificate is good.
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
if cert_status == "OK":
# The certificate is ok. The details has expiry info.
output.print_ok("SSL certificate is signed & valid. " + cert_status_details)
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.
@@ -541,7 +592,7 @@ def check_ssl_cert(domain, env, output):
output.print_line(cert_status_details)
output.print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key):
def check_certificate(domain, ssl_certificate, ssl_private_key, rounded_time=False):
# Use openssl verify to check the status of a certificate.
# First check that the certificate is for the right domain. The domain
@@ -652,7 +703,15 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
# But is it expiring soon?
now = datetime.datetime.now(dateutil.tz.tzlocal())
ndays = (cert_expiration_date-now).days
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
if 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:
return ("The certificate is expiring soon: " + expiry_info, None)
@@ -693,17 +752,109 @@ def list_apt_updates(apt_update=True):
return pkgs
def run_and_output_changes(env, pool, send_via_email):
import json
from difflib import SequenceMatcher
class ConsoleOutput:
try:
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
except:
terminal_columns = 76
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()
print(heading)
print("=" * len(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="")
@@ -715,28 +866,36 @@ class ConsoleOutput:
self.print_block(message, first_line="? ")
def print_block(self, message, first_line=" "):
print(first_line, end='')
print(first_line, end='', file=self.buf)
message = re.sub("\n\s*", " ", message)
words = re.split("(\s+)", message)
linelen = 0
for w in words:
if linelen + len(w) > self.terminal_columns-1-len(first_line):
print()
print(" ", end="")
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="")
print(w, end="", file=self.buf)
linelen += len(w)
print()
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):
self.buf = []
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
@@ -748,18 +907,25 @@ class BufferedOutput:
for attr, args, kwargs in self.buf:
getattr(output, attr)(*args, **kwargs)
if __name__ == "__main__":
import sys
from utils import load_environment
env = load_environment()
pool = multiprocessing.pool.Pool(processes=10)
if len(sys.argv) == 1:
run_checks(env, ConsoleOutput())
run_checks(False, env, ConsoleOutput(), pool)
elif sys.argv[1] == "--show-changes":
run_and_output_changes(env, pool, sys.argv[-1] == "--smtp")
elif sys.argv[1] == "--check-primary-hostname":
# 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 = get_domain_ssl_files(domain, env)
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
sys.exit(1)
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)

View File

@@ -1,79 +0,0 @@
<style>
</style>
<h2>Two-Factor Authentication</h2>
<p>Two-factor authentication (2FA) is <i>something you know</i> and <i>something you have</i>.</p>
<p>Regular password-based logins are one-factor (something you know). 2FA makes an account more secure by guarding against a lost or guessed password, since you also need a special device to access your account. You can turn on 2FA for your account here.</p>
<p>Your authentication method is currently: <strong id="2fa_current"> </strong></p>
<h3>TOTP</h3>
<p>TOTP is a time-based one-time password method of two-factor authentication.</p>
<p>You will need a TOTP-compatible device, such as any Android device with the <a href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a> app. We&rsquo;ll generate a QR code that you import into your device or app. After you generate the QR code, you&rsquo;ll activate 2FA by entering your first activation code provided by your device or app.</p>
<p><button onclick="totp_initialize()">Generate QR Code</button></p>
<div id="totp-form" class="row" style="display: none">
<div class="col-sm-6">
<center>QR Code</center>
<img id="totp_qr_code" src="" class="img-responsive">
</div>
<div class="col-sm-6">
<form class="form" role="form" onsubmit="totp_activate(); return false;">
<div class="form-group">
<label for="inputTOTP" class="control-label">Activation Code</label>
<p><input class="form-control" id="inputTOTP" placeholder="enter 6-digit code"></p>
<p><input type="submit" class="btn btn-primary" value="Activate"></p>
</div>
</form>
</div>
</div>
<p>When using TOTP 2FA, your password becomes your previous plain password plus a space plus the code generated by your TOTP device.</p>
<script>
function show_2fa() {
$('#2fa_current').text('loading...');
api(
"/me/2fa",
"GET",
{
},
function(response) {
$('#2fa_current').text(response.method);
});
}
var secret = null;
function totp_initialize() {
api(
"/me/2fa/totp/initialize",
"POST",
{
},
function(response) {
$('#totp_qr_code').attr('src', 'data:image/png;base64,' + response.qr);
$('#totp-form').fadeIn();
secret = response.secret;
});
}
function totp_activate() {
api(
"/me/2fa/totp/activate",
"POST",
{
"secret": secret,
"code": $('#inputTOTP').val()
},
function(response) {
show_modal_error("Two-Factor Authentication", $("<pre/>").text(response.message));
if (response.status == "OK")
$('#totp-form').fadeOut();
});
}
</script>

View File

@@ -35,6 +35,7 @@
<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>

View File

@@ -115,12 +115,6 @@
</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>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">You <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#2fa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
</ul>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
@@ -174,10 +168,6 @@
{% include "ssl.html" %}
</div>
<div id="panel_2fa" class="admin_panel">
{% include "2fa.html" %}
</div>
<hr>
<footer>
@@ -339,6 +329,7 @@ function api(url, method, data, callback, callback_error) {
ajax({
url: "/admin" + url,
method: method,
cache: false,
data: data,
beforeSend: function(xhr) {
// We don't store user credentials in a cookie to avoid the hassle of CSRF

View File

@@ -40,7 +40,7 @@
<h4>Exchange/ActiveSync settings</h4>
<p>On iOS devices and devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP. If you encounter any problems, please use the manual settings above.</p>
<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">
<tr><th>Server</th> <td>{{hostname}}</td></tr>

View File

@@ -15,7 +15,7 @@
<h3>Current Backups</h3>
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is <span id="backup-total-size"></span>.</p>
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
<table id="backup-status" class="table" style="width: auto">
<thead>
@@ -76,7 +76,7 @@ function show_system_backup() {
if (b.deleted_in)
tr.append( $('<td/>').text(b.deleted_in) );
else
tr.append( $('<td class="text-muted">n/a</td>') );
tr.append( $('<td class="text-muted">unknown</td>') );
$('#backup-status tbody').append(tr);
total_disk_size += b.size;

View File

@@ -1,13 +1,12 @@
<h2>Users</h2>
<style>
#user_table h4 { margin: 1em 0 0 0; }
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
#user_table .aliases { font-size: 90%; }
#user_table .aliases div:before { content: "⇖ "; }
#user_table .aliases div { }
#user_table .actions { margin-top: .33em; font-size: 95%; }
#user_table .account_inactive .if_active { display: none; }
#user_table .account_active .if_inactive { display: none; }
#user_table .account_active.if_inactive { display: none; }
</style>
<h3>Add a mail user</h3>
@@ -77,11 +76,9 @@
<td class='mailboxsize'>
</td>
</tr>
<tr id="user-extra-template">
<td colspan="3" style="border-top: 0; padding-top: 0">
<div class='if_inactive restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
<div class='aliases' style='display: none'> </div>
<tr id="user-extra-template" class="if_inactive">
<td colspan="3" style="border: 0; padding-top: 0">
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
</td>
</tr>
</table>
@@ -98,7 +95,7 @@ function show_users() {
function(r) {
$('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><td><h4/></td></tr>");
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
hdr.find('h4').text(r[i].domain);
$('#user_table tbody').append(hdr);
@@ -137,16 +134,6 @@ function show_users() {
p.find('span.name').text(add_privs[j]);
n.find('.add-privs').append(p);
}
if (user.aliases && user.aliases.length > 0) {
n2.find('.aliases').show();
for (var j = 0; j < user.aliases.length; j++) {
n2.find('.aliases').append($("<div/>").text(
user.aliases[j][0]
+ (user.aliases[j][1].length > 0 ? " ⇐ " + user.aliases[j][1].join(", ") : "")
))
}
}
}
}
})

View File

@@ -7,7 +7,7 @@
<h3>Uploading web files</h3>
<p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload it following these instructions:</p>
<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>
@@ -18,41 +18,24 @@
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li>
<li>The websites set up on this machine are listed in the table below with where to put the files for each website (if you have customized that, see next section).</li>
<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: 2em; width: auto;">
<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>
<li>If you want to have this box host a static website on a domain that is not listed in the table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first.</li>
<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>
<h3>Different sites for different domains</h3>
<p>Create one of the directories shown in the table below to create a space for different files for one of the websites.</p>
<p>After you create one of these directories, click <button id="web_update" class="btn btn-primary" onclick="do_web_update()">Web Update</button> to restart the box&rsquo;s web server so that it sees the new website file location.</p>
<table id="web_domains_custom" class="table" style="margin-bottom: 2em; width: auto;">
<thead>
<tr>
<th>Site</th>
<th>Create Directory</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<script>
function show_web() {
api(
@@ -64,24 +47,18 @@ function show_web() {
var tb = $('#web_domains_existing tbody');
tb.text('');
for (var i = 0; i < domains.length; i++) {
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td></tr>");
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');
}
tb = $('#web_domains_custom tbody');
tb.text('');
for (var i = 0; i < domains.length; i++) {
if (domains[i].root != domains[i].custom_root) {
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt></td></tr>");
tb.append(row);
row.find('.domain a').text('https://' + domains[i].domain);
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
row.find('.directory tt').text(domains[i].custom_root);
}
}
});
}
@@ -99,4 +76,14 @@ function do_web_update() {
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

@@ -17,9 +17,8 @@ def get_web_domains(env):
domains.add(env['PRIMARY_HOSTNAME'])
# Also serve web for all mail domains so that we might at least
# provide Webfinger and ActiveSync auto-discover of email settings
# (though the latter isn't really working). These will require that
# an SSL cert be installed.
# provide auto-discover of email settings, and also a static website
# if the user wants to make one. These will require an SSL cert.
domains |= get_mail_domains(env)
# ...Unless the domain has an A/AAAA record that maps it to a different
@@ -28,6 +27,7 @@ def get_web_domains(env):
for domain, value in dns.items():
if domain not in domains: continue
if (isinstance(value, str) and (value != "local")) \
or (isinstance(value, dict) and ("CNAME" in value)) \
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)
@@ -75,7 +75,7 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
root = get_web_root(domain, env)
# What private key and SSL certificate will we use for this domain?
ssl_key, ssl_certificate = 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
# available. Make a self-signed one now if one doesn't exist.
@@ -149,6 +149,7 @@ def get_domain_ssl_files(domain, env, allow_shared_cert=True):
# What SSL certificate will we use?
ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
ssl_via = None
if domain == env['PRIMARY_HOSTNAME']:
# For PRIMARY_HOSTNAME, use the one we generated at set-up time.
ssl_certificate = ssl_certificate_primary
@@ -163,8 +164,16 @@ def get_domain_ssl_files(domain, env, allow_shared_cert=True):
from status_checks import check_certificate
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
ssl_certificate = ssl_certificate_primary
ssl_via = "Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME']
return ssl_key, ssl_certificate
# For a 'www.' domain, see if we can reuse the cert of the parent.
elif domain.startswith('www.'):
ssl_certificate_parent = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain[4:]))
if os.path.exists(ssl_certificate_parent) and check_certificate(domain, ssl_certificate_parent, None)[0] == "OK":
ssl_certificate = ssl_certificate_parent
ssl_via = "Using multi/wildcard certificate of %s." % domain[4:]
return ssl_key, ssl_certificate, ssl_via
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env):
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
@@ -219,7 +228,7 @@ def install_cert(domain, ssl_cert, ssl_chain, env):
# Do validation on the certificate before installing it.
from status_checks import check_certificate
ssl_key, ssl_certificate = get_domain_ssl_files(domain, env, allow_shared_cert=False)
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":
@@ -250,18 +259,28 @@ def install_cert(domain, ssl_cert, ssl_chain, env):
return "\n".join(r for r in ret if r.strip() != "")
def get_web_domains_info(env):
# load custom settings so we can tell what domains have a redirect or proxy set up on '/',
# which means static hosting is not happening
custom_settings = { }
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn):
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
def has_root_proxy_or_redirect(domain):
return custom_settings.get(domain, {}).get('redirects', {}).get('/') or custom_settings.get(domain, {}).get('proxies', {}).get('/')
# for the SSL config panel, get cert status
def check_cert(domain):
from status_checks import check_certificate
ssl_key, ssl_certificate = get_domain_ssl_files(domain, env)
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
if not os.path.exists(ssl_certificate):
return ("danger", "No Certificate Installed")
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
if cert_status == "OK":
if domain == env['PRIMARY_HOSTNAME'] or ssl_certificate != get_domain_ssl_files(env['PRIMARY_HOSTNAME'], env)[1]:
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. Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME'])
return ("success", "Signed & valid. " + ssl_via)
elif cert_status == "SELF-SIGNED":
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
else:
@@ -273,6 +292,7 @@ def get_web_domains_info(env):
"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

@@ -7,7 +7,7 @@
#########################################################
if [ -z "$TAG" ]; then
TAG=v0.06
TAG=v0.08
fi
# Are we running as root?

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

@@ -10,7 +10,7 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install DKIM...
apt_install opendkim opendkim-tools
apt_install opendkim opendkim-tools opendmarc
# Make sure configuration directories exist.
mkdir -p /etc/opendkim;
@@ -48,15 +48,29 @@ fi
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
chmod go-rwx $STORAGE_ROOT/mail/dkim
# Add OpenDKIM as a milter to postfix, which is how it intercepts outgoing
# mail to perform the signing (by adding a mail header).
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line. #NODOC
tools/editconf.py /etc/opendmarc.conf -s \
"Syslog=true" \
"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 \
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 \
milter_default_action=accept
# Restart services.
restart_service opendkim
restart_service opendmarc
restart_service postfix

View File

@@ -22,6 +22,20 @@ function hide_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 {
# Report any packages already installed.
PACKAGES=$@
@@ -46,18 +60,10 @@ function apt_install {
echo installing $TO_INSTALL...
fi
# 'DEBIAN_FRONTEND=noninteractive' is to prevent dbconfig-common from asking you questions.
#
# 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
# 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
# a difference.
DEBIAN_FRONTEND=noninteractive \
hide_output \
apt-get -y install $PACKAGES
apt_get_quiet install $PACKAGES
}
function get_default_hostname {
@@ -173,3 +179,21 @@ function input_menu {
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
result_code=$?
}
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

@@ -62,6 +62,9 @@ tools/editconf.py /etc/postfix/main.cf \
# Enable the 'submission' port 587 smtpd server and tweak its settings.
#
# * Do not add the OpenDMAC Authentication-Results header. That should only be added
# on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
# OpenDKIM milter only. See dkim.sh.
# * 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.
@@ -71,6 +74,7 @@ tools/editconf.py /etc/postfix/main.cf \
tools/editconf.py /etc/postfix/master.cf -s -w \
"submission=inet n - - - - smtpd
-o syslog_name=postfix/submission
-o smtpd_milters=inet:127.0.0.1:8891
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3
-o cleanup_service_name=authclean" \
"authclean=unix n - - - 0 cleanup

View File

@@ -26,55 +26,31 @@ fi
# ### User Authentication
# Disable all of the built-in authentication mechanisms. (We formerly uncommented
# a line to include auth-sql.conf.ext but we no longer use that.)
sed -i "s/#*\(\!include auth-.*.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf
# 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-sql.conf.ext\)/\1/" /etc/dovecot/conf.d/10-auth.conf
# Legacy: Delete our old sql conf files.
rm -f /etc/dovecot/conf.d/auth-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
# Specify how Dovecot should perform user authentication (passdb) and how it knows
# where user mailboxes are stored (userdb).
#
# For passwords, we would normally have Dovecot query our mail user database
# directly. The way to do that is commented out below. Instead, in order to
# provide our own authentication framework so we can handle two-factor auth,
# we will use a custom system that hooks into the Mail-in-a-Box management daemon.
#
# The user part of this is standard. The mailbox path and Unix system user are the
# same for all mail users, modulo string substitution for the mailbox path that
# Dovecot handles.
cat > /etc/dovecot/conf.d/10-auth-mailinabox.conf << EOF;
# Specify how the database is to be queried for user authentication (passdb)
# and where user mailboxes are stored (userdb).
cat > /etc/dovecot/conf.d/auth-sql.conf.ext << EOF;
passdb {
driver = checkpassword
args = /usr/local/bin/dovecot-checkpassword
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=mail gid=mail home=$STORAGE_ROOT/mail/mailboxes/%d/%n
}
EOF
chmod 0600 /etc/dovecot/conf.d/10-auth-mailinabox.conf
# Copy dovecot-checkpassword into place.
cp conf/dovecot-checkpassword.py /usr/local/bin/dovecot-checkpassword
chown dovecot.dovecot /usr/local/bin/dovecot-checkpassword
chmod 700 /usr/local/bin/dovecot-checkpassword
# If we were having Dovecot query our database directly, which we did
# originally, `/etc/dovecot/conf.d/10-auth-mailinabox.conf` would say:
#
# passdb {
# driver = sql
# args = /etc/dovecot/dovecot-sql.conf.ext
# }
#
# and then `/etc/dovecot/dovecot-sql.conf.ext` (chmod 0600) would contain:
#
# driver = sqlite
# connect = $db_path
# default_pass_scheme = SHA512-CRYPT
# password_query = SELECT email as user, password FROM users WHERE email='%u';
# Configure the SQL to query for a user's password.
cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
driver = sqlite
connect = $db_path
default_pass_scheme = SHA512-CRYPT
password_query = SELECT email as user, password FROM users WHERE email='%u';
EOF
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
# Have Dovecot provide an authorization service that Postfix can access & use.
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;

View File

@@ -5,9 +5,6 @@ source setup/functions.sh
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
hide_output pip3 install rtyaml
# For two-factor authentication, the management server uses:
hide_output pip3 install git+https://github.com/mail-in-a-box/python-oath qrcode pillow
# Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
@@ -33,5 +30,19 @@ $(pwd)/management/backup.py
EOF
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

View File

@@ -84,13 +84,22 @@ def run_migrations():
env = load_environment()
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
migration_id = None
if os.path.exists(migration_id_file):
with open(migration_id_file) as f:
ourver = int(f.read().strip())
else:
migration_id = f.read().strip();
if migration_id is None:
# Load the legacy location of the migration ID. We'll drop support
# 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:
next_ver = (ourver + 1)

View File

@@ -1,6 +1,6 @@
# 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.
hide_output apt-get -y install bind9-host sed netcat-openbsd
apt_get_quiet install bind9-host sed netcat-openbsd
# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
# The user might have chosen a name that was previously in use by a spammer

View File

@@ -15,18 +15,49 @@ apt_install \
apt-get purge -qq -y owncloud*
# Install ownCloud from source of this version:
owncloud_ver=7.0.4
owncloud_ver=8.0.2
# 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
echo installing ownCloud...
# Clear out the existing ownCloud.
rm -f /tmp/owncloud-config.php
if [ ! -d /usr/local/lib/owncloud/ ]; then
echo installing ownCloud...
else
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
cp /usr/local/lib/owncloud/config/config.php /tmp/owncloud-config.php
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
fi
# Download and extract ownCloud.
rm -f /tmp/owncloud.zip
wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-$owncloud_ver.zip
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
hide_output php /usr/local/lib/owncloud/occ upgrade #if OC is up-to-date it wont matter
rm -f /tmp/owncloud.zip
# 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
@@ -105,9 +136,12 @@ fi
# Enable/disable apps. Note that this must be done after the ownCloud setup.
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
# user_external is what allows ownCloud to use IMAP for login.
hide_output php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
hide_output php /usr/local/lib/owncloud/console.php app:enable user_external
# user_external is what allows ownCloud to use IMAP for login. The contacts
# and calendar apps are the extensions we really care about here.
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
# (semicolon is the comment character in this file, hashes produce deprecation warnings)

View File

@@ -4,7 +4,7 @@ if [ -z "$NONINTERACTIVE" ]; then
# 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.
hide_output apt-get -y install dialog
apt_get_quiet install dialog
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.

View File

@@ -77,7 +77,7 @@ fi
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
echo "Private IPv6 Address: $PRIVATE_IPV6"
fi
if [ -f /usr/bin/git ]; then
if [ -f .git ]; then
echo "Mail-in-a-Box Version: " $(git describe)
fi
echo
@@ -87,17 +87,37 @@ if [ -z "$SKIP_NETWORK_CHECKS" ]; then
. setup/network-checks.sh
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
# 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
STORAGE_USER=user-data
if [ ! -d /home/$STORAGE_USER ]; then useradd -m $STORAGE_USER; fi
STORAGE_ROOT=/home/$STORAGE_USER
STORAGE_ROOT=$([[ -z "$DEFAULT_STORAGE_ROOT" ]] && echo "/home/$STORAGE_USER" || echo "$DEFAULT_STORAGE_ROOT")
fi
# 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
fi
# Create mailinabox.version file if not exists
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
fi
# Save the global options in /etc/mailinabox.conf so that standalone
# tools know where to look for data.
cat > /etc/mailinabox.conf << EOF;
@@ -126,10 +146,13 @@ source setup/owncloud.sh
source setup/zpush.sh
source setup/management.sh
# Write the DNS and nginx configuration files.
sleep 5 # wait for the daemon to start
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update
# Ping the management daemon to write the DNS and nginx configuration files.
while [ ! -f /var/lib/mailinabox/api.key ]; do
echo Waiting for the Mail-in-a-Box management daemon to start...
sleep 2
done
tools/dns_update
tools/web_update
# If there aren't any mail users yet, create one.
source setup/firstuser.sh

View File

@@ -9,7 +9,7 @@ source setup/functions.sh # load our functions
echo Updating system packages...
hide_output apt-get update
hide_output apt-get -y upgrade
apt_get_quiet upgrade
# Install basic utilities.
#
@@ -20,12 +20,13 @@ hide_output apt-get -y upgrade
# * 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-dev python3-pip \
wget curl sudo coreutils bc \
wget curl git sudo coreutils bc \
haveged unattended-upgrades cron ntp fail2ban
# Allow apt to install system updates automatically every day.
@@ -106,3 +107,11 @@ fi
restart_service bind9
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

@@ -53,6 +53,16 @@ cat conf/ios-profile.xml \
> /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
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

View File

@@ -30,24 +30,33 @@ apt_install \
apt-get purge -qq -y roundcube* #NODOC
# Install Roundcube from source if it is not already present or if it is out of date.
VERSION=1.0.3
# Combine the Roundcube version number with the commit hash of vacation_sieve to track
# whether we have the latest version.
VERSION=1.1.0
VACATION_SIEVE_VERSION=06a20e9d44db62259ae41fd8451f3c937d3ab4f3
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
# not installed yet #NODOC
needs_update=1 #NODOC
elif [[ $VERSION != `cat /usr/local/lib/roundcubemail/version` ]]; then
elif [[ "$VERSION:$VACATION_SIEVE_VERSION" != `cat /usr/local/lib/roundcubemail/version` ]]; then
# checks if the version is what we want
needs_update=1 #NODOC
fi
if [ $needs_update == 1 ]; then
echo installing roudcube webmail $VERSION...
# install roundcube
echo installing Roundcube webmail $VERSION...
rm -f /tmp/roundcube.tgz
wget -qO /tmp/roundcube.tgz http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
rm -rf /usr/local/lib/roundcubemail
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
rm -f /tmp/roundcube.tgz
echo $VERSION > /usr/local/lib/roundcubemail/version
# install roundcube autoreply/vacation plugin
git_clone https://github.com/arodier/Roundcube-Plugins.git $VACATION_SIEVE_VERSION plugins/vacation_sieve /usr/local/lib/roundcubemail/plugins/vacation_sieve
# record the version we've installed
echo $VERSION:$VACATION_SIEVE_VERSION > /usr/local/lib/roundcubemail/version
fi
# ### Configuring Roundcube
@@ -79,7 +88,7 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
\$config['support_url'] = 'https://mailinabox.email/';
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
\$config['des_key'] = '$SECRET_KEY';
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve');
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve');
\$config['skin'] = 'classic';
\$config['login_autocomplete'] = 2;
\$config['password_charset'] = 'UTF-8';
@@ -87,6 +96,26 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<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.
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

View File

@@ -30,17 +30,11 @@ elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
needs_update=1 #NODOC
fi
if [ $needs_update == 1 ]; then
rm -rf /usr/local/lib/z-push
rm -f /tmp/zpush-repo
echo installing z-push \(fmbiete fork\)...
git clone -q https://github.com/fmbiete/Z-Push-contrib /tmp/zpush-repo
(cd /tmp/zpush-repo/; git checkout -q $TARGETHASH;)
rm -rf /tmp/zpush-repo/.git
mv /tmp/zpush-repo /usr/local/lib/z-push
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
rm -f /usr/sbin/z-push-{admin,top}
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
rm -f /tmp/zpush-repo
echo $TARGETHASH > /usr/local/lib/z-push/version
fi

View File

@@ -28,13 +28,17 @@ def mgmt(cmd, data=None, is_json=False):
return resp
def read_password():
first = getpass.getpass('password: ')
second = getpass.getpass(' (again): ')
while first != second:
print('Passwords not the same. Try again.')
first = getpass.getpass('password: ')
second = getpass.getpass(' (again): ')
return first
while True:
first = getpass.getpass('password: ')
if len(first) < 4:
print('Passwords must be at least four characters.')
continue
second = getpass.getpass(' (again): ')
if first != second:
print('Passwords not the same. Try again.')
continue
break
return first
def setup_key_auth(mgmt_uri):
key = open('/var/lib/mailinabox/api.key').read().strip()