Commit 4d22fb9b authored by Joshua Tauberer's avatar Joshua Tauberer

run status checks each night and email the administrator with the changes from...

run status checks each night and email the administrator with the changes from the previous day's results
parent c18d58b1
......@@ -324,7 +324,7 @@ def system_status():
def print_line(self, message, monospace=False):
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
output = WebOutput()
run_checks(env, output, pool)
run_checks(False, env, output, pool)
return json_response(output.items)
@app.route('/system/updates')
......
......@@ -6,7 +6,7 @@
__ALL__ = ['check_certificate']
import os, os.path, re, subprocess, datetime, multiprocessing.pool
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz
......@@ -17,7 +17,7 @@ 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, pool):
def run_checks(rounded_values, env, output, pool):
# run systems checks
output.add_heading("System")
......@@ -33,12 +33,12 @@ def run_checks(env, output, pool):
# that in run_services checks.)
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
run_system_checks(env, output)
run_system_checks(rounded_values, env, output)
# perform other checks asynchronously
run_network_checks(env, output)
run_domain_checks(env, output, pool)
run_domain_checks(rounded_values, env, output, pool)
def get_ssh_port():
# Returns ssh port
......@@ -133,11 +133,11 @@ def check_service(i, service, env):
return (i, running, fatal, output)
def run_system_checks(env, output):
def run_system_checks(rounded_values, env, output):
check_ssh_password(env, output)
check_software_updates(env, output)
check_system_aliases(env, output)
check_free_disk_space(env, output)
check_free_disk_space(rounded_values, env, output)
def check_ssh_password(env, output):
# Check that SSH login with password is disabled. The openssh-server
......@@ -172,12 +172,15 @@ def check_system_aliases(env, output):
# admin email is automatically directed.
check_alias_exists("administrator@" + env['PRIMARY_HOSTNAME'], env, output)
def check_free_disk_space(env, output):
def check_free_disk_space(rounded_values, env, output):
# Check free disk space.
st = os.statvfs(env['STORAGE_ROOT'])
bytes_total = st.f_blocks * st.f_frsize
bytes_free = st.f_bavail * st.f_frsize
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10.0)
if not rounded_values:
disk_msg = "The disk has %s GB space remaining." % str(round(bytes_free/1024.0/1024.0/1024.0*10.0)/10)
else:
disk_msg = "The disk has less than %s%% space left." % str(round(bytes_free/bytes_total/10 + .5)*10)
if bytes_free > .3 * bytes_total:
output.print_ok(disk_msg)
elif bytes_free > .15 * bytes_total:
......@@ -215,7 +218,7 @@ def run_network_checks(env, output):
which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s."""
% (env['PUBLIC_IP'], zen, env['PUBLIC_IP']))
def run_domain_checks(env, output, pool):
def run_domain_checks(rounded_time, env, output, pool):
# Get the list of domains we handle mail for.
mail_domains = get_mail_domains(env)
......@@ -230,17 +233,17 @@ def run_domain_checks(env, output, pool):
# Serial version:
#for domain in sort_domains(domains_to_check, env):
# run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
# Parallelize the checks across a worker pool.
args = ((domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
for domain in domains_to_check)
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
ret = dict(ret) # (domain, output) => { domain: output }
for domain in sort_domains(ret, env):
ret[domain].playback(output)
def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
output = BufferedOutput()
output.add_heading(domain)
......@@ -255,7 +258,7 @@ def run_domain_checks_on_domain(domain, env, dns_domains, dns_zonefiles, mail_do
check_mail_domain(domain, env, output)
if domain in web_domains:
check_web_domain(domain, env, output)
check_web_domain(domain, rounded_time, env, output)
if domain in dns_domains:
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
......@@ -472,7 +475,7 @@ def check_mail_domain(domain, env, output):
which may prevent recipients from receiving your mail.
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
def check_web_domain(domain, env, output):
def check_web_domain(domain, rounded_time, env, output):
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
# other domains, it is required to access its website.
......@@ -488,7 +491,7 @@ def check_web_domain(domain, env, output):
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
# user will log in with IMAP or webmail. Any other domain we serve a
# website for also needs a signed certificate.
check_ssl_cert(domain, env, output)
check_ssl_cert(domain, rounded_time, env, output)
def query_dns(qname, rtype, nxdomain='[Not Set]'):
# Make the qname absolute by appending a period. Without this, dns.resolver.query
......@@ -515,7 +518,7 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
# can compare to a well known order.
return "; ".join(sorted(str(r).rstrip('.') for r in response))
def check_ssl_cert(domain, env, output):
def check_ssl_cert(domain, rounded_time, env, output):
# Check that SSL certificate is signed.
# Skip the check if the A record is not pointed here.
......@@ -530,7 +533,7 @@ def check_ssl_cert(domain, env, output):
# Check that the certificate is good.
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
if cert_status == "OK":
# The certificate is ok. The details has expiry info.
......@@ -569,7 +572,7 @@ def check_ssl_cert(domain, env, output):
output.print_line(cert_status_details)
output.print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key):
def check_certificate(domain, ssl_certificate, ssl_private_key, rounded_time=False):
# Use openssl verify to check the status of a certificate.
# First check that the certificate is for the right domain. The domain
......@@ -680,7 +683,15 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
# But is it expiring soon?
now = datetime.datetime.now(dateutil.tz.tzlocal())
ndays = (cert_expiration_date-now).days
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
if not rounded_time or ndays < 7:
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
elif ndays <= 14:
expiry_info = "The certificate expires in less than two weeks, on %s." % cert_expiration_date.strftime("%x")
elif ndays <= 31:
expiry_info = "The certificate expires in less than a month, on %s." % cert_expiration_date.strftime("%x")
else:
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
if ndays <= 31:
return ("The certificate is expiring soon: " + expiry_info, None)
......@@ -721,17 +732,109 @@ def list_apt_updates(apt_update=True):
return pkgs
def run_and_output_changes(env, pool, send_via_email):
import json
from difflib import SequenceMatcher
class ConsoleOutput:
try:
terminal_columns = int(shell('check_output', ['stty', 'size']).split()[1])
except:
terminal_columns = 76
if not send_via_email:
out = ConsoleOutput()
else:
import io
out = FileOutput(io.StringIO(""), 70)
# Run status checks.
cur = BufferedOutput()
run_checks(True, env, cur, pool)
# Load previously saved status checks.
cache_fn = "/var/cache/mailinabox/status_checks.json"
if os.path.exists(cache_fn):
prev = json.load(open(cache_fn))
# Group the serial output into categories by the headings.
def group_by_heading(lines):
from collections import OrderedDict
ret = OrderedDict()
k = []
ret["No Category"] = k
for line_type, line_args, line_kwargs in lines:
if line_type == "add_heading":
k = []
ret[line_args[0]] = k
else:
k.append((line_type, line_args, line_kwargs))
return ret
prev_status = group_by_heading(prev)
cur_status = group_by_heading(cur.buf)
# Compare the previous to the current status checks
# category by category.
for category, cur_lines in cur_status.items():
if category not in prev_status:
out.add_heading(category + " -- Added")
BufferedOutput(with_lines=cur_lines).playback(out)
else:
# Actual comparison starts here...
prev_lines = prev_status[category]
def stringify(lines):
return [json.dumps(line) for line in lines]
diff = SequenceMatcher(None, stringify(prev_lines), stringify(cur_lines)).get_opcodes()
for op, i1, i2, j1, j2 in diff:
if op == "replace":
out.add_heading(category + " -- Previously:")
elif op == "delete":
out.add_heading(category + " -- Removed")
if op in ("replace", "delete"):
BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out)
if op == "replace":
out.add_heading(category + " -- Currently:")
elif op == "insert":
out.add_heading(category + " -- Added")
if op in ("replace", "insert"):
BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out)
for category, prev_lines in prev_status.items():
if category not in cur_status:
out.add_heading(category)
out.add_warning("Removed.")
if send_via_email:
# If there were changes, send off an email.
buf = out.buf.getvalue()
if len(buf) > 0:
# create MIME message
from email.message import Message
msg = Message()
msg['From'] = "\"%s\" <administrator@%s>" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'])
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
msg.set_payload(buf, "UTF-8")
# send to administrator@
import smtplib
mailserver = smtplib.SMTP('localhost', 25)
mailserver.ehlo()
mailserver.sendmail(
"administrator@%s" % env['PRIMARY_HOSTNAME'], # MAIL FROM
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
msg.as_string())
mailserver.quit()
# Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f:
json.dump(cur.buf, f, indent=True)
class FileOutput:
def __init__(self, buf, width):
self.buf = buf
self.width = width
def add_heading(self, heading):
print()
print(heading)
print("=" * len(heading))
print(file=self.buf)
print(heading, file=self.buf)
print("=" * len(heading), file=self.buf)
def print_ok(self, message):
self.print_block(message, first_line="✓ ")
......@@ -743,28 +846,36 @@ class ConsoleOutput:
self.print_block(message, first_line="? ")
def print_block(self, message, first_line=" "):
print(first_line, end='')
print(first_line, end='', file=self.buf)
message = re.sub("\n\s*", " ", message)
words = re.split("(\s+)", message)
linelen = 0
for w in words:
if linelen + len(w) > self.terminal_columns-1-len(first_line):
print()
print(" ", end="")
if linelen + len(w) > self.width-1-len(first_line):
print(file=self.buf)
print(" ", end="", file=self.buf)
linelen = 0
if linelen == 0 and w.strip() == "": continue
print(w, end="")
print(w, end="", file=self.buf)
linelen += len(w)
print()
print(file=self.buf)
def print_line(self, message, monospace=False):
for line in message.split("\n"):
self.print_block(line)
class ConsoleOutput(FileOutput):
def __init__(self):
self.buf = sys.stdout
try:
self.width = int(shell('check_output', ['stty', 'size']).split()[1])
except:
self.width = 76
class BufferedOutput:
# Record all of the instance method calls so we can play them back later.
def __init__(self):
self.buf = []
def __init__(self, with_lines=None):
self.buf = [] if not with_lines else with_lines
def __getattr__(self, attr):
if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"):
raise AttributeError
......@@ -778,12 +889,17 @@ class BufferedOutput:
if __name__ == "__main__":
import sys
from utils import load_environment
env = load_environment()
pool = multiprocessing.pool.Pool(processes=10)
if len(sys.argv) == 1:
pool = multiprocessing.pool.Pool(processes=10)
run_checks(env, ConsoleOutput(), pool)
run_checks(False, env, ConsoleOutput(), pool)
elif sys.argv[1] == "--show-changes":
run_and_output_changes(env, pool, sys.argv[-1] == "--smtp")
elif sys.argv[1] == "--check-primary-hostname":
# See if the primary hostname appears resolvable and has a signed certificate.
domain = env['PRIMARY_HOSTNAME']
......@@ -796,5 +912,3 @@ if __name__ == "__main__":
if cert_status != "OK":
sys.exit(1)
sys.exit(0)
......@@ -30,6 +30,17 @@ $(pwd)/management/backup.py
EOF
chmod +x /etc/cron.daily/mailinabox-backup
# Perform daily status checks. Compare each day to the previous
# for changes and mail the changes to the administrator.
cat > /etc/cron.daily/mailinabox-statuschecks << EOF;
#!/bin/bash
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
# Run status checks.
$(pwd)/management/status_checks.py --show-changes --smtp
EOF
chmod +x /etc/cron.daily/mailinabox-statuschecks
# Start it. Remove the api key file first so that start.sh
# can wait for it to be created to know that the management
# server is ready.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment