mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ad6b4acc | ||
|
|
ec039719de | ||
|
|
14b16b2f36 | ||
|
|
cbc7e280d6 | ||
|
|
f4fa9c93a0 | ||
|
|
6c64723d7c | ||
|
|
3d21f2223e | ||
|
|
710a69b812 | ||
|
|
dd6a8d9998 | ||
|
|
9f32e5af0a | ||
|
|
298e19598b | ||
|
|
680191d7cb | ||
|
|
81d6d69b85 | ||
|
|
6df72bf4ac | ||
|
|
01f2451349 | ||
|
|
dcd971d079 | ||
|
|
4d22fb9b2a | ||
|
|
c18d58b13f | ||
|
|
b539c2df70 | ||
|
|
64fdb4ddc1 | ||
|
|
a8669197dd | ||
|
|
2412c92772 | ||
|
|
7c0ca42145 | ||
|
|
c443524ee2 | ||
|
|
e2fa01e0cf | ||
|
|
6558f05d1d | ||
|
|
1be0f39be0 | ||
|
|
d01001f2a5 | ||
|
|
7c85694d60 | ||
|
|
b2fcd4c9e5 | ||
|
|
ba8123f08a | ||
|
|
e2879a8eb1 | ||
|
|
eab8652225 | ||
|
|
ead6f96513 | ||
|
|
7ec662c83f | ||
|
|
348d2b8701 | ||
|
|
12f0dcb23b | ||
|
|
449a538e6b | ||
|
|
3c50c9a18b | ||
|
|
3c10ec70a5 | ||
|
|
1a59f343c0 | ||
|
|
fba4d4702e | ||
|
|
143bbf37f4 | ||
|
|
fd3ad267ba | ||
|
|
330583f71d | ||
|
|
d775f90f0c | ||
|
|
e096144713 | ||
|
|
7ce30ba888 | ||
|
|
6a3ec1d874 | ||
|
|
575d3a66c6 | ||
|
|
cc333b3965 | ||
|
|
351758b3bd | ||
|
|
94053d8432 | ||
|
|
e14b2826e0 | ||
|
|
150611123a | ||
|
|
abfc17ee62 | ||
|
|
97be9c94b9 | ||
|
|
21b00e8fbb | ||
|
|
01636c2e4b | ||
|
|
005315cd29 | ||
|
|
20d20df829 | ||
|
|
f945a1bc6b | ||
|
|
3a09b04786 | ||
|
|
82e752395b | ||
|
|
e330abd587 | ||
|
|
16422b4055 | ||
|
|
b9ca74c915 | ||
|
|
7e05d7478f | ||
|
|
8fd98d7db3 | ||
|
|
1039a08be6 | ||
|
|
023b38df50 | ||
|
|
3187053b3a | ||
|
|
a3e526e818 | ||
|
|
f6d4621834 | ||
|
|
d075113c1a | ||
|
|
63f2abd923 | ||
|
|
624cc7876a | ||
|
|
d3059c810f | ||
|
|
85a40da83c | ||
|
|
1bf8f1991f | ||
|
|
d155aa8745 | ||
|
|
c0bfd6d15f | ||
|
|
24cc108147 | ||
|
|
b02d7d990e | ||
|
|
87f82addbc | ||
|
|
09713e8eab |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -1,6 +1,73 @@
|
||||
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.
|
||||
|
||||
v0.07 (February 28, 2015)
|
||||
-------------------------
|
||||
|
||||
Mail:
|
||||
|
||||
* If the box manages mail for a domain and a subdomain of that domain, outbound mail from the subdomain was not DKIM-signed and would therefore fail DMARC tests on the receiving end, possibly result in the mail heading into spam folders.
|
||||
* Auto-configuration for Mozilla Thunderbird, Evolution, KMail, and Kontact is now available.
|
||||
* Domains that only have a catch-all alias or domain alias no longer automatically create/require admin@ and postmaster@ addresses since they'll forward anyway.
|
||||
* Roundcube is updated to version 1.1.0.
|
||||
* Authentication-Results headers for DMARC are now added to incoming mail.
|
||||
|
||||
DNS:
|
||||
|
||||
* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.
|
||||
* If a custom DNS A record overrides one provided by the box, the a corresponding default IPv6 record by the box is removed since it will probably be incorrect.
|
||||
* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.
|
||||
|
||||
Web:
|
||||
|
||||
* Static websites now deny access to certain dot (.) files and directories which typically have sensitive info: .ht*, .svn*, .git*, .hg*, .bzr*.
|
||||
* The nginx server no longer reports its version and OS for better privacy.
|
||||
* The HTTP->HTTPS redirect is now more efficient.
|
||||
* When serving a 'www.' domain, reuse the SSL certificate for the parent domain if it covers the 'www' subdomain too
|
||||
* If a custom DNS CNAME record is set on a domain, don't offer to put a website on that domain. (Same logic already applies to custom A/AAAA records.)
|
||||
|
||||
Control panel:
|
||||
|
||||
* Status checks now check that system services are actually running by pinging each port that should have something running on it.
|
||||
* The status checks are now parallelized so they may be a little faster.
|
||||
* The status check for MX records now allow any priority, in case an unusual setup is required.
|
||||
* The interface for setting website domain-specific directories is simplified.
|
||||
* The mail guide now says that to use Outlook, Outlook 2007 or later on Windows 7 and later is required.
|
||||
* External DNS settings now skip the special "_secondary_nameserver" key which is used for storing secondary NS information.
|
||||
|
||||
Setup:
|
||||
|
||||
* Install cron if it isn't already installed.
|
||||
* Fix a units problem in the minimum memory check.
|
||||
* If you override the STORAGE_ROOT, your setting will now persist if you re-run setup.
|
||||
* Hangs due to apt wanting the user to resolve a conflict should now be fixed (apt will just clobber the problematic file now).
|
||||
|
||||
v0.06 (January 4, 2015)
|
||||
-----------------------
|
||||
|
||||
|
||||
22
conf/fail2ban/dovecotimap.conf
Normal file
22
conf/fail2ban/dovecotimap.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
# Fail2Ban filter Dovecot authentication and pop3/imap server
|
||||
# For Mail-in-a-Box
|
||||
|
||||
[INCLUDES]
|
||||
|
||||
before = common.conf
|
||||
|
||||
[Definition]
|
||||
|
||||
_daemon = (auth|dovecot(-auth)?|auth-worker)
|
||||
|
||||
failregex = ^%(__prefix_line)s(pop3|imap)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
|
||||
|
||||
ignoreregex =
|
||||
|
||||
# DEV Notes:
|
||||
# * the first regex is essentially a copy of pam-generic.conf
|
||||
# * Probably doesn't do dovecot sql/ldap backends properly
|
||||
#
|
||||
# Author: Martin Waschbuesch
|
||||
# Daniel Black (rewrote with begin and end anchors)
|
||||
# Mail-in-a-Box (swapped session=...)
|
||||
34
conf/fail2ban/jail.local
Normal file
34
conf/fail2ban/jail.local
Normal file
@@ -0,0 +1,34 @@
|
||||
# Fail2Ban configuration file.
|
||||
# For Mail-in-a-Box
|
||||
[DEFAULT]
|
||||
|
||||
# bantime in seconds
|
||||
bantime = 60
|
||||
|
||||
# This should ban dumb brute-force attacks, not oblivious users.
|
||||
findtime = 30
|
||||
maxretry = 20
|
||||
|
||||
#
|
||||
# JAILS
|
||||
#
|
||||
|
||||
[ssh]
|
||||
|
||||
enabled = true
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 20
|
||||
|
||||
[ssh-ddos]
|
||||
|
||||
enabled = true
|
||||
maxretry = 20
|
||||
|
||||
[sasl]
|
||||
|
||||
enabled = true
|
||||
|
||||
[dovecot]
|
||||
|
||||
enabled = true
|
||||
filter = dovecotimap
|
||||
44
conf/mozilla-autoconfig.xml
Normal file
44
conf/mozilla-autoconfig.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="PRIMARY_HOSTNAME">
|
||||
<domain>PRIMARY_HOSTNAME</domain>
|
||||
|
||||
<displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>
|
||||
<displayShortName>PRIMARY_HOSTNAME</displayShortName>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<addThisServer>true</addThisServer>
|
||||
<useGlobalPreferredServer>true</useGlobalPreferredServer>
|
||||
</outgoingServer>
|
||||
|
||||
<documentation url="https://PRIMARY_HOSTNAME/">
|
||||
<descr lang="en">PRIMARY_HOSTNAME website.</descr>
|
||||
</documentation>
|
||||
</emailProvider>
|
||||
|
||||
<webMail>
|
||||
<loginPage url="https://PRIMARY_HOSTNAME/mail/" />
|
||||
<loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" >
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<usernameField id="rcmloginuser" name="_user" />
|
||||
<passwordField id="rcmloginpwd" name="_pass" />
|
||||
<loginButton id="rcmloginsubmit" />
|
||||
</loginPageInfo>
|
||||
</webMail>
|
||||
|
||||
<clientConfigUpdate url="https://PRIMARY_HOSTNAME/.well-known/autoconfig/mail/config-v1.1.xml" />
|
||||
|
||||
</clientConfig>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import base64, os, os.path
|
||||
import base64, os, os.path, hmac
|
||||
|
||||
from flask import make_response
|
||||
|
||||
import utils
|
||||
from mailconfig import get_mail_user_privileges
|
||||
from mailconfig import get_mail_password, get_mail_user_privileges
|
||||
|
||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||
@@ -40,10 +40,12 @@ class KeyAuthService:
|
||||
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
||||
key_file.write(self.key + '\n')
|
||||
|
||||
def is_authenticated(self, request, env):
|
||||
def authenticate(self, request, env):
|
||||
"""Test if the client key passed in HTTP Authorization header matches the service key
|
||||
or if the or username/password passed in the header matches an administrator user.
|
||||
Returns 'OK' if the key is good or the user is an administrator, otherwise an error message."""
|
||||
Returns a tuple of the user's email address and list of user privileges (e.g.
|
||||
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
|
||||
If the user used an API key, the user's email is returned as None."""
|
||||
|
||||
def decode(s):
|
||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||
@@ -63,46 +65,64 @@ class KeyAuthService:
|
||||
|
||||
header = request.headers.get('Authorization')
|
||||
if not header:
|
||||
return "No authorization header provided."
|
||||
raise ValueError("No authorization header provided.")
|
||||
|
||||
username, password = parse_basic_auth(header)
|
||||
|
||||
if username in (None, ""):
|
||||
return "Authorization header invalid."
|
||||
raise ValueError("Authorization header invalid.")
|
||||
elif username == self.key:
|
||||
return "OK"
|
||||
# The user passed the API key which grants administrative privs.
|
||||
return (None, ["admin"])
|
||||
else:
|
||||
return self.check_imap_login( username, password, env)
|
||||
# The user is trying to log in with a username and user-specific
|
||||
# API key or password. Raises or returns privs.
|
||||
return (username, self.get_user_credentials(username, password, env))
|
||||
|
||||
def check_imap_login(self, email, pw, env):
|
||||
# Validate a user's credentials.
|
||||
def get_user_credentials(self, email, pw, env):
|
||||
# Validate a user's credentials. On success returns a list of
|
||||
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
|
||||
# with a login error message.
|
||||
|
||||
# Sanity check.
|
||||
if email == "" or pw == "":
|
||||
return "Enter an email address and password."
|
||||
raise ValueError("Enter an email address and password.")
|
||||
|
||||
# Authenticate.
|
||||
try:
|
||||
# Use doveadm to check credentials. doveadm will return
|
||||
# a non-zero exit status if the credentials are no good,
|
||||
# and check_call will raise an exception in that case.
|
||||
utils.shell('check_call', [
|
||||
"/usr/bin/doveadm",
|
||||
"auth", "test",
|
||||
email, pw
|
||||
])
|
||||
except:
|
||||
# Login failed.
|
||||
return "Invalid email address or password."
|
||||
# The password might be a user-specific API key.
|
||||
if hmac.compare_digest(self.create_user_key(email), pw):
|
||||
# OK.
|
||||
pass
|
||||
else:
|
||||
# Get the hashed password of the user. Raise a ValueError if the
|
||||
# email address does not correspond to a user.
|
||||
pw_hash = get_mail_password(email, env)
|
||||
|
||||
# Authorize.
|
||||
# (This call should never fail on a valid user.)
|
||||
# Authenticate.
|
||||
try:
|
||||
# Use 'doveadm pw' to check credentials. doveadm will return
|
||||
# a non-zero exit status if the credentials are no good,
|
||||
# and check_call will raise an exception in that case.
|
||||
utils.shell('check_call', [
|
||||
"/usr/bin/doveadm", "pw",
|
||||
"-p", pw,
|
||||
"-t", pw_hash,
|
||||
])
|
||||
except:
|
||||
# Login failed.
|
||||
raise ValueError("Invalid password.")
|
||||
|
||||
# Get privileges for authorization.
|
||||
|
||||
# (This call should never fail on a valid user. But if it did fail, it would
|
||||
# return a tuple of an error message and an HTTP status code.)
|
||||
privs = get_mail_user_privileges(email, env)
|
||||
if isinstance(privs, tuple): raise Exception("Error getting privileges.")
|
||||
if "admin" not in privs:
|
||||
return "You are not an administrator for this system."
|
||||
|
||||
return "OK"
|
||||
# Return a list of privileges.
|
||||
return privs
|
||||
|
||||
def create_user_key(self, email):
|
||||
return hmac.new(self.key.encode('ascii'), b"AUTH:" + email.encode("utf8"), digestmod="sha1").hexdigest()
|
||||
|
||||
def _generate_key(self):
|
||||
raw_key = os.urandom(32)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
# Helps you purchase a SSL certificate from Gandi.net using
|
||||
# their API.
|
||||
#
|
||||
# Before you begin:
|
||||
# 1) Create an account on Gandi.net.
|
||||
# 2) Pre-pay $16 into your account at https://www.gandi.net/prepaid/operations. Wait until the payment goes through.
|
||||
# 3) Activate your API key first on the test platform (wait a while, refresh the page) and then activate the production API at https://www.gandi.net/admin/api_key.
|
||||
|
||||
import sys, re, os.path, urllib.request
|
||||
import xmlrpc.client
|
||||
import rtyaml
|
||||
|
||||
from utils import load_environment, shell
|
||||
from web_update import get_web_domains, get_domain_ssl_files, get_web_root
|
||||
from status_checks import check_certificate
|
||||
|
||||
def buy_ssl_certificate(api_key, domain, command, env):
|
||||
if domain != env['PRIMARY_HOSTNAME'] \
|
||||
and domain not in get_web_domains(env):
|
||||
raise ValueError("Domain is not %s or a domain we're serving a website for." % env['PRIMARY_HOSTNAME'])
|
||||
|
||||
# Initialize.
|
||||
|
||||
gandi = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/')
|
||||
|
||||
try:
|
||||
existing_certs = gandi.cert.list(api_key)
|
||||
except Exception as e:
|
||||
if "Invalid API key" in str(e):
|
||||
print("Invalid API key. Check that you copied the API Key correctly from https://www.gandi.net/admin/api_key.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Where is the SSL cert stored?
|
||||
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
|
||||
# Have we already created a cert for this domain?
|
||||
|
||||
for cert in existing_certs:
|
||||
if cert['cn'] == domain:
|
||||
break
|
||||
else:
|
||||
# No existing cert found. Purchase one.
|
||||
if command != 'purchase':
|
||||
print("No certificate or order found yet. If you haven't yet purchased a certificate, run ths script again with the 'purchase' command. Otherwise wait a moment and try again.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Start an order for a single standard SSL certificate.
|
||||
# Use DNS validation. Web-based validation won't work because they
|
||||
# require a file on HTTP but not HTTPS w/o redirects and we don't
|
||||
# serve anything plainly over HTTP. Email might be another way but
|
||||
# DNS is easier to automate.
|
||||
op = gandi.cert.create(api_key, {
|
||||
"csr": open(ssl_csr_path).read(),
|
||||
"dcv_method": "dns",
|
||||
"duration": 1, # year?
|
||||
"package": "cert_std_1_0_0",
|
||||
})
|
||||
print("An SSL certificate has been ordered.")
|
||||
print()
|
||||
print(op)
|
||||
print()
|
||||
print("In a moment please run this script again with the 'setup' command.")
|
||||
|
||||
if cert['status'] == 'pending':
|
||||
# Get the information we need to update our DNS with a code so that
|
||||
# Gandi can verify that we own the domain.
|
||||
|
||||
dcv = gandi.cert.get_dcv_params(api_key, {
|
||||
"csr": open(ssl_csr_path).read(),
|
||||
"cert_id": cert['id'],
|
||||
"dcv_method": "dns",
|
||||
"duration": 1, # year?
|
||||
"package": "cert_std_1_0_0",
|
||||
})
|
||||
if dcv["dcv_method"] != "dns":
|
||||
raise Exception("Certificate ordered with an unknown validation method.")
|
||||
|
||||
# Update our DNS data.
|
||||
|
||||
dns_config = env['STORAGE_ROOT'] + '/dns/custom.yaml'
|
||||
if os.path.exists(dns_config):
|
||||
dns_records = rtyaml.load(open(dns_config))
|
||||
else:
|
||||
dns_records = { }
|
||||
|
||||
qname = dcv['md5'] + '.' + domain
|
||||
value = dcv['sha1'] + '.comodoca.com.'
|
||||
dns_records[qname] = { "CNAME": value }
|
||||
|
||||
with open(dns_config, 'w') as f:
|
||||
f.write(rtyaml.dump(dns_records))
|
||||
|
||||
shell('check_call', ['tools/dns_update'])
|
||||
|
||||
# Okay, done with this step.
|
||||
|
||||
print("DNS has been updated. Gandi will check within 60 minutes.")
|
||||
print()
|
||||
print("See https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
|
||||
|
||||
elif cert['status'] == 'valid':
|
||||
# The certificate is ready.
|
||||
|
||||
# Check before we overwrite something we shouldn't.
|
||||
if os.path.exists(ssl_certificate):
|
||||
cert_status, 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())
|
||||
|
||||
@@ -11,6 +11,12 @@ from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_u
|
||||
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()
|
||||
@@ -24,19 +30,32 @@ except OSError:
|
||||
|
||||
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
||||
|
||||
# Decorator to protect views that require authentication.
|
||||
# Decorator to protect views that require a user with 'admin' privileges.
|
||||
def authorized_personnel_only(viewfunc):
|
||||
@wraps(viewfunc)
|
||||
def newview(*args, **kwargs):
|
||||
# Check if the user is authorized.
|
||||
authorized_status = auth_service.is_authenticated(request, env)
|
||||
if authorized_status == "OK":
|
||||
# Authorized. Call view func.
|
||||
# Authenticate the passed credentials, which is either the API key or a username:password pair.
|
||||
error = None
|
||||
try:
|
||||
email, privs = auth_service.authenticate(request, env)
|
||||
except ValueError as e:
|
||||
# Authentication failed.
|
||||
privs = []
|
||||
error = str(e)
|
||||
|
||||
# Authorized to access an API view?
|
||||
if "admin" in privs:
|
||||
# Call view func.
|
||||
return viewfunc(*args, **kwargs)
|
||||
elif not error:
|
||||
error = "You are not an administrator."
|
||||
|
||||
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
||||
status = 401
|
||||
headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) }
|
||||
headers = {
|
||||
'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm),
|
||||
'X-Reason': error,
|
||||
}
|
||||
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
# Don't issue a 401 to an AJAX request because the user will
|
||||
@@ -46,13 +65,13 @@ def authorized_personnel_only(viewfunc):
|
||||
|
||||
if request.headers.get('Accept') in (None, "", "*/*"):
|
||||
# Return plain text output.
|
||||
return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers)
|
||||
return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
|
||||
else:
|
||||
# Return JSON output.
|
||||
return Response(json.dumps({
|
||||
"status": "error",
|
||||
"reason": authorized_status
|
||||
}+"\n"), status=status, mimetype='application/json', headers=headers)
|
||||
"reason": error,
|
||||
})+"\n", status=status, mimetype='application/json', headers=headers)
|
||||
|
||||
return newview
|
||||
|
||||
@@ -81,16 +100,26 @@ def index():
|
||||
@app.route('/me')
|
||||
def me():
|
||||
# Is the caller authorized?
|
||||
authorized_status = auth_service.is_authenticated(request, env)
|
||||
if authorized_status != "OK":
|
||||
try:
|
||||
email, privs = auth_service.authenticate(request, env)
|
||||
except ValueError as e:
|
||||
return json_response({
|
||||
"status": "not-authorized",
|
||||
"reason": authorized_status,
|
||||
"status": "invalid",
|
||||
"reason": str(e),
|
||||
})
|
||||
return json_response({
|
||||
"status": "authorized",
|
||||
"api_key": auth_service.key,
|
||||
})
|
||||
|
||||
resp = {
|
||||
"status": "ok",
|
||||
"email": email,
|
||||
"privileges": privs,
|
||||
}
|
||||
|
||||
# Is authorized as admin? Return an API key for future use.
|
||||
if "admin" in privs:
|
||||
resp["api_key"] = auth_service.create_user_key(email)
|
||||
|
||||
# Return.
|
||||
return json_response(resp)
|
||||
|
||||
# MAIL
|
||||
|
||||
@@ -249,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, csr_path = 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'])
|
||||
@@ -295,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')
|
||||
|
||||
@@ -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,11 +203,13 @@ 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.)"))
|
||||
|
||||
# Add defaults if not overridden by the user's custom settings (and not otherwise configured).
|
||||
# Any "CNAME" record on the qname overrides A and AAAA.
|
||||
defaults = [
|
||||
(None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
|
||||
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
|
||||
@@ -221,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):
|
||||
# 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"))
|
||||
@@ -253,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
|
||||
@@ -381,17 +397,26 @@ $TTL 1800 ; default time to live
|
||||
"""
|
||||
|
||||
# Replace replacement strings.
|
||||
zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"])
|
||||
zone = zone.format(domain=domain.encode("idna").decode("ascii"), primary_domain=env["PRIMARY_HOSTNAME"].encode("idna").decode("ascii"))
|
||||
|
||||
# Add records.
|
||||
for subdomain, querytype, value, explanation in records:
|
||||
if subdomain:
|
||||
zone += subdomain
|
||||
zone += subdomain.encode("idna").decode("ascii")
|
||||
zone += "\tIN\t" + querytype + "\t"
|
||||
if querytype == "TXT":
|
||||
# Quote and escape.
|
||||
value = value.replace('\\', '\\\\') # escape backslashes
|
||||
value = value.replace('"', '\\"') # escape quotes
|
||||
value = '"' + value + '"' # wrap in quotes
|
||||
elif querytype in ("NS", "CNAME"):
|
||||
# These records must be IDNA-encoded.
|
||||
value = value.encode("idna").decode("ascii")
|
||||
elif querytype == "MX":
|
||||
# Also IDNA-encoded, but must parse first.
|
||||
priority, host = value.split(" ", 1)
|
||||
host = host.encode("idna").decode("ascii")
|
||||
value = priority + " " + host
|
||||
zone += value + "\n"
|
||||
|
||||
# DNSSEC requires re-signing a zone periodically. That requires
|
||||
@@ -485,7 +510,7 @@ server:
|
||||
zone:
|
||||
name: %s
|
||||
zonefile: %s
|
||||
""" % (domain, zonefile)
|
||||
""" % (domain.encode("idna").decode("ascii"), zonefile)
|
||||
|
||||
# If a custom secondary nameserver has been set, allow zone transfers
|
||||
# and notifies to that nameserver.
|
||||
@@ -530,6 +555,9 @@ def sign_zone(domain, zonefile, env):
|
||||
algo = dnssec_choose_algo(domain, env)
|
||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
|
||||
|
||||
# From here, use the IDNA encoding of the domain name.
|
||||
domain = domain.encode("idna").decode("ascii")
|
||||
|
||||
# In order to use the same keys for all domains, we have to generate
|
||||
# a new .key file with a DNSSEC record for the specific domain. We
|
||||
# can reuse the same key, but it won't validate without a DNSSEC
|
||||
@@ -599,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')
|
||||
|
||||
@@ -619,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
|
||||
@@ -628,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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -669,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:
|
||||
|
||||
@@ -14,7 +14,7 @@ def validate_email(email, mode=None):
|
||||
|
||||
if mode == 'user':
|
||||
# For Dovecot's benefit, only allow basic characters.
|
||||
ATEXT = r'[\w\-]'
|
||||
ATEXT = r'[a-zA-Z0-9_\-]'
|
||||
elif mode in (None, 'alias'):
|
||||
# For aliases, we can allow any valid email address.
|
||||
# Based on RFC 2822 and https://github.com/SyrusAkbary/validate_email/blob/master/validate_email.py,
|
||||
@@ -36,9 +36,34 @@ def validate_email(email, mode=None):
|
||||
DOT_ATOM_TEXT_HOST = ATEXT + r'+(?:\.' + ATEXT + r'+)+'
|
||||
|
||||
# per RFC 2822 3.4.1
|
||||
ADDR_SPEC = '^%s@%s$' % (DOT_ATOM_TEXT_LOCAL, DOT_ATOM_TEXT_HOST)
|
||||
ADDR_SPEC = '^(%s)@(%s)$' % (DOT_ATOM_TEXT_LOCAL, DOT_ATOM_TEXT_HOST)
|
||||
|
||||
return re.match(ADDR_SPEC, email)
|
||||
# Check the regular expression.
|
||||
m = re.match(ADDR_SPEC, email)
|
||||
if not m: return False
|
||||
|
||||
# Check that the domain part is IDNA-encodable.
|
||||
localpart, domainpart = m.groups()
|
||||
try:
|
||||
domainpart.encode("idna")
|
||||
except:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def sanitize_idn_email_address(email):
|
||||
# Convert an IDNA-encoded email address (domain part) into Unicode
|
||||
# before storing in our database. Chrome may IDNA-ize <input type="email">
|
||||
# values before POSTing, so we want to normalize before putting
|
||||
# values into the database.
|
||||
try:
|
||||
localpart, domainpart = email.split("@")
|
||||
domainpart = domainpart.encode("ascii").decode("idna")
|
||||
return localpart + "@" + domainpart
|
||||
except:
|
||||
# Domain part is already Unicode or not IDNA-valid, so
|
||||
# leave unchanged.
|
||||
return email
|
||||
|
||||
def open_database(env, with_connection=False):
|
||||
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
||||
@@ -66,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", ...]),
|
||||
# ...
|
||||
# ]
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
@@ -77,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()
|
||||
@@ -96,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.
|
||||
@@ -205,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]
|
||||
|
||||
@@ -230,11 +229,16 @@ def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||
)
|
||||
|
||||
def add_mail_user(email, pw, privs, env):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
email = sanitize_idn_email_address(email)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -251,7 +255,7 @@ def add_mail_user(email, pw, privs, env):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
|
||||
# hash the password
|
||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||
pw = hash_password(pw)
|
||||
|
||||
# add the user to the database
|
||||
try:
|
||||
@@ -263,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.
|
||||
@@ -276,7 +282,7 @@ 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])
|
||||
|
||||
@@ -284,10 +290,14 @@ def add_mail_user(email, pw, privs, env):
|
||||
return kick(env, "mail user added")
|
||||
|
||||
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
|
||||
validate_password(pw)
|
||||
|
||||
# hash the password
|
||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||
pw = hash_password(pw)
|
||||
|
||||
# update the database
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
@@ -297,7 +307,29 @@ def set_mail_password(email, pw, env):
|
||||
conn.commit()
|
||||
return "OK"
|
||||
|
||||
def hash_password(pw):
|
||||
# Turn the plain password into a Dovecot-format hashed password, meaning
|
||||
# something like "{SCHEME}hashedpassworddata".
|
||||
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||
|
||||
def get_mail_password(email, env):
|
||||
# Gets the hashed password for a user. Passwords are stored in Dovecot's
|
||||
# password format, with a prefixed scheme.
|
||||
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||
# update the database
|
||||
c = open_database(env)
|
||||
c.execute('SELECT password FROM users WHERE email=?', (email,))
|
||||
rows = c.fetchall()
|
||||
if len(rows) != 1:
|
||||
raise ValueError("That's not a user (%s)." % email)
|
||||
return rows[0][0]
|
||||
|
||||
def remove_mail_user(email, env):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
email = sanitize_idn_email_address(email)
|
||||
|
||||
# remove
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute("DELETE FROM users WHERE email=?", (email,))
|
||||
if c.rowcount != 1:
|
||||
@@ -311,6 +343,10 @@ def parse_privs(value):
|
||||
return [p for p in value.split("\n") if p.strip() != ""]
|
||||
|
||||
def get_mail_user_privileges(email, env):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
email = sanitize_idn_email_address(email)
|
||||
|
||||
# get privs
|
||||
c = open_database(env)
|
||||
c.execute('SELECT privileges FROM users WHERE email=?', (email,))
|
||||
rows = c.fetchall()
|
||||
@@ -324,6 +360,9 @@ def validate_privilege(priv):
|
||||
return None
|
||||
|
||||
def add_remove_mail_user_privilege(email, priv, action, env):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
email = sanitize_idn_email_address(email)
|
||||
|
||||
# validate
|
||||
validation = validate_privilege(priv)
|
||||
if validation: return validation
|
||||
@@ -351,6 +390,9 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
||||
return "OK"
|
||||
|
||||
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
source = sanitize_idn_email_address(source)
|
||||
|
||||
# validate source
|
||||
if source.strip() == "":
|
||||
return ("No incoming email address provided.", 400)
|
||||
@@ -363,13 +405,14 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
|
||||
if validate_email(destination, mode='alias'):
|
||||
# Oostfix allows a single @domain.tld as the destination, which means
|
||||
# the local part on the address is preserved in the rewrite.
|
||||
dests.append(destination)
|
||||
dests.append(sanitize_idn_email_address(destination))
|
||||
else:
|
||||
# Parse comma and \n-separated destination emails & validate. In this
|
||||
# case, the recipients must be complete email addresses.
|
||||
for line in destination.split("\n"):
|
||||
for email in line.split(","):
|
||||
email = email.strip()
|
||||
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
||||
if email == "": continue
|
||||
if not validate_email(email):
|
||||
return ("Invalid destination email address (%s)." % email, 400)
|
||||
@@ -397,6 +440,10 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
|
||||
return kick(env, return_status)
|
||||
|
||||
def remove_mail_alias(source, env, do_kick=True):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
source = sanitize_idn_email_address(source)
|
||||
|
||||
# remove
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute("DELETE FROM aliases WHERE source=?", (source,))
|
||||
if c.rowcount != 1:
|
||||
@@ -418,12 +465,12 @@ def get_required_aliases(env):
|
||||
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
||||
|
||||
# Get a list of domains we serve mail for, except ones for which the only
|
||||
# email on that domain is a postmaster/admin alias to the administrator.
|
||||
# email on that domain is a postmaster/admin alias to the administrator
|
||||
# or a wildcard alias (since it will forward postmaster/admin).
|
||||
real_mail_domains = get_mail_domains(env,
|
||||
filter_aliases = lambda alias : \
|
||||
(not alias[0].startswith("postmaster@") \
|
||||
and not alias[0].startswith("admin@")) \
|
||||
or alias[1] != get_system_administrator(env) \
|
||||
filter_aliases = lambda alias :
|
||||
((not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@")) or alias[1] != get_system_administrator(env))
|
||||
and not alias[0].startswith("@")
|
||||
)
|
||||
|
||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
__ALL__ = ['check_certificate']
|
||||
|
||||
import os, os.path, re, subprocess, datetime
|
||||
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
||||
|
||||
import dns.reversename, dns.resolver
|
||||
import dateutil.parser, dateutil.tz
|
||||
@@ -17,24 +17,129 @@ 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, pool):
|
||||
# If critical services are not running, stop. If bind9 isn't running,
|
||||
# all later DNS checks will timeout and that will take forever to
|
||||
# go through, and if running over the web will cause a fastcgi timeout.
|
||||
return
|
||||
|
||||
# clear bind9's DNS cache so our DNS checks are up to date
|
||||
shell('check_call', ["/usr/sbin/rndc", "flush"])
|
||||
# (ignore errors; if bind9/rndc isn't running we'd already report
|
||||
# that in run_services checks.)
|
||||
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
||||
|
||||
# perform checks
|
||||
env["out"] = output
|
||||
run_system_checks(env)
|
||||
run_network_checks(env)
|
||||
run_domain_checks(env)
|
||||
run_system_checks(rounded_values, env, output)
|
||||
|
||||
def run_system_checks(env):
|
||||
env["out"].add_heading("System")
|
||||
check_ssh_password(env)
|
||||
check_software_updates(env)
|
||||
check_system_aliases(env)
|
||||
check_free_disk_space(env)
|
||||
# perform other checks asynchronously
|
||||
|
||||
def check_ssh_password(env):
|
||||
run_network_checks(env, output)
|
||||
run_domain_checks(rounded_values, env, output, pool)
|
||||
|
||||
def get_ssh_port():
|
||||
# Returns ssh port
|
||||
output = shell('check_output', ['sshd', '-T'])
|
||||
returnNext = False
|
||||
|
||||
for e in output.split():
|
||||
if returnNext:
|
||||
return int(e)
|
||||
if e == "port":
|
||||
returnNext = True
|
||||
|
||||
def run_services_checks(env, output, pool):
|
||||
# Check that system services are running.
|
||||
|
||||
services = [
|
||||
{ "name": "Local DNS (bind9)", "port": 53, "public": False, },
|
||||
#{ "name": "NSD Control", "port": 8952, "public": False, },
|
||||
{ "name": "Local DNS Control (bind9/rndc)", "port": 953, "public": False, },
|
||||
{ "name": "Dovecot LMTP LDA", "port": 10026, "public": False, },
|
||||
{ "name": "Postgrey", "port": 10023, "public": False, },
|
||||
{ "name": "Spamassassin", "port": 10025, "public": False, },
|
||||
{ "name": "OpenDKIM", "port": 8891, "public": False, },
|
||||
{ "name": "OpenDMARC", "port": 8893, "public": False, },
|
||||
{ "name": "Memcached", "port": 11211, "public": False, },
|
||||
{ "name": "Sieve (dovecot)", "port": 4190, "public": False, },
|
||||
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
|
||||
|
||||
{ "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, },
|
||||
{ "name": "Public DNS (nsd4)", "port": 53, "public": True, },
|
||||
{ "name": "Incoming Mail (SMTP/postfix)", "port": 25, "public": True, },
|
||||
{ "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, },
|
||||
#{ "name": "Postfix/master", "port": 10587, "public": True, },
|
||||
{ "name": "IMAPS (dovecot)", "port": 993, "public": True, },
|
||||
{ "name": "HTTP Web (nginx)", "port": 80, "public": True, },
|
||||
{ "name": "HTTPS Web (nginx)", "port": 443, "public": True, },
|
||||
]
|
||||
|
||||
all_running = True
|
||||
fatal = False
|
||||
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1)
|
||||
for i, running, fatal2, output2 in sorted(ret):
|
||||
all_running = all_running and running
|
||||
fatal = fatal or fatal2
|
||||
output2.playback(output)
|
||||
|
||||
if all_running:
|
||||
output.print_ok("All system services are running.")
|
||||
|
||||
return not fatal
|
||||
|
||||
def check_service(i, service, env):
|
||||
import socket
|
||||
output = BufferedOutput()
|
||||
running = False
|
||||
fatal = False
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
try:
|
||||
try:
|
||||
s.connect((
|
||||
"127.0.0.1" if not service["public"] else env['PUBLIC_IP'],
|
||||
service["port"]))
|
||||
running = True
|
||||
except OSError as e1:
|
||||
if service["public"] and service["port"] != 53:
|
||||
# For public services (except DNS), try the private IP as a fallback.
|
||||
s1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s1.settimeout(1)
|
||||
try:
|
||||
s1.connect(("127.0.0.1", service["port"]))
|
||||
output.print_error("%s is running but is not publicly accessible at %s:%d (%s)." % (service['name'], env['PUBLIC_IP'], service['port'], str(e1)))
|
||||
except:
|
||||
raise e1
|
||||
finally:
|
||||
s1.close()
|
||||
else:
|
||||
raise
|
||||
|
||||
except OSError as e:
|
||||
output.print_error("%s is not running (%s; port %d)." % (service['name'], str(e), service['port']))
|
||||
|
||||
# Why is nginx not running?
|
||||
if service["port"] in (80, 443):
|
||||
output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())
|
||||
|
||||
# Flag if local DNS is not running.
|
||||
if service["port"] == 53 and service["public"] == False:
|
||||
fatal = True
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
return (i, running, fatal, output)
|
||||
|
||||
def run_system_checks(rounded_values, env, output):
|
||||
check_ssh_password(env, output)
|
||||
check_software_updates(env, output)
|
||||
check_system_aliases(env, output)
|
||||
check_free_disk_space(rounded_values, env, output)
|
||||
|
||||
def check_ssh_password(env, output):
|
||||
# Check that SSH login with password is disabled. The openssh-server
|
||||
# package may not be installed so check that before trying to access
|
||||
# the configuration file.
|
||||
@@ -43,57 +148,59 @@ def check_ssh_password(env):
|
||||
sshd = open("/etc/ssh/sshd_config").read()
|
||||
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
||||
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
||||
env['out'].print_error("""The SSH server on this machine permits password-based login. A more secure
|
||||
output.print_error("""The SSH server on this machine permits password-based login. A more secure
|
||||
way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check
|
||||
that you can log in without a password, set the option 'PasswordAuthentication no' in
|
||||
/etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""")
|
||||
else:
|
||||
env['out'].print_ok("SSH disallows password-based login.")
|
||||
output.print_ok("SSH disallows password-based login.")
|
||||
|
||||
def check_software_updates(env):
|
||||
def check_software_updates(env, output):
|
||||
# Check for any software package updates.
|
||||
pkgs = list_apt_updates(apt_update=False)
|
||||
if os.path.exists("/var/run/reboot-required"):
|
||||
env['out'].print_error("System updates have been installed and a reboot of the machine is required.")
|
||||
output.print_error("System updates have been installed and a reboot of the machine is required.")
|
||||
elif len(pkgs) == 0:
|
||||
env['out'].print_ok("System software is up to date.")
|
||||
output.print_ok("System software is up to date.")
|
||||
else:
|
||||
env['out'].print_error("There are %d software packages that can be updated." % len(pkgs))
|
||||
output.print_error("There are %d software packages that can be updated." % len(pkgs))
|
||||
for p in pkgs:
|
||||
env['out'].print_line("%s (%s)" % (p["package"], p["version"]))
|
||||
output.print_line("%s (%s)" % (p["package"], p["version"]))
|
||||
|
||||
def check_system_aliases(env):
|
||||
def check_system_aliases(env, output):
|
||||
# Check that the administrator alias exists since that's where all
|
||||
# admin email is automatically directed.
|
||||
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env)
|
||||
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env, output)
|
||||
|
||||
def check_free_disk_space(env):
|
||||
def check_free_disk_space(rounded_values, env, output):
|
||||
# Check free disk space.
|
||||
st = os.statvfs(env['STORAGE_ROOT'])
|
||||
bytes_total = st.f_blocks * st.f_frsize
|
||||
bytes_free = st.f_bavail * st.f_frsize
|
||||
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10.0)
|
||||
if bytes_free > .3 * bytes_total:
|
||||
env['out'].print_ok(disk_msg)
|
||||
elif bytes_free > .15 * bytes_total:
|
||||
env['out'].print_warning(disk_msg)
|
||||
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:
|
||||
env['out'].print_error(disk_msg)
|
||||
disk_msg = "The disk has less than %s%% space left." % str(round(bytes_free/bytes_total/10 + .5)*10)
|
||||
if bytes_free > .3 * bytes_total:
|
||||
output.print_ok(disk_msg)
|
||||
elif bytes_free > .15 * bytes_total:
|
||||
output.print_warning(disk_msg)
|
||||
else:
|
||||
output.print_error(disk_msg)
|
||||
|
||||
|
||||
def run_network_checks(env):
|
||||
def run_network_checks(env, output):
|
||||
# Also see setup/network-checks.sh.
|
||||
|
||||
env["out"].add_heading("Network")
|
||||
output.add_heading("Network")
|
||||
|
||||
# Stop if we cannot make an outbound connection on port 25. Many residential
|
||||
# networks block outbound port 25 to prevent their network from sending spam.
|
||||
# See if we can reach one of Google's MTAs with a 5-second timeout.
|
||||
code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True)
|
||||
if ret == 0:
|
||||
env['out'].print_ok("Outbound mail (SMTP port 25) is not blocked.")
|
||||
output.print_ok("Outbound mail (SMTP port 25) is not blocked.")
|
||||
else:
|
||||
env['out'].print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
|
||||
output.print_error("""Outbound mail (SMTP port 25) seems to be blocked by your network. You
|
||||
will not be able to send any mail. Many residential networks block port 25 to prevent hijacked
|
||||
machines from being able to send spam. A quick connection test to Google's mail server on port 25
|
||||
failed.""")
|
||||
@@ -105,13 +212,13 @@ def run_network_checks(env):
|
||||
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
|
||||
zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None)
|
||||
if zen is None:
|
||||
env['out'].print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
||||
output.print_ok("IP address is not blacklisted by zen.spamhaus.org.")
|
||||
else:
|
||||
env['out'].print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
|
||||
output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s),
|
||||
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
|
||||
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
|
||||
|
||||
def run_domain_checks(env):
|
||||
def run_domain_checks(rounded_time, env, output, pool):
|
||||
# Get the list of domains we handle mail for.
|
||||
mail_domains = get_mail_domains(env)
|
||||
|
||||
@@ -122,52 +229,80 @@ def run_domain_checks(env):
|
||||
# Get the list of domains we serve HTTPS for.
|
||||
web_domains = set(get_web_domains(env))
|
||||
|
||||
# Check the domains.
|
||||
for domain in sort_domains(mail_domains | dns_domains | web_domains, env):
|
||||
env["out"].add_heading(domain)
|
||||
domains_to_check = mail_domains | dns_domains | web_domains
|
||||
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles)
|
||||
# Serial version:
|
||||
#for domain in sort_domains(domains_to_check, env):
|
||||
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||
|
||||
# Parallelize the checks across a worker pool.
|
||||
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||
for domain in domains_to_check)
|
||||
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
||||
ret = dict(ret) # (domain, output) => { domain: output }
|
||||
for domain in sort_domains(ret, env):
|
||||
ret[domain].playback(output)
|
||||
|
||||
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
|
||||
output = BufferedOutput()
|
||||
|
||||
output.add_heading(domain)
|
||||
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
|
||||
|
||||
if domain in dns_domains:
|
||||
check_dns_zone(domain, env, dns_zonefiles)
|
||||
if domain in dns_domains:
|
||||
check_dns_zone(domain, env, output, dns_zonefiles)
|
||||
|
||||
if domain in mail_domains:
|
||||
check_mail_domain(domain, env)
|
||||
if domain in mail_domains:
|
||||
check_mail_domain(domain, env, output)
|
||||
|
||||
if domain in web_domains:
|
||||
check_web_domain(domain, env)
|
||||
if domain in web_domains:
|
||||
check_web_domain(domain, rounded_time, env, output)
|
||||
|
||||
if domain in dns_domains:
|
||||
check_dns_zone_suggestions(domain, env, dns_zonefiles)
|
||||
if domain in dns_domains:
|
||||
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
|
||||
|
||||
def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
|
||||
return (domain, output)
|
||||
|
||||
def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
||||
# If a DS record is set on the zone containing this domain, check DNSSEC now.
|
||||
has_dnssec = False
|
||||
for zone in dns_domains:
|
||||
if zone == domain or domain.endswith("." + zone):
|
||||
if query_dns(zone, "DS", nxdomain=None) is not None:
|
||||
check_dnssec(zone, env, dns_zonefiles, is_checking_primary=True)
|
||||
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']:
|
||||
env['out'].print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], 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:
|
||||
env['out'].print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
||||
output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
||||
registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for
|
||||
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']:
|
||||
env['out'].print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
output.print_ok("Domain resolves to box's IP address. [%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
else:
|
||||
env['out'].print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
||||
output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
||||
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
||||
issues listed here."""
|
||||
% (env['PUBLIC_IP'], ip))
|
||||
@@ -177,9 +312,9 @@ def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
|
||||
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
|
||||
existing_rdns = query_dns(ipaddr_rev, "PTR")
|
||||
if existing_rdns == domain:
|
||||
env['out'].print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
||||
output.print_ok("Reverse DNS is set correctly at ISP. [%s => %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
||||
else:
|
||||
env['out'].print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
||||
output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
||||
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
|
||||
|
||||
# Check the TLSA record.
|
||||
@@ -187,29 +322,32 @@ def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
|
||||
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
|
||||
tlsa25_expected = build_tlsa_record(env)
|
||||
if tlsa25 == tlsa25_expected:
|
||||
env['out'].print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
|
||||
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
|
||||
elif tlsa25 is None:
|
||||
env['out'].print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""")
|
||||
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:
|
||||
env['out'].print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'.
|
||||
output.print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'.
|
||||
It may take several hours for public DNS to update after a change."""
|
||||
% (tlsa_qname, tlsa25, tlsa25_expected))
|
||||
|
||||
# Check that the hostmaster@ email address exists.
|
||||
check_alias_exists("hostmaster@" + domain, env)
|
||||
check_alias_exists("hostmaster@" + domain, env, output)
|
||||
|
||||
def check_alias_exists(alias, env):
|
||||
def check_alias_exists(alias, env, output):
|
||||
mail_alises = dict(get_mail_aliases(env))
|
||||
if alias in mail_alises:
|
||||
env['out'].print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias]))
|
||||
output.print_ok("%s exists as a mail alias [=> %s]" % (alias, mail_alises[alias]))
|
||||
else:
|
||||
env['out'].print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
|
||||
output.print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
|
||||
|
||||
def check_dns_zone(domain, env, dns_zonefiles):
|
||||
def check_dns_zone(domain, env, output, dns_zonefiles):
|
||||
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
|
||||
# If it is not set, we suggest it last.
|
||||
if query_dns(domain, "DS", nxdomain=None) is not None:
|
||||
check_dnssec(domain, env, dns_zonefiles)
|
||||
check_dnssec(domain, env, output, dns_zonefiles)
|
||||
|
||||
# We provide a DNS zone for the domain. It should have NS records set up
|
||||
# at the domain name's registrar pointing to this box. The secondary DNS
|
||||
@@ -217,6 +355,7 @@ def check_dns_zone(domain, env, 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([
|
||||
@@ -224,20 +363,25 @@ def check_dns_zone(domain, env, dns_zonefiles):
|
||||
custom_dns.get("_secondary_nameserver", "ns2." + env['PRIMARY_HOSTNAME']),
|
||||
]))
|
||||
if existing_ns.lower() == correct_ns.lower():
|
||||
env['out'].print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||
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:
|
||||
env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
|
||||
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
|
||||
control panel to set the nameservers to %s."""
|
||||
% (existing_ns, correct_ns) )
|
||||
|
||||
def check_dns_zone_suggestions(domain, env, dns_zonefiles):
|
||||
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles):
|
||||
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
|
||||
# (If it was set, we did the check earlier.)
|
||||
if query_dns(domain, "DS", nxdomain=None) is None:
|
||||
check_dnssec(domain, env, dns_zonefiles)
|
||||
check_dnssec(domain, env, output, dns_zonefiles)
|
||||
|
||||
|
||||
def check_dnssec(domain, env, dns_zonefiles, is_checking_primary=False):
|
||||
def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
|
||||
# See if the domain has a DS record set at the registrar. The DS record may have
|
||||
# several forms. We have to be prepared to check for any valid record. We've
|
||||
# pre-generated all of the valid digests --- read them in.
|
||||
@@ -259,54 +403,58 @@ def check_dnssec(domain, env, dns_zonefiles, is_checking_primary=False):
|
||||
if ds_looks_valid: ds = ds.split(" ")
|
||||
if ds_looks_valid and ds[0] == ds_keytag and ds[1] == ds_alg and ds[3] == digests.get(ds[2]):
|
||||
if is_checking_primary: return
|
||||
env['out'].print_ok("DNSSEC 'DS' record is set correctly at registrar.")
|
||||
output.print_ok("DNSSEC 'DS' record is set correctly at registrar.")
|
||||
else:
|
||||
if ds == None:
|
||||
if is_checking_primary: return
|
||||
env['out'].print_error("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC.
|
||||
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:
|
||||
env['out'].print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain)
|
||||
output.print_error("""The DNSSEC 'DS' record for %s is incorrect. See further details below.""" % domain)
|
||||
return
|
||||
env['out'].print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system
|
||||
output.print_error("""This domain's DNSSEC DS record is incorrect. The chain of trust is broken between the public DNS system
|
||||
and this machine's DNS server. It may take several hours for public DNS to update after a change. If you did not recently
|
||||
make a change, you must resolve this immediately by following the instructions provided by your domain name registrar and
|
||||
provide to them this information:""")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
|
||||
env['out'].print_line("Key Flags: KSK")
|
||||
env['out'].print_line(
|
||||
output.print_line("")
|
||||
output.print_line("Key Tag: " + ds_keytag + ("" if not ds_looks_valid or ds[0] == ds_keytag else " (Got '%s')" % ds[0]))
|
||||
output.print_line("Key Flags: KSK")
|
||||
output.print_line(
|
||||
("Algorithm: %s / %s" % (ds_alg, alg_name_map[ds_alg]))
|
||||
+ ("" if not ds_looks_valid or ds[1] == ds_alg else " (Got '%s')" % ds[1]))
|
||||
# see http://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
|
||||
env['out'].print_line("Digest Type: 2 / SHA-256")
|
||||
output.print_line("Digest Type: 2 / SHA-256")
|
||||
# http://www.ietf.org/assignments/ds-rr-types/ds-rr-types.xml
|
||||
env['out'].print_line("Digest: " + digests['2'])
|
||||
output.print_line("Digest: " + digests['2'])
|
||||
if ds_looks_valid and ds[3] != digests.get(ds[2]):
|
||||
env['out'].print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
|
||||
env['out'].print_line("Public Key: ")
|
||||
env['out'].print_line(dnsssec_pubkey, monospace=True)
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line("Bulk/Record Format:")
|
||||
env['out'].print_line("" + ds_correct[0])
|
||||
env['out'].print_line("")
|
||||
output.print_line("(Got digest type %s and digest %s which do not match.)" % (ds[2], ds[3]))
|
||||
output.print_line("Public Key: ")
|
||||
output.print_line(dnsssec_pubkey, monospace=True)
|
||||
output.print_line("")
|
||||
output.print_line("Bulk/Record Format:")
|
||||
output.print_line("" + ds_correct[0])
|
||||
output.print_line("")
|
||||
|
||||
def check_mail_domain(domain, env):
|
||||
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:
|
||||
env['out'].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.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
||||
output.print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
||||
|
||||
# And a missing MX record is okay on other domains if the A record
|
||||
# matches the A record of the PRIMARY_HOSTNAME. Actually this will
|
||||
@@ -315,48 +463,55 @@ def check_mail_domain(domain, env):
|
||||
domain_a = query_dns(domain, "A", nxdomain=None)
|
||||
primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None)
|
||||
if domain_a != None and domain_a == primary_a:
|
||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
|
||||
output.print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,))
|
||||
else:
|
||||
env['out'].print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not
|
||||
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:
|
||||
env['out'].print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
|
||||
output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not
|
||||
be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from
|
||||
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)
|
||||
# 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
|
||||
# and will not be able to reliably send mail.
|
||||
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
|
||||
if dbl is None:
|
||||
env['out'].print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
||||
output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.")
|
||||
else:
|
||||
env['out'].print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
||||
output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s),
|
||||
which may prevent recipients from receiving your mail.
|
||||
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
||||
|
||||
def check_web_domain(domain, env):
|
||||
def check_web_domain(domain, rounded_time, env, output):
|
||||
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
||||
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
||||
# other domains, it is required to access its website.
|
||||
if domain != env['PRIMARY_HOSTNAME']:
|
||||
ip = query_dns(domain, "A")
|
||||
if ip == env['PUBLIC_IP']:
|
||||
env['out'].print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP']))
|
||||
output.print_ok("Domain resolves to this box's IP address. [%s => %s]" % (domain, env['PUBLIC_IP']))
|
||||
else:
|
||||
env['out'].print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
||||
output.print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
||||
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
||||
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
|
||||
|
||||
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
||||
# user will log in with IMAP or webmail. Any other domain we serve a
|
||||
# website for also needs a signed certificate.
|
||||
check_ssl_cert(domain, env)
|
||||
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
|
||||
@@ -374,6 +529,8 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
||||
# Host did not have an answer for this query; not sure what the
|
||||
# difference is between the two exceptions.
|
||||
return nxdomain
|
||||
except dns.exception.Timeout:
|
||||
return "[timeout]"
|
||||
|
||||
# There may be multiple answers; concatenate the response. Remove trailing
|
||||
# periods from responses since that's how qnames are encoded in DNS but is
|
||||
@@ -381,26 +538,26 @@ 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):
|
||||
def check_ssl_cert(domain, rounded_time, env, output):
|
||||
# Check that SSL certificate is signed.
|
||||
|
||||
# Skip the check if the A record is not pointed here.
|
||||
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
||||
|
||||
# Where is the SSL stored?
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||
|
||||
if not os.path.exists(ssl_certificate):
|
||||
env['out'].print_error("The SSL certificate file for this domain is missing.")
|
||||
output.print_error("The SSL certificate file for this domain is missing.")
|
||||
return
|
||||
|
||||
# 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.
|
||||
env['out'].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.
|
||||
@@ -415,27 +572,27 @@ def check_ssl_cert(domain, env):
|
||||
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
|
||||
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
env['out'].print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
||||
output.print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
||||
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
||||
static site hosting). Use the SSL Certificates page in this control panel to install a signed SSL certificate.
|
||||
You may choose to leave the self-signed certificate in place and confirm the security exception, but check that
|
||||
the certificate fingerprint matches the following:""")
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line(" " + fingerprint, monospace=True)
|
||||
output.print_line("")
|
||||
output.print_line(" " + fingerprint, monospace=True)
|
||||
else:
|
||||
env['out'].print_warning("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
||||
output.print_warning("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
||||
this domain will get a security warning. If you are not serving a website on this domain, then it is
|
||||
safe to leave the self-signed certificate in place. Use the SSL Certificates page in this control panel to
|
||||
install a signed SSL certificate.""")
|
||||
|
||||
else:
|
||||
env['out'].print_error("The SSL certificate has a problem: " + cert_status)
|
||||
output.print_error("The SSL certificate has a problem: " + cert_status)
|
||||
if cert_status_details:
|
||||
env['out'].print_line("")
|
||||
env['out'].print_line(cert_status_details)
|
||||
env['out'].print_line("")
|
||||
output.print_line("")
|
||||
output.print_line(cert_status_details)
|
||||
output.print_line("")
|
||||
|
||||
def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
def check_certificate(domain, ssl_certificate, ssl_private_key, rounded_time=False):
|
||||
# Use openssl verify to check the status of a certificate.
|
||||
|
||||
# First check that the certificate is for the right domain. The domain
|
||||
@@ -482,6 +639,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
if m:
|
||||
cert_expiration_date = dateutil.parser.parse(m.group(1))
|
||||
|
||||
domain = domain.encode("idna").decode("ascii")
|
||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
||||
if domain is not None and domain not in certificate_names and wildcard_domain not in certificate_names:
|
||||
return ("The certificate is for the wrong domain name. It is for %s."
|
||||
@@ -545,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)
|
||||
|
||||
@@ -586,16 +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
|
||||
|
||||
if not send_via_email:
|
||||
out = ConsoleOutput()
|
||||
else:
|
||||
import io
|
||||
out = FileOutput(io.StringIO(""), 70)
|
||||
|
||||
# Run status checks.
|
||||
cur = BufferedOutput()
|
||||
run_checks(True, env, cur, pool)
|
||||
|
||||
# Load previously saved status checks.
|
||||
cache_fn = "/var/cache/mailinabox/status_checks.json"
|
||||
if os.path.exists(cache_fn):
|
||||
prev = json.load(open(cache_fn))
|
||||
|
||||
# Group the serial output into categories by the headings.
|
||||
def group_by_heading(lines):
|
||||
from collections import OrderedDict
|
||||
ret = OrderedDict()
|
||||
k = []
|
||||
ret["No Category"] = k
|
||||
for line_type, line_args, line_kwargs in lines:
|
||||
if line_type == "add_heading":
|
||||
k = []
|
||||
ret[line_args[0]] = k
|
||||
else:
|
||||
k.append((line_type, line_args, line_kwargs))
|
||||
return ret
|
||||
prev_status = group_by_heading(prev)
|
||||
cur_status = group_by_heading(cur.buf)
|
||||
|
||||
# Compare the previous to the current status checks
|
||||
# category by category.
|
||||
for category, cur_lines in cur_status.items():
|
||||
if category not in prev_status:
|
||||
out.add_heading(category + " -- Added")
|
||||
BufferedOutput(with_lines=cur_lines).playback(out)
|
||||
else:
|
||||
# Actual comparison starts here...
|
||||
prev_lines = prev_status[category]
|
||||
def stringify(lines):
|
||||
return [json.dumps(line) for line in lines]
|
||||
diff = SequenceMatcher(None, stringify(prev_lines), stringify(cur_lines)).get_opcodes()
|
||||
for op, i1, i2, j1, j2 in diff:
|
||||
if op == "replace":
|
||||
out.add_heading(category + " -- Previously:")
|
||||
elif op == "delete":
|
||||
out.add_heading(category + " -- Removed")
|
||||
if op in ("replace", "delete"):
|
||||
BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)
|
||||
|
||||
if op == "replace":
|
||||
out.add_heading(category + " -- Currently:")
|
||||
elif op == "insert":
|
||||
out.add_heading(category + " -- Added")
|
||||
if op in ("replace", "insert"):
|
||||
BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)
|
||||
|
||||
for category, prev_lines in prev_status.items():
|
||||
if category not in cur_status:
|
||||
out.add_heading(category)
|
||||
out.print_warning("This section was removed.")
|
||||
|
||||
if send_via_email:
|
||||
# If there were changes, send off an email.
|
||||
buf = out.buf.getvalue()
|
||||
if len(buf) > 0:
|
||||
# create MIME message
|
||||
from email.message import Message
|
||||
msg = Message()
|
||||
msg['From'] = "\"%s\" <administrator@%s>" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'])
|
||||
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
|
||||
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
|
||||
msg.set_payload(buf, "UTF-8")
|
||||
|
||||
# send to administrator@
|
||||
import smtplib
|
||||
mailserver = smtplib.SMTP('localhost', 25)
|
||||
mailserver.ehlo()
|
||||
mailserver.sendmail(
|
||||
"administrator@%s" % env['PRIMARY_HOSTNAME'], # MAIL FROM
|
||||
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
|
||||
msg.as_string())
|
||||
mailserver.quit()
|
||||
|
||||
# Store the current status checks output for next time.
|
||||
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
|
||||
with open(cache_fn, "w") as f:
|
||||
json.dump(cur.buf, f, indent=True)
|
||||
|
||||
class FileOutput:
|
||||
def __init__(self, buf, width):
|
||||
self.buf = buf
|
||||
self.width = width
|
||||
|
||||
try:
|
||||
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||
except:
|
||||
terminal_columns = 76
|
||||
class ConsoleOutput:
|
||||
def add_heading(self, heading):
|
||||
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="✓ ")
|
||||
@@ -607,36 +866,66 @@ 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) > 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, with_lines=None):
|
||||
self.buf = [] if not with_lines else with_lines
|
||||
def __getattr__(self, attr):
|
||||
if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"):
|
||||
raise AttributeError
|
||||
# Return a function that just records the call & arguments to our buffer.
|
||||
def w(*args, **kwargs):
|
||||
self.buf.append((attr, args, kwargs))
|
||||
return w
|
||||
def playback(self, output):
|
||||
for attr, args, kwargs in self.buf:
|
||||
getattr(output, attr)(*args, **kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||
if not os.path.exists(ssl_certificate):
|
||||
sys.exit(1)
|
||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<label for="addaliasEmail" class="col-sm-1 control-label">Alias</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="email" class="form-control" id="addaliasEmail">
|
||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">You may use international (non-ASCII) characters, but this has not yet been well tested.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -329,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
|
||||
|
||||
@@ -67,18 +67,25 @@ function do_login() {
|
||||
function(response){
|
||||
// This API call always succeeds. It returns a JSON object indicating
|
||||
// whether the request was authenticated or not.
|
||||
if (response.status != "authorized") {
|
||||
if (response.status != "ok") {
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
|
||||
} else if (!("api_key" in response)) {
|
||||
// Login succeeded but user might not be authorized!
|
||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
|
||||
} else {
|
||||
// Login succeeded.
|
||||
|
||||
// Save the new credentials.
|
||||
api_credentials = [response.api_key, ""];
|
||||
api_credentials = [response.email, response.api_key];
|
||||
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
|
||||
<tr><th>Mail server</th> <td>{{hostname}}</td>
|
||||
<tr><th>IMAP Port</th> <td>993</td></tr>
|
||||
<tr><th>IMAP Sercurity</th> <td>SSL</td></tr>
|
||||
<tr><th>IMAP Security</th> <td>SSL</td></tr>
|
||||
<tr><th>SMTP Port</th> <td>587</td></tr>
|
||||
<tr><th>SMTP Security</td> <td>STARTTLS <small>(“always” or “required”, if prompted)</small></td></tr>
|
||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||
@@ -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’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’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>
|
||||
|
||||
@@ -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>
|
||||
@@ -28,7 +28,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 2em"><small>The size column in the table indicates the size of the encrpyted backup, but the total size on disk shown above includes storage for unencrpyted intermediate files.</small></p>
|
||||
<p style="margin-top: 2em"><small>The size column in the table indicates the size of the encrypted backup, but the total size on disk shown above includes storage for unencrypted intermediate files.</small></p>
|
||||
|
||||
<script>
|
||||
function nice_size(bytes) {
|
||||
@@ -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;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<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>
|
||||
|
||||
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p>
|
||||
<p>Add an email address to this system. This will create a new login username/password.</p>
|
||||
|
||||
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
|
||||
<div class="form-group">
|
||||
@@ -31,10 +30,12 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</form>
|
||||
<p style="margin-top: .5em"><small>
|
||||
Passwords must be at least four characters and may not contain spaces.
|
||||
Administrators get access to this control panel.
|
||||
</small></p>
|
||||
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||
<li>Passwords must be at least four characters and may not contain spaces.</li>
|
||||
<li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
||||
<li>Administrators get access to this control panel.</li>
|
||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="javascript:show_panel('aliases')">aliases</a> can.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Existing mail users</h3>
|
||||
<table id="user_table" class="table" style="width: auto">
|
||||
@@ -75,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>
|
||||
@@ -96,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);
|
||||
|
||||
@@ -135,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(", ") : "")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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’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’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’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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# domains for which a mail account has been set up.
|
||||
########################################################################
|
||||
|
||||
import os, os.path, shutil, re, rtyaml
|
||||
import os, os.path, shutil, re, tempfile, rtyaml
|
||||
|
||||
from mailconfig import get_mail_domains
|
||||
from dns_update import get_custom_dns_config, do_dns_update
|
||||
@@ -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,11 +75,11 @@ 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, csr_path = get_domain_ssl_files(domain, env)
|
||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||
|
||||
# For hostnames created after the initial setup, ensure we have an SSL certificate
|
||||
# available. Make a self-signed one now if one doesn't exist.
|
||||
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env)
|
||||
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env)
|
||||
|
||||
# Put pieces together.
|
||||
nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template)
|
||||
@@ -89,7 +89,7 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
|
||||
# Replace substitution strings in the template & return.
|
||||
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
||||
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
||||
nginx_conf = nginx_conf.replace("$HOSTNAME", domain.encode("idna").decode("ascii"))
|
||||
nginx_conf = nginx_conf.replace("$ROOT", root)
|
||||
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
|
||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
|
||||
@@ -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,17 +164,18 @@ 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']
|
||||
|
||||
# Where would the CSR go? As with the SSL cert itself, the CSR must be
|
||||
# different for each domain name.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_cert_sign_req.csr')
|
||||
else:
|
||||
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/certificate_signing_request.csr' % safe_domain_name(domain))
|
||||
# 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, csr_path
|
||||
return ssl_key, ssl_certificate, ssl_via
|
||||
|
||||
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env):
|
||||
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env):
|
||||
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
|
||||
# a certificate doesn't already exist. See setup/mail.sh for documentation.
|
||||
|
||||
@@ -192,17 +194,18 @@ def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, en
|
||||
|
||||
# Generate a new self-signed certificate using the same private key that we already have.
|
||||
|
||||
# Start with a CSR.
|
||||
with open(csr_path, "w") as f:
|
||||
f.write(create_csr(domain, ssl_key, env))
|
||||
# Start with a CSR written to a temporary file.
|
||||
with tempfile.NamedTemporaryFile(mode="w") as csr_fp:
|
||||
csr_fp.write(create_csr(domain, ssl_key, env))
|
||||
csr_fp.flush() # since we won't close until after running 'openssl x509', since close triggers delete.
|
||||
|
||||
# And then make the certificate.
|
||||
shell("check_call", [
|
||||
"openssl", "x509", "-req",
|
||||
"-days", "365",
|
||||
"-in", csr_path,
|
||||
"-signkey", ssl_key,
|
||||
"-out", ssl_certificate])
|
||||
# And then make the certificate.
|
||||
shell("check_call", [
|
||||
"openssl", "x509", "-req",
|
||||
"-days", "365",
|
||||
"-in", csr_fp.name,
|
||||
"-signkey", ssl_key,
|
||||
"-out", ssl_certificate])
|
||||
|
||||
def create_csr(domain, ssl_key, env):
|
||||
return shell("check_output", [
|
||||
@@ -210,7 +213,7 @@ def create_csr(domain, ssl_key, env):
|
||||
"-key", ssl_key,
|
||||
"-out", "/dev/stdout",
|
||||
"-sha256",
|
||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain.encode("idna").decode("ascii"))])
|
||||
|
||||
def install_cert(domain, ssl_cert, ssl_chain, env):
|
||||
if domain not in get_web_domains(env):
|
||||
@@ -225,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, ssl_csr_path = 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":
|
||||
@@ -256,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, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
||||
if not os.path.exists(ssl_certificate):
|
||||
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:
|
||||
@@ -279,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)
|
||||
]
|
||||
|
||||
@@ -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
24
setup/dkim.sh
Normal file → Executable 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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,13 +17,19 @@ if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "U
|
||||
exit
|
||||
fi
|
||||
|
||||
# Check that we have enough memory. Skip the check if we appear to be
|
||||
# running inside of Vagrant, because that's really just for testing.
|
||||
# Check that we have enough memory.
|
||||
#
|
||||
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 KB,
|
||||
# which is 750000 kibibytes.
|
||||
#
|
||||
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
||||
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
||||
if [ $TOTAL_PHYSICAL_MEM -lt 786432 ]; then
|
||||
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
|
||||
if [ ! -d /vagrant ]; then
|
||||
echo "Your Mail-in-a-Box needs more than $TOTAL_PHYSICAL_MEM MB RAM."
|
||||
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
|
||||
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
||||
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
||||
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -45,7 +45,7 @@ fi
|
||||
|
||||
# For nginx and postfix, pre-generate some Diffie-Hellman cipher bits which is
|
||||
# used when a Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
|
||||
# provides Perfect Forward Security. openssl's default is 1024 bits, but we'll
|
||||
# provides Perfect Forward Secrecy. openssl's default is 1024 bits, but we'll
|
||||
# create 2048.
|
||||
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|
||||
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
@@ -17,15 +17,17 @@ hide_output apt-get -y upgrade
|
||||
# when generating random numbers for private keys (e.g. during
|
||||
# ldns-keygen).
|
||||
# * unattended-upgrades: Apt tool to install security updates automatically.
|
||||
# * cron: Runs background processes periodically.
|
||||
# * ntp: keeps the system time correct
|
||||
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
||||
# * git: we install some things directly from github
|
||||
# * sudo: allows privileged users to execute commands as root without being root
|
||||
# * coreutils: includes `nproc` tool to report number of processors
|
||||
# * bc: allows us to do math to compute sane defaults
|
||||
|
||||
apt_install python3 python3-dev python3-pip \
|
||||
wget curl sudo coreutils bc \
|
||||
haveged unattended-upgrades ntp fail2ban
|
||||
wget curl git sudo coreutils bc \
|
||||
haveged unattended-upgrades cron ntp fail2ban
|
||||
|
||||
# Allow apt to install system updates automatically every day.
|
||||
|
||||
@@ -105,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
|
||||
|
||||
10
setup/web.sh
10
setup/web.sh
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user