aboutsummaryrefslogtreecommitdiff
path: root/notification_proxy.py
blob: 117f8e131c7991a5b6fea019a4bad85af4b1c7f6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
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/<route_name>', 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()