diff options
author | vuko <vuko@hackerspace.pl> | 2020-06-12 22:20:31 +0200 |
---|---|---|
committer | vuko <vuko@hackerspace.pl> | 2020-06-12 22:20:31 +0200 |
commit | 21f8891b0f954c531770fd6ec44270d5787eeb0e (patch) | |
tree | 91035723c120a0a73dcd30b8d32431bdf54529bf /at | |
parent | d3ab4653825d080677869bfbe64cf0e9da30eabd (diff) | |
download | checkinator-21f8891b0f954c531770fd6ec44270d5787eeb0e.tar.gz checkinator-21f8891b0f954c531770fd6ec44270d5787eeb0e.tar.bz2 checkinator-21f8891b0f954c531770fd6ec44270d5787eeb0e.zip |
move at.py into package
Diffstat (limited to 'at')
-rw-r--r-- | at/__init__.py | 422 |
1 files changed, 422 insertions, 0 deletions
diff --git a/at/__init__.py b/at/__init__.py new file mode 100644 index 0000000..5a06ef2 --- /dev/null +++ b/at/__init__.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- +import sqlite3 +import threading +import traceback +import json +import requests +import os +import logging + +from flask import Flask, render_template, abort, g, \ + redirect, request, flash, url_for, make_response +from datetime import datetime +from time import sleep, time, mktime +from collections import namedtuple +from urllib.parse import urlencode + +from spaceauth import SpaceAuth, login_required, current_user, cap_required + +app = Flask('at') +app.config.from_pyfile('at.cfg') +app.jinja_env.add_extension('jinja2.ext.i18n') +app.jinja_env.install_null_translations() +app.updater = None + +if app.config.get('PROXY_FIX'): + from werkzeug.contrib.fixers import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app) + +auth = SpaceAuth(app) + + +from functools import wraps + + +def v4addr(): + if request.headers.getlist("X-Forwarded-For"): + r_addr = request.headers.getlist("X-Forwarded-For")[-1] + else: + r_addr = request.remote_addr + if r_addr.startswith('::ffff:'): + r_addr = r_addr[7:] + return r_addr + + +def restrict_ip(prefix='', exclude=[]): + def decorator(f): + @wraps(f) + def func(*a, **kw): + r_addr = v4addr() + if not r_addr.startswith(prefix) or r_addr in exclude: + app.logger.info('got IP %s, rejecting', r_addr) + abort(403) + + return f(*a, **kw) + return func + return decorator + + +def req_to_ctx(): + return dict(iter(request.form.items())) + + +@app.template_filter('strfts') +def strfts(ts, format='%d/%m/%Y %H:%M'): + return datetime.fromtimestamp(ts).strftime(format) + + +@app.template_filter('wikiurl') +def wikiurl(user): + return app.config['WIKI_URL'] % {'login': user} + + +@app.before_request +def make_connection(): + conn = sqlite3.connect(app.config['DB']) + conn.row_factory = sqlite3.Row + conn.isolation_level = None # for autocommit mode + g.db = conn + + +@app.teardown_request +def close_connection(exception): + g.db.close() + + +DeviceInfo = namedtuple('DeviceInfo', ['hwaddr', 'name', 'owner', 'ignored']) + + +def get_device_info(conn, hwaddr): + return list(get_device_infos(conn, (hwaddr,)))[0] + + +def get_device_infos(conn, hwaddrs): + in_clause = '({})'.format(', '.join(['?'] * len(hwaddrs))) + stmt = '''select hwaddr, name, ignored, owner from + devices where devices.hwaddr in ''' + in_clause + for row in conn.execute(stmt, hwaddrs): + owner = row['owner'] or '' + ignored = row['ignored'] + yield DeviceInfo(row['hwaddr'], row['name'], owner, ignored) + + +class Updater(threading.Thread): + def __init__(self, timeout, lease_offset=0, *a, **kw): + self.timeout = timeout + self.lock = threading.Lock() + self.lease_offset = lease_offset + self.active = {} + threading.Thread.__init__(self, *a, **kw) + self.daemon = True + + def purge_stale(self): + now = time() + for addr, (atime, ip, name) in list(self.active.items()): + if now - atime > self.timeout: + del self.active[addr] + + def get_active_devices(self): + with self.lock: + self.purge_stale() + r = dict(self.active) + return r + + def get_device(self, ip): + active_devices = iter(self.get_active_devices().items()) + for hwaddr, (atime, dip, name) in active_devices: + if ip == dip: + return hwaddr, name + return None, None + + def update(self, hwaddr, atime=None, ip=None, name=None): + if atime: + atime -= self.lease_offset + else: + atime = time() + with self.lock: + if hwaddr not in self.active or self.active[hwaddr][0] < atime: + self.active[hwaddr] = (atime, ip, name) + app.logger.info('updated %s with atime %s and ip %s', + hwaddr, strfts(atime), ip) + + +class CapUpdater(Updater): + def __init__(self, cap_file, *a, **kw): + self.cap_file = cap_file + Updater.__init__(self, *a, **kw) + + def run(self): + while True: + try: + with open(self.cap_file, 'r', buffering=0) as f: + app.logger.info('Updater ready on cap file %s', + self.cap_file) + lines = [l.strip() for l in f.read().split('\n')] + for hwaddr in lines: + if hwaddr: + self.update(hwaddr) + app.logger.warning('Cap file %s closed, reopening', + self.cap_file) + except Exception as e: + app.logger.error('Updater got an exception:\n' + + traceback.format_exc(e)) + sleep(10.0) + + +class MtimeUpdater(Updater): + def __init__(self, lease_file, *a, **kw): + self.lease_file = lease_file + self.position = 0 + self.last_modified = 0 + Updater.__init__(self, *a, **kw) + + def file_changed(self, f): + """Callback on changed lease file + + Args: + f: Lease file. File offset can be used to skip already parsed lines. + + Returns: New byte offset pointing after last parsed byte. + """ + return f.tell() + + def _trigger_update(self): + app.logger.info('Lease file changed, updating') + with open(self.lease_file, 'r') as f: + f.seek(self.position) + self.position = self.file_changed(f) + + def run(self): + """Periodicaly check if file has changed + + From ISC DHCPD manual: + + New leases are appended to the end of the dhcpd.leases file. In + order to prevent the file from becoming arbitrarily large, from + time to time dhcpd creates a new dhcpd.leases file from its in-core + lease database. Once this file has been written to disk, the old + file is renamed dhcpd.leases~, and the new file is renamed + dhcpd.leases. + """ + while True: + try: + stat = os.stat(self.lease_file) + mtime = stat.st_mtime + size = stat.st_size + if size < self.position: + app.logger.info('leases file changed - reseting pointer') + self.position = 0 + try: + # checking if DHCPD performed cleanup + # cleanup during operation seems to be currently broken + # on customs so this could never execute + purge_time = os.stat(self.lease_file + '~').st_mtime + if purge_time > self.last_modified: + app.logger.info('leases file purged - reseting pointer') + self.position = 0 + except FileNotFoundError: + pass + if mtime > self.last_modified: + self._trigger_update() + self.last_modified = mtime + sleep(5.0) + except Exception as e: + app.logger.exception('Exception in updater') + sleep(10.0) + + +class DnsmasqUpdater(MtimeUpdater): + def file_changed(self, f): + raise NotImplementedError( + "This was not tested after adding differential update") + for line in f: + ts, hwaddr, ip, name, client_id = line.split(' ') + self.update(hwaddr, int(ts), ip, name) + return f.tell() + + +class DhcpdUpdater(MtimeUpdater): + def file_changed(self, f): + lease = False + # for use by next-line logic + ip = None + hwaddr = None + atime = None + offset = f.tell() + while True: + # using readline because iter(file) blocks file.tell usage + line = f.readline() + if not line: + return offset + line = line.split('#')[0] + cmd = line.strip().split() + if not cmd: + continue + if lease: + field = cmd[0] + if(field == 'starts'): + dt = datetime.strptime(' '.join(cmd[2:]), + '%Y/%m/%d %H:%M:%S;') + atime = mktime(dt.utctimetuple()) + if(field == 'client-hostname'): + name = cmd[1][1:-2] + if(field == 'hardware'): + hwaddr = cmd[2][:-1] + if(field.startswith('}')): + offset = f.tell() + lease = False + if hwaddr is not None and atime is not None: + self.update(hwaddr, atime, ip, name) + hwaddr, atime = None, None + elif cmd[0] == 'lease': + ip = cmd[1] + name, hwaddr, atime = [None] * 3 + lease = True + + +@app.route('/') +def main_view(): + return render_template('main.html', **now_at()) + + +@app.route('/api') +def list_all(): + data = now_at() + + def prettify_user(xxx_todo_changeme): + (user, atime) = xxx_todo_changeme + if user == 'greenmaker': + user = 'dreammaker' + return { + 'login': user, + 'timestamp': atime, + 'pretty_time': strfts(atime), + } + result = {} + result['users'] = list(map(prettify_user, data.pop('users'))) + result.update((k, len(v)) for k, v in list(data.items())) + res = make_response(json.dumps(result), 200) + res.headers['Access-Control-Allow-Origin'] = '*' + return res + + +def now_at(): + result = dict() + devices = app.updater.get_active_devices() + device_infos = list(get_device_infos(g.db, list(devices.keys()))) + device_infos.sort(key=lambda di: devices[di.hwaddr]) + unknown = set(devices.keys()) - set(d.hwaddr for d in device_infos) + # das kektop sorting maschine + for name, prefixes in list(app.config['SPECIAL_DEVICES'].items()): + result[name] = set() + for u in unknown.copy(): + if u.startswith(prefixes): + result[name].add(u) + unknown.discard(u) + + result['unknown'] = unknown + + users = {} + for info in device_infos: + if info.owner not in users and not info.ignored: + users[info.owner] = devices[info.hwaddr][0] + result['users'] = sorted(list(users.items()), key=lambda u_a: u_a[1], reverse=True) + + return result + + +restrict_to_hs = restrict_ip(prefix=app.config['CLAIMABLE_PREFIX'], + exclude=app.config['CLAIMABLE_EXCLUDE']) + + +@app.route('/claim', methods=['GET']) +@restrict_to_hs +@login_required +def claim_form(): + hwaddr, name = app.updater.get_device(v4addr()) + return render_template('claim.html', hwaddr=hwaddr, name=name) + + +@app.route('/claim', methods=['POST']) +@restrict_to_hs +@login_required +def claim(): + hwaddr, lease_name = app.updater.get_device(v4addr()) + ctx = None + if not hwaddr: + ctx = dict(error='Invalid device.') + else: + login = current_user.id + try: + g.db.execute(''' +insert into devices (hwaddr, name, owner, ignored) values (?, ?, ?, ?)''', + [hwaddr, request.form['name'], login, False]) + ctx = {} + except sqlite3.Error: + error = 'Could not add device! Perhaps someone claimed it?' + ctx = dict(error=error) + return render_template('post_claim.html', **ctx) + + +def get_user_devices(conn, user): + devs = conn.execute('select hwaddr, name, ignored from devices where\ + owner = ?', [user]) + device_info = [] + for row in devs: + di = DeviceInfo(row['hwaddr'], row['name'], user, row['ignored']) + device_info.append(di) + return device_info + + +@app.route('/account', methods=['GET']) +@login_required +def account(): + devices = get_user_devices(g.db, current_user.id) + return render_template('account.html', devices=devices) + + +def set_ignored(conn, hwaddr, user, value): + return conn.execute(''' +update devices set ignored = ? where hwaddr = ? and owner = ?''', + [value, hwaddr, user]) + + +def delete_device(conn, hwaddr, user): + return conn.execute(''' +delete from devices where hwaddr = ? and owner = ?''', + [hwaddr, user]) + + +@app.route('/devices/<id>/<action>/') +@login_required +def device(id, action): + user = current_user.id + if action == 'hide': + set_ignored(g.db, id, user, True) + if action == 'show': + set_ignored(g.db, id, user, False) + if action == 'delete': + delete_device(g.db, id, user) + return redirect(url_for('account')) + + +@app.route('/admin') +@cap_required('staff') +def admin(): + data = now_at() + return render_template('admin.html', data=data) + + +@app.before_first_request +def setup(): + updater = DhcpdUpdater(app.config['LEASE_FILE'], app.config['TIMEOUT'], + app.config['LEASE_OFFSET']) + updater.start() + app.updater = updater + + +port = 8080 +if __name__ == '__main__': + app.logger.setLevel(logging.DEBUG) + app.run('0.0.0.0', 8080, debug=True) |