mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-13 17:17:23 +01:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c843fc92e | ||
|
|
1f0345fe0e | ||
|
|
7e05d7478f | ||
|
|
8fd98d7db3 | ||
|
|
1039a08be6 | ||
|
|
023b38df50 | ||
|
|
3187053b3a | ||
|
|
a3e526e818 | ||
|
|
f6d4621834 | ||
|
|
d075113c1a | ||
|
|
63f2abd923 | ||
|
|
624cc7876a | ||
|
|
d3059c810f | ||
|
|
85a40da83c | ||
|
|
1bf8f1991f | ||
|
|
d155aa8745 | ||
|
|
c0bfd6d15f | ||
|
|
24cc108147 | ||
|
|
b02d7d990e | ||
|
|
87f82addbc | ||
|
|
09713e8eab | ||
|
|
0aa3941832 | ||
|
|
fea77e41df | ||
|
|
74ef9ab7c5 | ||
|
|
6499c82d7f | ||
|
|
80e97feee2 | ||
|
|
fddab5d432 | ||
|
|
c4e4805160 | ||
|
|
c75950125d | ||
|
|
f141af4b61 | ||
|
|
3d8ea0e6ed | ||
|
|
6efeff6fce | ||
|
|
399f9d9bdf | ||
|
|
2b76fd299e | ||
|
|
90592bb157 | ||
|
|
5cf38b950a | ||
|
|
3bc5361491 | ||
|
|
c3a7e3413b | ||
|
|
d390bfb215 | ||
|
|
ceba53f1c4 | ||
|
|
be59bcd47d | ||
|
|
cfe0fa912a | ||
|
|
31d6128a2b | ||
|
|
82cf5b72e4 | ||
|
|
8ec8c42441 | ||
|
|
7e36e1fd90 | ||
|
|
a7710e9058 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,6 +1,51 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
Control panel:
|
||||
|
||||
* Status checks now check that system services are actually running by pinging each port that should have something running on it.
|
||||
* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.
|
||||
|
||||
Setup:
|
||||
|
||||
* Install cron if it isn't already installed.
|
||||
* Fix a units problem in the minimum memory check.
|
||||
|
||||
Miscellaneous:
|
||||
|
||||
* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.
|
||||
* Domains that only have a catch-all alias or domain alias no longer automatically create/require admin@ and postmaster@ addresses since they'll forward anyway.
|
||||
|
||||
|
||||
v0.06 (January 4, 2015)
|
||||
-----------------------
|
||||
|
||||
Mail:
|
||||
|
||||
* Set better default system limits to accommodate boxes handling mail for 20+ users.
|
||||
|
||||
Contacts/calendar:
|
||||
|
||||
* Update to ownCloud to 7.0.4.
|
||||
* Contacts syncing via ActiveSync wasn't working.
|
||||
|
||||
Control panel:
|
||||
|
||||
* New control panel for setting custom DNS settings (without having to use the API).
|
||||
* Status checks showed a false positive for Spamhause blacklists and for secondary DNS in some cases.
|
||||
* Status checks would fail to load if openssh-sever was not pre-installed, but openssh-server is not required.
|
||||
* The local DNS cache is cleared before running the status checks using 'rncd' now rather than restarting 'bind9', which should be faster and wont interrupt other services.
|
||||
* Multi-domain and wildcard certificate can now be installed through the control panel.
|
||||
* The DNS API now allows the setting of SRV records.
|
||||
|
||||
Misc:
|
||||
|
||||
* IPv6 configuration error in postgrey, nginx.
|
||||
* Missing dependency on sudo.
|
||||
|
||||
v0.05 (November 18, 2014)
|
||||
-------------------------
|
||||
|
||||
|
||||
53
conf/dovecot-checkpassword.py
Normal file
53
conf/dovecot-checkpassword.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/python3
|
||||
#
|
||||
# This script implement's Dovecot's checkpassword authentication mechanism:
|
||||
# http://wiki2.dovecot.org/AuthDatabase/CheckPassword?action=show&redirect=PasswordDatabase%2FCheckPassword
|
||||
#
|
||||
# This allows us to perform our own password validation, such as for two-factor authentication,
|
||||
# which Dovecot does not have any native support for.
|
||||
#
|
||||
# We will issue an HTTP request to our management server to perform authentication.
|
||||
|
||||
import sys, os, urllib.request, base64, json, traceback
|
||||
|
||||
try:
|
||||
# Read fd 3 which provides the username and password separated
|
||||
# by NULLs and two other undocumented/empty fields.
|
||||
creds = b''
|
||||
while True:
|
||||
b = os.read(3, 1024)
|
||||
if len(b) == 0: break
|
||||
creds += b
|
||||
email, pw, dummy, dummy = creds.split(b'\x00')
|
||||
|
||||
# Call the management server's "/me" method with the
|
||||
# provided credentials
|
||||
req = urllib.request.Request('http://127.0.0.1:10222/me')
|
||||
req.add_header(b'Authorization', b'Basic ' + base64.b64encode(email + b':' + pw))
|
||||
response = urllib.request.urlopen(req)
|
||||
|
||||
# The response is always success and always a JSON object
|
||||
# indicating the authentication result.
|
||||
resp = response.read().decode('utf8')
|
||||
resp = json.loads(resp)
|
||||
if not isinstance(resp, dict): raise ValueError("Response is not a JSON object.")
|
||||
|
||||
except:
|
||||
# Handle all exceptions. Print what happens (ends up in syslog, thanks
|
||||
# to dovecot) and return an exit status that indicates temporary failure,
|
||||
# which is passed on to the authenticating client.
|
||||
traceback.print_exc()
|
||||
print(json.dumps(dict(os.environ), indent=2), file=sys.stderr)
|
||||
sys.exit(111)
|
||||
|
||||
if resp.get('status') != 'authorized':
|
||||
# Indicates login failure.
|
||||
# (sys.exit should not be inside the try block.)
|
||||
sys.exit(1)
|
||||
|
||||
# Signal ok by executing the indicated process, per the Dovecot
|
||||
# protocol. (Note that the second parameter is the 0th argument
|
||||
# to the called process, which is required and is typically the
|
||||
# file itself.)
|
||||
os.execl(sys.argv[1], sys.argv[1])
|
||||
|
||||
@@ -13,6 +13,7 @@ server {
|
||||
# The secure HTTPS server.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
|
||||
server_name $HOSTNAME;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdire
|
||||
define('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */
|
||||
define('CARDDAV_GAL_MIN_LENGTH', 5);
|
||||
define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
|
||||
define('CARDDAV_SUPPORTS_SYNC', true);
|
||||
define('CARDDAV_SUPPORTS_SYNC', false);
|
||||
|
||||
// If the CardDAV server supports the FN attribute for searches
|
||||
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import base64, os, os.path
|
||||
import base64, os, os.path, hmac, json
|
||||
|
||||
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,83 @@ 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.)
|
||||
# If 2FA is set up, get the first factor and authenticate against
|
||||
# that first.
|
||||
twofa = None
|
||||
if pw_hash.startswith("{TOTP}"):
|
||||
twofa = json.loads(pw_hash[6:])
|
||||
pw_hash = twofa["first_factor"]
|
||||
try:
|
||||
pw, twofa_code = pw.split(" ", 1)
|
||||
except:
|
||||
twofa_code = ""
|
||||
|
||||
# Authenticate.
|
||||
try:
|
||||
# Use 'doveadm pw' to check credentials. doveadm will return
|
||||
# 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.")
|
||||
|
||||
# Check second factor.
|
||||
if twofa:
|
||||
import oath
|
||||
ok, drift = oath.accept_totp(twofa["secret"], twofa_code, drift=twofa["drift"])
|
||||
if not ok:
|
||||
raise ValueError("Invalid 2FA code.")
|
||||
|
||||
|
||||
# Get privileges for authorization.
|
||||
|
||||
# (This call should never fail on a valid user. But if it did fail, it would
|
||||
# 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)
|
||||
|
||||
@@ -36,7 +36,7 @@ def buy_ssl_certificate(api_key, domain, command, env):
|
||||
|
||||
# Where is the SSL cert stored?
|
||||
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
ssl_key, ssl_certificate = get_domain_ssl_files(domain, env)
|
||||
|
||||
# Have we already created a cert for this domain?
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from functools import wraps
|
||||
from flask import Flask, request, render_template, abort, Response
|
||||
|
||||
import auth, utils
|
||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user, get_mail_password
|
||||
from mailconfig import get_mail_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
|
||||
|
||||
@@ -24,19 +24,33 @@ 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.
|
||||
request.user_email = email
|
||||
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 +60,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,17 +95,102 @@ 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),
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
# ME
|
||||
|
||||
@app.route('/me/2fa')
|
||||
@authorized_personnel_only
|
||||
def twofa_status():
|
||||
pw = get_mail_password(request.user_email, env)
|
||||
if pw.startswith("{SHA512-CRYPT}"):
|
||||
method = "password-only"
|
||||
elif pw.startswith("{TOTP}"):
|
||||
method = "TOTP 2FA"
|
||||
else:
|
||||
method = "unknown"
|
||||
|
||||
return json_response({
|
||||
"status": "authorized",
|
||||
"api_key": auth_service.key,
|
||||
"method": method
|
||||
})
|
||||
|
||||
@app.route('/me/2fa/totp/initialize', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def twofa_initialize():
|
||||
# Generate a Google Authenticator URI that encodes TOTP info.
|
||||
import urllib.parse, base64, qrcode, io, binascii
|
||||
|
||||
secret = os.urandom(32)
|
||||
uri = "otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&algorithm=%s" % (
|
||||
urllib.parse.quote(env['PRIMARY_HOSTNAME']),
|
||||
urllib.parse.quote(request.user_email),
|
||||
base64.b32encode(secret).decode("ascii").lower().replace("=", ""),
|
||||
urllib.parse.quote(env['PRIMARY_HOSTNAME']),
|
||||
6,
|
||||
"sha1"
|
||||
)
|
||||
|
||||
image_buffer = io.BytesIO()
|
||||
im = qrcode.make(uri)
|
||||
im.save(image_buffer, 'png')
|
||||
|
||||
return json_response({
|
||||
"uri": uri,
|
||||
"secret": binascii.hexlify(secret).decode('ascii'),
|
||||
"qr": base64.b64encode(image_buffer.getvalue()).decode('ascii')
|
||||
})
|
||||
|
||||
@app.route('/me/2fa/totp/activate', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def twofa_activate():
|
||||
import oath
|
||||
ok, drift = oath.accept_totp(request.form['secret'], request.form['code'])
|
||||
if ok:
|
||||
# use the user's current plain password as the first_factor
|
||||
# of 2FA.
|
||||
existing_pw = get_mail_password(request.user_email, env)
|
||||
if existing_pw.startswith("{TOTP}"):
|
||||
existing_pw = json.loads(existing_pw)["first_factor"]
|
||||
|
||||
pw = "{TOTP}" + json.dumps({
|
||||
"secret": request.form['secret'],
|
||||
"drift": drift,
|
||||
"first_factor": existing_pw,
|
||||
})
|
||||
|
||||
set_mail_password(request.user_email, pw, env, already_hashed=True)
|
||||
|
||||
return json_response({
|
||||
"status": "ok",
|
||||
"message": "TOTP 2FA installed."
|
||||
})
|
||||
|
||||
else:
|
||||
return json_response({
|
||||
"status": "fail",
|
||||
"message": "The activation code was not right. Try again?"
|
||||
})
|
||||
|
||||
|
||||
# MAIL
|
||||
|
||||
@app.route('/mail/users')
|
||||
@@ -172,6 +271,12 @@ def mail_domains():
|
||||
|
||||
# DNS
|
||||
|
||||
@app.route('/dns/zones')
|
||||
@authorized_personnel_only
|
||||
def dns_zones():
|
||||
from dns_update import get_dns_zones
|
||||
return json_response([z[0] for z in get_dns_zones(env)])
|
||||
|
||||
@app.route('/dns/update', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def dns_update():
|
||||
@@ -196,6 +301,17 @@ def dns_set_secondary_nameserver():
|
||||
except ValueError as e:
|
||||
return (str(e), 400)
|
||||
|
||||
@app.route('/dns/set')
|
||||
@authorized_personnel_only
|
||||
def dns_get_records():
|
||||
from dns_update import get_custom_dns_config, get_custom_records
|
||||
additional_records = get_custom_dns_config(env)
|
||||
records = get_custom_records(None, additional_records, env)
|
||||
return json_response([{
|
||||
"qname": r[0],
|
||||
"rtype": r[1],
|
||||
"value": r[2],
|
||||
} for r in records])
|
||||
|
||||
@app.route('/dns/set/<qname>', methods=['POST'])
|
||||
@app.route('/dns/set/<qname>/<rtype>', methods=['POST'])
|
||||
@@ -232,7 +348,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 = get_domain_ssl_files(domain, env)
|
||||
return create_csr(domain, ssl_key, env)
|
||||
|
||||
@app.route('/ssl/install', methods=['POST'])
|
||||
|
||||
@@ -212,6 +212,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
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,7 +222,7 @@ 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):
|
||||
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME"):
|
||||
records.append((qname, rtype, value, explanation))
|
||||
|
||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
||||
@@ -254,14 +255,17 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
||||
def get_custom_records(domain, additional_records, env):
|
||||
for qname, value in additional_records.items():
|
||||
# Is this record for the domain or one of its subdomains?
|
||||
if qname != domain and not qname.endswith("." + domain): continue
|
||||
# If `domain` is None, return records for all domains.
|
||||
if domain is not None and qname != domain and not qname.endswith("." + domain): continue
|
||||
|
||||
# Turn the fully qualified domain name in the YAML file into
|
||||
# our short form (None => domain, or a relative QNAME).
|
||||
if qname == domain:
|
||||
qname = None
|
||||
else:
|
||||
qname = qname[0:len(qname)-len("." + domain)]
|
||||
# our short form (None => domain, or a relative QNAME) if
|
||||
# domain is not None.
|
||||
if domain is not None:
|
||||
if qname == domain:
|
||||
qname = None
|
||||
else:
|
||||
qname = qname[0:len(qname)-len("." + domain)]
|
||||
|
||||
# Short form. Mapping a domain name to a string is short-hand
|
||||
# for creating A records.
|
||||
@@ -378,17 +382,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
|
||||
@@ -482,7 +495,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.
|
||||
@@ -490,7 +503,7 @@ zone:
|
||||
# Get the IP address of the nameserver by resolving it.
|
||||
hostname = additional_records.get("_secondary_nameserver")
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
response = dns.resolver.query(hostname, "A")
|
||||
response = dns.resolver.query(hostname+'.', "A")
|
||||
ipaddr = str(response[0])
|
||||
nsdconf += """\tnotify: %s NOKEY
|
||||
provide-xfr: %s NOKEY
|
||||
@@ -511,8 +524,12 @@ zone:
|
||||
########################################################################
|
||||
|
||||
def dnssec_choose_algo(domain, env):
|
||||
if domain.endswith(".email") or domain.endswith(".guide"):
|
||||
# At least at GoDaddy, this is the only algorithm supported.
|
||||
if '.' in domain and domain.rsplit('.')[-1] in \
|
||||
("email", "guide", "fund"):
|
||||
# At GoDaddy, RSASHA256 is the only algorithm supported
|
||||
# for .email and .guide.
|
||||
# A variety of algorithms are supported for .fund. This
|
||||
# is preferred.
|
||||
return "RSASHA256"
|
||||
|
||||
# For any domain we were able to sign before, don't change the algorithm
|
||||
@@ -523,6 +540,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
|
||||
@@ -662,7 +682,7 @@ def set_custom_dns_record(qname, rtype, value, env):
|
||||
v = ipaddress.ip_address(value)
|
||||
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
||||
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
||||
elif rtype in ("CNAME", "TXT"):
|
||||
elif rtype in ("CNAME", "TXT", "SRV"):
|
||||
# anything goes
|
||||
pass
|
||||
else:
|
||||
|
||||
@@ -18,8 +18,9 @@ def scan_mail_log(logger, env):
|
||||
|
||||
for fn in ('/var/log/mail.log.1', '/var/log/mail.log'):
|
||||
if not os.path.exists(fn): continue
|
||||
with open(fn) as log:
|
||||
with open(fn, 'rb') as log:
|
||||
for line in log:
|
||||
line = line.decode("utf8", errors='replace')
|
||||
scan_mail_log_line(line.strip(), collector)
|
||||
|
||||
if collector["imap-logins"]:
|
||||
@@ -96,6 +97,21 @@ def scan_postfix_smtpd_line(date, log, collector):
|
||||
message, sender, recipient = m.groups()
|
||||
if recipient in collector["real_mail_addresses"]:
|
||||
# only log mail to real recipients
|
||||
|
||||
# skip this, is reported in the greylisting report
|
||||
if "Recipient address rejected: Greylisted" in message:
|
||||
return
|
||||
|
||||
# simplify this one
|
||||
m = re.search(r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message)
|
||||
if m:
|
||||
message = "ip blocked: " + m.group(2)
|
||||
|
||||
# simplify this one too
|
||||
m = re.search(r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message)
|
||||
if m:
|
||||
message = "domain blocked: " + m.group(2)
|
||||
|
||||
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -230,6 +255,9 @@ 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)
|
||||
@@ -251,7 +279,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:
|
||||
@@ -283,11 +311,16 @@ def add_mail_user(email, pw, privs, env):
|
||||
# Update things in case any new domains are added.
|
||||
return kick(env, "mail user added")
|
||||
|
||||
def set_mail_password(email, pw, env):
|
||||
validate_password(pw)
|
||||
|
||||
# hash the password
|
||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||
def set_mail_password(email, pw, env, already_hashed=False):
|
||||
# accept IDNA domain names but normalize to Unicode before going into database
|
||||
email = sanitize_idn_email_address(email)
|
||||
|
||||
# validate that password is acceptable
|
||||
if not already_hashed:
|
||||
# Validate and hash the password. Skip if we're providing
|
||||
# a raw hashed password value.
|
||||
validate_password(pw)
|
||||
pw = hash_password(pw)
|
||||
|
||||
# update the database
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
@@ -297,7 +330,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 +366,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 +383,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 +413,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 +428,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 +463,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 +488,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 os, os.path, re, subprocess, datetime, multiprocessing.pool
|
||||
|
||||
import dns.reversename, dns.resolver
|
||||
import dateutil.parser, dateutil.tz
|
||||
@@ -18,70 +18,164 @@ from mailconfig import get_mail_domains, get_mail_aliases
|
||||
from utils import shell, sort_domains, load_env_vars_from_file
|
||||
|
||||
def run_checks(env, output):
|
||||
# clear the DNS cache so our DNS checks are most up to date
|
||||
shell('check_call', ["/usr/sbin/service", "bind9", "restart"])
|
||||
# run systems checks
|
||||
output.add_heading("System")
|
||||
|
||||
# check that services are running
|
||||
if not run_services_checks(env, output):
|
||||
# If critical services are not running, stop. If bind9 isn't running,
|
||||
# all later DNS checks will timeout and that will take forever to
|
||||
# go through, and if running over the web will cause a fastcgi timeout.
|
||||
return
|
||||
|
||||
# clear bind9's DNS cache so our DNS checks are up to date
|
||||
# (ignore errors; if bind9/rndc isn't running we'd already report
|
||||
# that in run_services checks.)
|
||||
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
||||
|
||||
# perform checks
|
||||
env["out"] = output
|
||||
run_system_checks(env)
|
||||
run_network_checks(env)
|
||||
run_domain_checks(env)
|
||||
run_system_checks(env, output)
|
||||
|
||||
def run_system_checks(env):
|
||||
env["out"].add_heading("System")
|
||||
# perform other checks asynchronously
|
||||
|
||||
# Check that SSH login with password is disabled.
|
||||
pool = multiprocessing.pool.Pool(processes=1)
|
||||
r1 = pool.apply_async(run_network_checks, [env])
|
||||
r2 = run_domain_checks(env)
|
||||
r1.get().playback(output)
|
||||
r2.playback(output)
|
||||
|
||||
def run_services_checks(env, output):
|
||||
# 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": "Memcached", "port": 11211, "public": False, },
|
||||
{ "name": "Sieve (dovecot)", "port": 4190, "public": True, },
|
||||
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
|
||||
|
||||
{ "name": "SSH Login (ssh)", "port": 22, "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
|
||||
pool = multiprocessing.pool.Pool(processes=10)
|
||||
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1)
|
||||
for i, running, fatal2, output2 in sorted(ret):
|
||||
all_running = all_running and running
|
||||
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:
|
||||
s.connect((
|
||||
"127.0.0.1" if not service["public"] else env['PUBLIC_IP'],
|
||||
service["port"]))
|
||||
running = True
|
||||
|
||||
except OSError as e:
|
||||
output.print_error("%s is not running (%s)." % (service['name'], str(e)))
|
||||
|
||||
# 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(env, output):
|
||||
check_ssh_password(env, output)
|
||||
check_software_updates(env, output)
|
||||
check_system_aliases(env, output)
|
||||
check_free_disk_space(env, output)
|
||||
|
||||
def check_ssh_password(env, output):
|
||||
# Check that SSH login with password is disabled. The openssh-server
|
||||
# package may not be installed so check that before trying to access
|
||||
# the configuration file.
|
||||
if not os.path.exists("/etc/ssh/sshd_config"):
|
||||
return
|
||||
sshd = open("/etc/ssh/sshd_config").read()
|
||||
if re.search("\nPasswordAuthentication\s+yes", sshd) \
|
||||
or not re.search("\nPasswordAuthentication\s+no", sshd):
|
||||
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, 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, 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, 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)
|
||||
output.print_ok(disk_msg)
|
||||
elif bytes_free > .15 * bytes_total:
|
||||
env['out'].print_warning(disk_msg)
|
||||
output.print_warning(disk_msg)
|
||||
else:
|
||||
env['out'].print_error(disk_msg)
|
||||
|
||||
output.print_error(disk_msg)
|
||||
|
||||
def run_network_checks(env):
|
||||
# Also see setup/network-checks.sh.
|
||||
|
||||
env["out"].add_heading("Network")
|
||||
output = BufferedOutput()
|
||||
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.""")
|
||||
@@ -93,12 +187,14 @@ 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']))
|
||||
|
||||
return output
|
||||
|
||||
def run_domain_checks(env):
|
||||
# Get the list of domains we handle mail for.
|
||||
mail_domains = get_mail_domains(env)
|
||||
@@ -110,31 +206,51 @@ 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, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||
|
||||
# Parallelize the checks across a worker pool.
|
||||
args = ((domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||
for domain in domains_to_check)
|
||||
pool = multiprocessing.pool.Pool(processes=10)
|
||||
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
||||
ret = dict(ret) # (domain, output) => { domain: output }
|
||||
output = BufferedOutput()
|
||||
for domain in sort_domains(ret, env):
|
||||
ret[domain].playback(output)
|
||||
return output
|
||||
|
||||
def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
|
||||
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, 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.
|
||||
for zone in dns_domains:
|
||||
if zone == domain or domain.endswith("." + zone):
|
||||
if query_dns(zone, "DS", nxdomain=None) is not None:
|
||||
check_dnssec(zone, env, dns_zonefiles, is_checking_primary=True)
|
||||
check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True)
|
||||
|
||||
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
||||
# comes from the TLD since the information is set at the registrar as glue records.
|
||||
@@ -143,9 +259,9 @@ def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
|
||||
# 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']))
|
||||
output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s => %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
||||
else:
|
||||
env['out'].print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name
|
||||
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))
|
||||
@@ -153,9 +269,9 @@ def check_primary_hostname_dns(domain, env, dns_domains, dns_zonefiles):
|
||||
# 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))
|
||||
@@ -165,9 +281,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.
|
||||
@@ -175,29 +291,29 @@ 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.""")
|
||||
output.print_error("""The DANE TLSA record for incoming mail is not set. This is optional.""")
|
||||
else:
|
||||
env['out'].print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'.
|
||||
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
|
||||
@@ -207,25 +323,25 @@ def check_dns_zone(domain, env, dns_zonefiles):
|
||||
# to do a DNS trace.
|
||||
custom_dns = get_custom_dns_config(env)
|
||||
existing_ns = query_dns(domain, "NS")
|
||||
correct_ns = "; ".join([
|
||||
correct_ns = "; ".join(sorted([
|
||||
"ns1." + env['PRIMARY_HOSTNAME'],
|
||||
custom_dns.get("_secondary_nameserver", "ns2." + env['PRIMARY_HOSTNAME']),
|
||||
])
|
||||
]))
|
||||
if existing_ns.lower() == correct_ns.lower():
|
||||
env['out'].print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||
else:
|
||||
env['out'].print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name 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.
|
||||
@@ -247,54 +363,54 @@ 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_error("""This domain's DNSSEC DS record is not set. The DS record is optional. The DS record activates DNSSEC.
|
||||
To set a DS record, you must follow the instructions provided by your domain name registrar and provide to them this information:""")
|
||||
else:
|
||||
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.
|
||||
|
||||
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))
|
||||
output.print_ok("Domain's email is directed to this domain. [%s => %s]" % (domain, mx))
|
||||
|
||||
elif mx == None:
|
||||
# A missing MX record is okay on the primary hostname because
|
||||
# the primary hostname's A record (the MX fallback) is... itself,
|
||||
# which is what we want the MX to be.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
env['out'].print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,))
|
||||
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
|
||||
@@ -303,57 +419,67 @@ 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,))
|
||||
|
||||
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))
|
||||
|
||||
# Check that the postmaster@ email address exists.
|
||||
check_alias_exists("postmaster@" + domain, 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, 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, env, output)
|
||||
|
||||
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
# Make the qname absolute by appending a period. Without this, dns.resolver.query
|
||||
# will fall back a failed lookup to a second query with this machine's hostname
|
||||
# appended. This has been causing some false-positive Spamhaus reports. The
|
||||
# reverse DNS lookup will pass a dns.name.Name instance which is already
|
||||
# absolute so we should not modify that.
|
||||
if isinstance(qname, str):
|
||||
qname += "."
|
||||
|
||||
# Do the query.
|
||||
try:
|
||||
response = dns.resolver.query(qname, rtype)
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# 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
|
||||
@@ -361,17 +487,17 @@ 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, 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 = 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.
|
||||
@@ -380,7 +506,7 @@ def check_ssl_cert(domain, env):
|
||||
|
||||
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. " + cert_status_details)
|
||||
|
||||
elif cert_status == "SELF-SIGNED":
|
||||
# Offer instructions for purchasing a signed certificate.
|
||||
@@ -395,25 +521,25 @@ 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):
|
||||
# Use openssl verify to check the status of a certificate.
|
||||
@@ -462,6 +588,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."
|
||||
@@ -514,7 +641,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
|
||||
return ("SELF-SIGNED", None)
|
||||
elif retcode != 0:
|
||||
if "unable to get local issuer certificate" in verifyoutput:
|
||||
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete.", None)
|
||||
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
|
||||
|
||||
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||
return ("There is a problem with the SSL certificate.", verifyoutput.strip())
|
||||
@@ -567,11 +694,12 @@ def list_apt_updates(apt_update=True):
|
||||
return pkgs
|
||||
|
||||
|
||||
try:
|
||||
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||
except:
|
||||
terminal_columns = 76
|
||||
class ConsoleOutput:
|
||||
try:
|
||||
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||
except:
|
||||
terminal_columns = 76
|
||||
|
||||
def add_heading(self, heading):
|
||||
print()
|
||||
print(heading)
|
||||
@@ -592,7 +720,7 @@ class ConsoleOutput:
|
||||
words = re.split("(\s+)", message)
|
||||
linelen = 0
|
||||
for w in words:
|
||||
if linelen + len(w) > terminal_columns-1-len(first_line):
|
||||
if linelen + len(w) > self.terminal_columns-1-len(first_line):
|
||||
print()
|
||||
print(" ", end="")
|
||||
linelen = 0
|
||||
@@ -605,6 +733,21 @@ class ConsoleOutput:
|
||||
for line in message.split("\n"):
|
||||
self.print_block(line)
|
||||
|
||||
class BufferedOutput:
|
||||
# Record all of the instance method calls so we can play them back later.
|
||||
def __init__(self):
|
||||
self.buf = []
|
||||
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
|
||||
@@ -616,7 +759,7 @@ if __name__ == "__main__":
|
||||
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 = 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)
|
||||
|
||||
79
management/templates/2fa.html
Normal file
79
management/templates/2fa.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
|
||||
<p>Two-factor authentication (2FA) is <i>something you know</i> and <i>something you have</i>.</p>
|
||||
|
||||
<p>Regular password-based logins are one-factor (something you know). 2FA makes an account more secure by guarding against a lost or guessed password, since you also need a special device to access your account. You can turn on 2FA for your account here.</p>
|
||||
|
||||
<p>Your authentication method is currently: <strong id="2fa_current"> </strong></p>
|
||||
|
||||
<h3>TOTP</h3>
|
||||
|
||||
<p>TOTP is a time-based one-time password method of two-factor authentication.</p>
|
||||
|
||||
<p>You will need a TOTP-compatible device, such as any Android device with the <a href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a> app. We’ll generate a QR code that you import into your device or app. After you generate the QR code, you’ll activate 2FA by entering your first activation code provided by your device or app.</p>
|
||||
|
||||
<p><button onclick="totp_initialize()">Generate QR Code</button></p>
|
||||
<div id="totp-form" class="row" style="display: none">
|
||||
<div class="col-sm-6">
|
||||
<center>QR Code</center>
|
||||
<img id="totp_qr_code" src="" class="img-responsive">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<form class="form" role="form" onsubmit="totp_activate(); return false;">
|
||||
<div class="form-group">
|
||||
<label for="inputTOTP" class="control-label">Activation Code</label>
|
||||
<p><input class="form-control" id="inputTOTP" placeholder="enter 6-digit code"></p>
|
||||
<p><input type="submit" class="btn btn-primary" value="Activate"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>When using TOTP 2FA, your password becomes your previous plain password plus a space plus the code generated by your TOTP device.</p>
|
||||
|
||||
<script>
|
||||
function show_2fa() {
|
||||
$('#2fa_current').text('loading...');
|
||||
api(
|
||||
"/me/2fa",
|
||||
"GET",
|
||||
{
|
||||
},
|
||||
function(response) {
|
||||
$('#2fa_current').text(response.method);
|
||||
});
|
||||
}
|
||||
|
||||
var secret = null;
|
||||
|
||||
function totp_initialize() {
|
||||
api(
|
||||
"/me/2fa/totp/initialize",
|
||||
"POST",
|
||||
{
|
||||
},
|
||||
function(response) {
|
||||
$('#totp_qr_code').attr('src', 'data:image/png;base64,' + response.qr);
|
||||
$('#totp-form').fadeIn();
|
||||
secret = response.secret;
|
||||
});
|
||||
}
|
||||
|
||||
function totp_activate() {
|
||||
api(
|
||||
"/me/2fa/totp/activate",
|
||||
"POST",
|
||||
{
|
||||
"secret": secret,
|
||||
"code": $('#inputTOTP').val()
|
||||
},
|
||||
function(response) {
|
||||
show_modal_error("Two-Factor Authentication", $("<pre/>").text(response.message));
|
||||
if (response.status == "OK")
|
||||
$('#totp-form').fadeOut();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<style>
|
||||
#custom-dns-current td.long {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Custom DNS</h2>
|
||||
@@ -7,6 +10,60 @@
|
||||
|
||||
<p>It is possible to set custom DNS records on domains hosted here.</p>
|
||||
|
||||
<h3>Set Custom DNS Records</h3>
|
||||
|
||||
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_set_custom_dns(); return false;">
|
||||
<div class="form-group">
|
||||
<label for="customdnsQname" class="col-sm-1 control-label">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<table style="max-width: 400px">
|
||||
<tr><td>
|
||||
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
|
||||
</td><td style="padding: 0 1em; font-weight: bold;">.</td><td>
|
||||
<select id="customdnsZone" class="form-control"> </select>
|
||||
</td></tr></table>
|
||||
<div class="text-info" style="margin-top: .5em">Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
|
||||
<div class="col-sm-10">
|
||||
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
|
||||
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012).">A (IPv4 address)</option>
|
||||
<option value="AAAA" data-hint="Enter an IPv6 address.">AAAA (IPv6 address)</option>
|
||||
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
|
||||
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="customdnsValue" class="col-sm-1 control-label">Value</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="customdnsValue" placeholder="">
|
||||
<div id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-1 col-sm-11">
|
||||
<button type="submit" class="btn btn-primary">Set Record</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table id="custom-dns-current" class="table" style="width: auto; display: none">
|
||||
<thead>
|
||||
<th>Domain Name</th>
|
||||
<th>Record Type</th>
|
||||
<th>Value</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Using a Secondary Nameserver</h3>
|
||||
|
||||
<p>If your TLD requires you to have two separate nameservers, you can either set up a secondary (aka “slave”) nameserver or, alternatively, set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box. If you choose to use a seconday/slave nameserver, you must find a seconday/slave nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the seconday/slave nameserver service, enter the hostname of <em>their</em> secondary nameserver:</p>
|
||||
@@ -30,6 +87,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<h3>Custom DNS API</h3>
|
||||
|
||||
<p>Use your box’s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>
|
||||
@@ -66,11 +124,17 @@ curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/bar.
|
||||
|
||||
# sets a TXT record using the alternate value syntax
|
||||
curl -d "value=something%20here" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/txt
|
||||
|
||||
# sets a <a href="http://en.wikipedia.org/wiki/SRV_record">SRV record</a> for the "service" and "protocol" hosted on "target" server
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/_service._protocol.{{hostname}}/srv/"priority weight port target"
|
||||
|
||||
# sets a SRV record using the value syntax
|
||||
curl -d "value=priority weight port target" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/_service._protocol.host/srv
|
||||
</pre>
|
||||
|
||||
<script>
|
||||
function show_custom_dns() {
|
||||
api(
|
||||
api(
|
||||
"/dns/secondary-nameserver",
|
||||
"GET",
|
||||
{ },
|
||||
@@ -78,6 +142,52 @@ function show_custom_dns() {
|
||||
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
|
||||
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
|
||||
});
|
||||
|
||||
api(
|
||||
"/dns/zones",
|
||||
"GET",
|
||||
{ },
|
||||
function(data) {
|
||||
$('#customdnsZone').text('');
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
$('#customdnsZone').append($('<option/>').text(data[i]));
|
||||
}
|
||||
});
|
||||
|
||||
show_current_custom_dns();
|
||||
show_customdns_rtype_hint();
|
||||
}
|
||||
|
||||
function show_current_custom_dns() {
|
||||
api(
|
||||
"/dns/set",
|
||||
"GET",
|
||||
{ },
|
||||
function(data) {
|
||||
if (data.length > 0)
|
||||
$('#custom-dns-current').fadeIn();
|
||||
else
|
||||
$('#custom-dns-current').fadeOut();
|
||||
|
||||
$('#custom-dns-current').find("tbody").text('');
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var tr = $("<tr/>");
|
||||
$('#custom-dns-current').find("tbody").append(tr);
|
||||
tr.attr('data-qname', data[i].qname);
|
||||
tr.attr('data-rtype', data[i].rtype);
|
||||
tr.append($('<td class="long"/>').text(data[i].qname));
|
||||
tr.append($('<td/>').text(data[i].rtype));
|
||||
tr.append($('<td class="long"/>').text(data[i].value));
|
||||
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function delete_custom_dns_record(elem) {
|
||||
var qname = $(elem).parents('tr').attr('data-qname');
|
||||
var rtype = $(elem).parents('tr').attr('data-rtype');
|
||||
do_set_custom_dns(qname, rtype, "__delete__");
|
||||
return false;
|
||||
}
|
||||
|
||||
function do_set_secondary_dns() {
|
||||
@@ -96,4 +206,34 @@ function do_set_secondary_dns() {
|
||||
show_modal_error("Secondary DNS", $("<pre/>").text(err));
|
||||
});
|
||||
}
|
||||
|
||||
function do_set_custom_dns(qname, rtype, value) {
|
||||
if (!qname) {
|
||||
if ($('#customdnsQname').val() != '')
|
||||
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
|
||||
else
|
||||
qname = $('#customdnsZone').val();
|
||||
rtype = $('#customdnsType').val();
|
||||
value = $('#customdnsValue').val();
|
||||
}
|
||||
|
||||
api(
|
||||
"/dns/set/" + qname + "/" + rtype,
|
||||
"POST",
|
||||
{
|
||||
value: value
|
||||
},
|
||||
function(data) {
|
||||
if (data == "") return; // nothing updated
|
||||
show_modal_error("Custom DNS", $("<pre/>").text(data));
|
||||
show_current_custom_dns();
|
||||
},
|
||||
function(err) {
|
||||
show_modal_error("Custom DNS", $("<pre/>").text(err));
|
||||
});
|
||||
}
|
||||
|
||||
function show_customdns_rtype_hint() {
|
||||
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<li><a href="#ssl" onclick="return show_panel(this);">SSL Certificates</a></li>
|
||||
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Super Advanced Options</li>
|
||||
<li class="dropdown-header">Advanced Options</li>
|
||||
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
||||
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
||||
</ul>
|
||||
@@ -115,6 +115,12 @@
|
||||
</li>
|
||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
||||
<li><a href="#web" onclick="return show_panel(this);">Web</a></li>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">You <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#2fa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
||||
@@ -168,6 +174,10 @@
|
||||
{% include "ssl.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_2fa" class="admin_panel">
|
||||
{% include "2fa.html" %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<footer>
|
||||
@@ -264,12 +274,13 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
|
||||
$('#global_modal').modal({});
|
||||
}
|
||||
|
||||
var is_ajax_loading = false;
|
||||
var ajax_num_executing_requests = 0;
|
||||
function ajax(options) {
|
||||
setTimeout("if (is_ajax_loading) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||
function hide_loading_indicator() {
|
||||
is_ajax_loading = false;
|
||||
$('#ajax_loading_indicator').hide();
|
||||
ajax_num_executing_requests--;
|
||||
if (ajax_num_executing_requests == 0)
|
||||
$('#ajax_loading_indicator').stop().hide(); // stop() prevents an ongoing fade from causing the thing to be shown again after this call
|
||||
}
|
||||
var old_success = options.success;
|
||||
var old_error = options.error;
|
||||
@@ -287,7 +298,7 @@ function ajax(options) {
|
||||
else
|
||||
old_error(jqxhr.responseText, jqxhr);
|
||||
};
|
||||
is_ajax_loading = true;
|
||||
ajax_num_executing_requests++;
|
||||
$.ajax(options);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>Advanced:<br>Install a multi-domain or wildcard certificate for the <code>{{hostname}}</code> domain to have it automatically applied to any domains it is valid for.</p>
|
||||
|
||||
<h3 id="ssl_install_header">Install SSL Certificate</h3>
|
||||
|
||||
<p>There are many places where you can get a free or cheap SSL certificate. We recommend <a href="https://www.namecheap.com/security/ssl-certificates/domain-validation.aspx">Namecheap’s $9 certificate</a> or <a href="https://www.startssl.com/">StartSSL’s free express lane</a>.</p>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<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 +31,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">
|
||||
|
||||
@@ -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
|
||||
@@ -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 = 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)
|
||||
@@ -115,6 +115,8 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
yaml = yaml[domain]
|
||||
for path, url in yaml.get("proxies", {}).items():
|
||||
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
||||
for path, url in yaml.get("redirects", {}).items():
|
||||
nginx_conf += "\trewrite %s %s permanent;\n" % (path, url)
|
||||
|
||||
# Add in any user customizations in the includes/ folder.
|
||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||
@@ -133,7 +135,7 @@ def get_web_root(domain, env, test_exists=True):
|
||||
if os.path.exists(root) or not test_exists: break
|
||||
return root
|
||||
|
||||
def get_domain_ssl_files(domain, env):
|
||||
def get_domain_ssl_files(domain, env, allow_shared_cert=True):
|
||||
# What SSL private key will we use? Allow the user to override this, but
|
||||
# in many cases using the same private key for all domains would be fine.
|
||||
# Don't allow the user to override the key for PRIMARY_HOSTNAME because
|
||||
@@ -157,21 +159,14 @@ def get_domain_ssl_files(domain, env):
|
||||
# But we can be smart and reuse the main SSL certificate if is has
|
||||
# a Subject Alternative Name matching this domain. Don't do this if
|
||||
# the user has uploaded a different private key for this domain.
|
||||
if not ssl_key_is_alt:
|
||||
if not ssl_key_is_alt and allow_shared_cert:
|
||||
from status_checks import check_certificate
|
||||
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
|
||||
ssl_certificate = ssl_certificate_primary
|
||||
|
||||
# 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))
|
||||
return ssl_key, ssl_certificate
|
||||
|
||||
return ssl_key, ssl_certificate, csr_path
|
||||
|
||||
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.
|
||||
|
||||
@@ -190,17 +185,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", [
|
||||
@@ -208,7 +204,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):
|
||||
@@ -223,7 +219,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)
|
||||
ssl_key, ssl_certificate = 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,12 +252,16 @@ def install_cert(domain, ssl_cert, ssl_chain, env):
|
||||
def get_web_domains_info(env):
|
||||
def check_cert(domain):
|
||||
from status_checks import check_certificate
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
ssl_key, ssl_certificate = 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":
|
||||
return ("success", "Signed & valid. " + cert_status_details)
|
||||
if domain == env['PRIMARY_HOSTNAME'] or ssl_certificate != get_domain_ssl_files(env['PRIMARY_HOSTNAME'], env)[1]:
|
||||
return ("success", "Signed & valid. " + cert_status_details)
|
||||
else:
|
||||
# This is an alternate domain but using the same cert as the primary domain.
|
||||
return ("success", "Signed & valid. Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME'])
|
||||
elif cert_status == "SELF-SIGNED":
|
||||
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
||||
else:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#########################################################
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=v0.05
|
||||
TAG=v0.06
|
||||
fi
|
||||
|
||||
# Are we running as root?
|
||||
|
||||
@@ -52,6 +52,10 @@ mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||
#
|
||||
# * .email
|
||||
# * .guide
|
||||
#
|
||||
# Supports `RSASHA256` (and defaulting to this)
|
||||
#
|
||||
# * .fund
|
||||
|
||||
FIRST=1 #NODOC
|
||||
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
|
||||
|
||||
@@ -26,6 +26,23 @@ apt_install \
|
||||
|
||||
# The `dovecot-imapd` and `dovecot-lmtpd` packages automatically enable IMAP and LMTP protocols.
|
||||
|
||||
# Set basic daemon options.
|
||||
|
||||
# The `default_process_limit` is 100, which constrains the total number
|
||||
# of active IMAP connections (at, say, 5 open connections per user that
|
||||
# would be 20 users). Set it to 250 times the number of cores this
|
||||
# machine has, so on a two-core machine that's 500 processes/100 users).
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
|
||||
default_process_limit=$(echo "`nproc` * 250" | bc)
|
||||
|
||||
# The inotify `max_user_instances` default is 128, which constrains
|
||||
# the total number of watched (IMAP IDLE push) folders by open connections.
|
||||
# See http://www.dovecot.org/pipermail/dovecot/2013-March/088834.html.
|
||||
# A reboot is required for this to take effect (which we don't do as
|
||||
# as a part of setup). Test with `cat /proc/sys/fs/inotify/max_user_instances`.
|
||||
tools/editconf.py /etc/sysctl.conf \
|
||||
fs.inotify.max_user_instances=1024
|
||||
|
||||
# Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the
|
||||
# username part of the user's email address. We'll ensure that no bad domains or email addresses
|
||||
# are created within the management daemon.
|
||||
|
||||
@@ -160,6 +160,11 @@ tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \
|
||||
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
|
||||
|
||||
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
|
||||
# Postgrey listens on the same interface (and not IPv6, for instance).
|
||||
tools/editconf.py /etc/default/postgrey \
|
||||
POSTGREY_OPTS=\"--inet=127.0.0.1:10023\"
|
||||
|
||||
# Increase the message size limit from 10MB to 128MB.
|
||||
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
|
||||
@@ -26,31 +26,55 @@ fi
|
||||
|
||||
# ### User Authentication
|
||||
|
||||
# Have Dovecot query our database, and not system users, for authentication.
|
||||
sed -i "s/#*\(\!include auth-system.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf
|
||||
sed -i "s/#\(\!include auth-sql.conf.ext\)/\1/" /etc/dovecot/conf.d/10-auth.conf
|
||||
# Disable all of the built-in authentication mechanisms. (We formerly uncommented
|
||||
# a line to include auth-sql.conf.ext but we no longer use that.)
|
||||
sed -i "s/#*\(\!include auth-.*.conf.ext\)/#\1/" /etc/dovecot/conf.d/10-auth.conf
|
||||
|
||||
# Specify how the database is to be queried for user authentication (passdb)
|
||||
# and where user mailboxes are stored (userdb).
|
||||
cat > /etc/dovecot/conf.d/auth-sql.conf.ext << EOF;
|
||||
# Legacy: Delete our old sql conf files.
|
||||
rm -f /etc/dovecot/conf.d/auth-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
|
||||
|
||||
# Specify how Dovecot should perform user authentication (passdb) and how it knows
|
||||
# where user mailboxes are stored (userdb).
|
||||
#
|
||||
# For passwords, we would normally have Dovecot query our mail user database
|
||||
# directly. The way to do that is commented out below. Instead, in order to
|
||||
# provide our own authentication framework so we can handle two-factor auth,
|
||||
# we will use a custom system that hooks into the Mail-in-a-Box management daemon.
|
||||
#
|
||||
# The user part of this is standard. The mailbox path and Unix system user are the
|
||||
# same for all mail users, modulo string substitution for the mailbox path that
|
||||
# Dovecot handles.
|
||||
cat > /etc/dovecot/conf.d/10-auth-mailinabox.conf << EOF;
|
||||
passdb {
|
||||
driver = sql
|
||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
driver = checkpassword
|
||||
args = /usr/local/bin/dovecot-checkpassword
|
||||
}
|
||||
userdb {
|
||||
driver = static
|
||||
args = uid=mail gid=mail home=$STORAGE_ROOT/mail/mailboxes/%d/%n
|
||||
}
|
||||
EOF
|
||||
chmod 0600 /etc/dovecot/conf.d/10-auth-mailinabox.conf
|
||||
|
||||
# Configure the SQL to query for a user's password.
|
||||
cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
|
||||
driver = sqlite
|
||||
connect = $db_path
|
||||
default_pass_scheme = SHA512-CRYPT
|
||||
password_query = SELECT email as user, password FROM users WHERE email='%u';
|
||||
EOF
|
||||
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
||||
# Copy dovecot-checkpassword into place.
|
||||
cp conf/dovecot-checkpassword.py /usr/local/bin/dovecot-checkpassword
|
||||
chown dovecot.dovecot /usr/local/bin/dovecot-checkpassword
|
||||
chmod 700 /usr/local/bin/dovecot-checkpassword
|
||||
|
||||
# If we were having Dovecot query our database directly, which we did
|
||||
# originally, `/etc/dovecot/conf.d/10-auth-mailinabox.conf` would say:
|
||||
#
|
||||
# passdb {
|
||||
# driver = sql
|
||||
# args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
# }
|
||||
#
|
||||
# and then `/etc/dovecot/dovecot-sql.conf.ext` (chmod 0600) would contain:
|
||||
#
|
||||
# driver = sqlite
|
||||
# connect = $db_path
|
||||
# default_pass_scheme = SHA512-CRYPT
|
||||
# password_query = SELECT email as user, password FROM users WHERE email='%u';
|
||||
|
||||
# Have Dovecot provide an authorization service that Postfix can access & use.
|
||||
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
source setup/functions.sh
|
||||
|
||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
||||
hide_output pip3 install rtyaml
|
||||
hide_output pip3 install rtyaml
|
||||
|
||||
# For two-factor authentication, the management server uses:
|
||||
hide_output pip3 install git+https://github.com/mail-in-a-box/python-oath qrcode pillow
|
||||
|
||||
# Create a backup directory and a random key for encrypting backups.
|
||||
mkdir -p $STORAGE_ROOT/backup
|
||||
|
||||
@@ -15,7 +15,7 @@ apt_install \
|
||||
apt-get purge -qq -y owncloud*
|
||||
|
||||
# Install ownCloud from source of this version:
|
||||
owncloud_ver=7.0.3
|
||||
owncloud_ver=7.0.4
|
||||
|
||||
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,12 +17,16 @@ 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
|
||||
# * 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 \
|
||||
haveged unattended-upgrades ntp fail2ban
|
||||
wget curl sudo coreutils bc \
|
||||
haveged unattended-upgrades cron ntp fail2ban
|
||||
|
||||
# Allow apt to install system updates automatically every day.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user