import smtplib import ssl import email.utils import hmac from email.mime.text import MIMEText from datetime import datetime import time import functools import json import binascii import uwsgidecorators import sqlite3 from flask import Flask, request, abort app = Flask(__name__) app.config.from_pyfile('config.py') db = sqlite3.connect(app.config['SQLITE_DB'], check_same_thread=False) with db as conn: conn.execute('''CREATE TABLE IF NOT EXISTS seqs_seen (route_name TEXT PRIMARY KEY, seq INTEGER)''') conn.execute('''CREATE TABLE IF NOT EXISTS time_seen (route_name TEXT PRIMARY KEY)''') conn.execute('''CREATE TABLE IF NOT EXISTS heartbeats_seen (route_name TEXT PRIMARY KEY, timestamp INTEGER, notified INTEGER)''') # Clear table on startup to avoid spurious notifications conn.execute('''DELETE FROM heartbeats_seen''') mail_routes = {} def mail_route(name, receiver, secret): def wrap(func): global routes mail_routes[name] = (receiver, func, secret) return func return wrap def authenticate(route_name, secret, clock_delta_tolerance:'s'=120): with db as conn: if not request.is_json: print('Rejecting notification: Incorrect content type') abort(400) if not 'auth' in request.json and 'payload' in request.json: print('Rejecting notification: signature or payload not found') abort(400) if not isinstance(request.json['auth'], str): print('Rejecting notification: signature is of incorrect type') abort(400) their_digest = binascii.unhexlify(request.json['auth']) our_digest = hmac.digest(secret.encode('utf-8'), request.json['payload'].encode('utf-8'), 'sha256') if not hmac.compare_digest(their_digest, our_digest): print('Rejecting notification: Incorrect signature') abort(403) try: payload = json.loads(request.json['payload']) except: print('Rejecting notification: Payload is not JSON') abort(400) last_seqnum = conn.execute('SELECT seq FROM seqs_seen WHERE route_name = ?', (route_name,)).fetchone() or 0 # We can check for seq here: Only an attacker with knowledge of the secret would be able to remove # seq from a message. This means for a single key, only messages with or without seq may ever be used. if 'seq' in payload: seq = payload['seq'] if not isinstance(seq, int): print('Rejecting notification: seq of wrong type') abort(400) if seq <= last_seqnum: print('Rejecting notification: seq out of order') abort(400) conn.execute('INSERT OR REPLACE INTO seqs_seen VALUES (?, ?)', (route_name, seq)) elif last_seqnum: print('Rejecting notification: seq not included but past messages included seq') abort(400) msg_time = None if 'time' in payload: msg_time = payload['time'] if not isinstance(msg_time, int): print('Rejecting notification: time of wrong type') abort(400) if abs(msg_time - int(time.time())) > clock_delta_tolerance: print('Rejecting notification: timestamp too far in the future or past') abort(400) conn.execute('INSERT OR REPLACE INTO time_seen VALUES (?)', (route_name,)) elif conn.execute('SELECT * FROM time_seen WHERE route_name = ?', (route_name,)).fetchone(): print('Rejecting notification: time not included but past messages included time') abort(400) if msg_time is None: msg_time = int(time.time()) return msg_time, payload['scope'], payload['d'] @mail_route('klingel', 'computerstuff@jaseg.de', app.config['SECRET_KLINGEL']) def klingel(classification='somewhere', rms=None, capture=None, **kwargs): return (f'It rang {classification}!', f'rms={rms}\ncapture={capture}\nextra_args={kwargs}') def send_mail(route_name, receiver, subject, body): try: context = ssl.create_default_context() smtp = smtplib.SMTP_SSL(app.config['SMTP_HOST'], app.config['SMTP_PORT']) smtp.login('apikey', app.config['SENDGRID_APIKEY']) sender = f'{route_name}@{app.config["DOMAIN"]}' msg = MIMEText(body) msg['Subject'] = subject msg['From'] = sender msg['To'] = receiver msg['Date'] = email.utils.formatdate() smtp.sendmail(sender, receiver, msg.as_string()) finally: smtp.quit() @app.route('/v1/notify/', methods=['POST']) def notify(route_name): receiver, func, secret = mail_routes[route_name] msg_time, scope, kwargs = authenticate(route_name, secret) if scope == 'default': # Exceptions will yield a 500 error subject, body = func(**kwargs) send_mail(route_name, receiver, subject, body or 'empty message') elif scope == 'info': send_mail(route_name, receiver, f'System info: {kwargs["info_msg"]}', f'Logged data: {kwargs}') elif scope == 'boot': formatted = datetime.utcfromtimestamp(msg_time).isoformat() send_mail(route_name, receiver, 'System startup', f'System powered up at {formatted}') elif scope == 'heartbeat': with db as conn: conn.execute('INSERT OR REPLACE INTO heartbeats_seen VALUES (?, ?, 0)', (route_name, int(time.time()))) elif scope == 'error': print(f'Device error: {kwargs}') return 'success' @uwsgidecorators.timer(60) def heartbeat_timer(_uwsgi_signum): threshold = int(time.time()) - app.config['HEARTBEAT_TIMEOUT'] with db as conn: for route, ts in db.execute( 'SELECT route_name, timestamp FROM heartbeats_seen WHERE timestamp <= ? AND notified == 0', (threshold,)).fetchall(): print(f'Heartbeat expired for {route}: {ts} < {threshold}') receiver, *_ = mail_routes[route] last = datetime.utcfromtimestamp(ts).isoformat() send_mail(route, receiver, 'Heartbeat timeout', f'Last heartbeat at {last}') db.execute('UPDATE heartbeats_seen SET notified = ? WHERE route_name = ?', (int(time.time()), route)) if __name__ == '__main__': app.run()