summaryrefslogtreecommitdiffstats
path: root/at
diff options
context:
space:
mode:
authorvuko <vuko@hackerspace.pl>2020-06-13 12:13:19 +0200
committervuko <vuko@hackerspace.pl>2020-06-13 12:13:19 +0200
commit68508440d10143fe7429a5d6cc6ae31264cfecf2 (patch)
tree0e9c177cb432a91549b636254062aeca2adc8c48 /at
parentf5cde26030a66608628c3581784074753b6ad679 (diff)
parentbc53e2fe6b20e8d6642c369ceec819b7c7221e3a (diff)
downloadcheckinator-master.tar.gz
checkinator-master.tar.bz2
checkinator-master.zip
preparation for parsing and webapp splitHEADmaster
Diffstat (limited to 'at')
-rw-r--r--at/__init__.py0
-rw-r--r--at/dhcp.py187
-rw-r--r--at/templates/account.html34
-rw-r--r--at/templates/admin.html13
-rw-r--r--at/templates/basic.html24
-rw-r--r--at/templates/claim.html21
-rw-r--r--at/templates/login.html17
-rw-r--r--at/templates/main.html34
-rw-r--r--at/templates/post_claim.html11
-rw-r--r--at/templates/register.html36
-rw-r--r--at/web.py240
11 files changed, 617 insertions, 0 deletions
diff --git a/at/__init__.py b/at/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/at/__init__.py
diff --git a/at/dhcp.py b/at/dhcp.py
new file mode 100644
index 0000000..2874e27
--- /dev/null
+++ b/at/dhcp.py
@@ -0,0 +1,187 @@
+import threading
+import traceback
+import os
+
+import logging
+
+from time import sleep, time, mktime
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+def strfts(ts, format='%d/%m/%Y %H:%M'):
+ return datetime.fromtimestamp(ts).strftime(format)
+
+class Updater(threading.Thread):
+ def __init__(self, timeout, lease_offset=0, logger=logger, *a, **kw):
+ self.timeout = timeout
+ self.lock = threading.Lock()
+ self.lease_offset = lease_offset
+ self.logger = logger
+ 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)
+ self.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:
+ self.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)
+ self.logger.warning('Cap file %s closed, reopening',
+ self.cap_file)
+ except Exception as e:
+ self.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):
+ self.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:
+ self.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:
+ self.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:
+ self.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
diff --git a/at/templates/account.html b/at/templates/account.html
new file mode 100644
index 0000000..5e8272a
--- /dev/null
+++ b/at/templates/account.html
@@ -0,0 +1,34 @@
+{% extends "basic.html" %}
+{% block content %}
+<a href="/">Back to homepage</a>
+<h2>Account settings</h2>
+{% for msg in get_flashed_messages(True) %}
+ <p class="{{ msg[0] }}">{{ msg[1] }}</p>
+{% endfor %}
+<h3>Claimed devices</h3>
+<table class="devices">
+ <tr>
+ <th>MAC</th>
+ <th>Device name</th>
+ <th>Visible</th>
+ <th>Toggle visibility</th>
+ <th>Delete</th>
+ </tr>
+{% for device in devices %}
+ <tr>
+ <td>{{ device.hwaddr }}</td>
+ <td>{{ device.name }}</td>
+ {% if device.ignored %}
+ <td class="invisible">invisible</td>
+ <td><a href="devices/{{ device.hwaddr }}/show">make visible</a></td>
+ {% else %}
+ <td class="visible">visible</td>
+ <td><a href="devices/{{ device.hwaddr }}/hide">make invisible</a></td>
+ {% endif %}
+ <td><a href="devices/{{ device.hwaddr }}/delete">delete device</a></td>
+ </tr>
+ </tbody>
+{% endfor%}
+</table>
+<p><a href="/claim">claim this device</a>
+{% endblock %}
diff --git a/at/templates/admin.html b/at/templates/admin.html
new file mode 100644
index 0000000..c2fcb4b
--- /dev/null
+++ b/at/templates/admin.html
@@ -0,0 +1,13 @@
+{% extends "basic.html" %}
+{% block content %}
+<table class="devices">
+ <tr>
+ <th>MAC</th>
+ <th>Device type</th>
+ </tr>
+ {% for key, l in data.items() %}
+ {% for item in l %}
+ <tr><td>{{ item }}</td><td>{{ key }}</td></tr>
+ {% endfor %}
+ {% endfor %}
+{% endblock %}
diff --git a/at/templates/basic.html b/at/templates/basic.html
new file mode 100644
index 0000000..4f27050
--- /dev/null
+++ b/at/templates/basic.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html>
+ <head>
+ {% block head %}
+ <link rel="stylesheet" type="text/css" href="/static/css/basic.css">
+ <title>{% block title %}Now at hackerspace{% endblock %}</title>
+ {% endblock %}
+ </head>
+ <body>
+ {% block body %}
+ <div class="login">
+ {% if current_user.is_authenticated %}
+ logged in as {{ current_user.id }} |
+ <a href="account">account</a> |
+ <a href="{{ url_for('spaceauth.logout') }}">log out</a>
+ {% else %}
+ <a href="{{ url_for('spaceauth.login') }}">login</a>
+ {% endif %}
+ </div>
+ {% block content %}
+ {% endblock %}
+ {% endblock %}
+ </body>
+</html>
diff --git a/at/templates/claim.html b/at/templates/claim.html
new file mode 100644
index 0000000..05f2e0b
--- /dev/null
+++ b/at/templates/claim.html
@@ -0,0 +1,21 @@
+{% extends "basic.html" %}
+{% block content %}
+ <h2>Claiming a device</h2>
+ {% if not hwaddr %}
+ <p class="error">Unknown MAC. Are you sure you're in the hackerspace?</p>
+ {% else %}
+ You are about to claim <strong>{{ hwaddr }}</strong> as <strong>{{ current_user.id }}</strong>. Do you wish to continue?
+ <table>
+ <form action="" method="post">
+ <label><tr>
+ <td>Device name (optional):</td>
+ <td><input type="text" name="name" value="{{ name }}"></td>
+ </tr></label>
+ <tr>
+ <td><input type="submit" value="yes"></td>
+ </form>
+ <td><a href="/"><button>no</button></button></td>
+ </tr>
+ </table>
+ {% endif %}
+{% endblock %}
diff --git a/at/templates/login.html b/at/templates/login.html
new file mode 100644
index 0000000..b4174b8
--- /dev/null
+++ b/at/templates/login.html
@@ -0,0 +1,17 @@
+<html>
+<body>
+<h2>Login</h2>
+{% for error in get_flashed_messages() %}
+<p class="error">{{ error }}</p>
+{% endfor %}
+<form action="" method="POST">
+<table>
+<label><tr><td>login</td><td><input type="text" name="login" value="{{ login }}"></td></tr></label>
+<label><tr><td>password</td><td><input type="password" name="password"></td></tr></label>
+{% if goto %}
+<input type="hidden" name="goto" value="{{ goto }}">
+{% endif %}
+<tr><td></td><td><input type="submit" value="login"></input></td></tr>
+</form>
+</body>
+</html>
diff --git a/at/templates/main.html b/at/templates/main.html
new file mode 100644
index 0000000..3ac5c50
--- /dev/null
+++ b/at/templates/main.html
@@ -0,0 +1,34 @@
+{% extends "basic.html" %}
+{% block title %}
+Now at hackerspace
+{% endblock %}
+{% block content %}
+ <h2>Now at hackerspace!</h2>
+ Recently at <a href="http://www.hackerspace.pl">hackerspace</a>:
+ <ul>
+ {% for user, timestamp in users %}
+ <li>
+ <a href="{{ user | wikiurl }}">
+ {{ user }} ({{ timestamp|strfts() }})
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+ {% trans n_unk=unknown|length %}
+ There is {{ n_unk }} unknown device operating.
+ {% pluralize %}
+ There are {{ n_unk }} unknown devices operating.
+ {% endtrans %}
+ {% trans n_kek=kektops|length %}
+ There is {{ n_kek }} unknown kektop operating.
+ {% pluralize %}
+ There are {{ n_kek }} unknown kektops operating.
+ {% endtrans %}
+ {% trans n_esp=esps|length %}
+ There is {{ n_esp }} unknown ESP operating.
+ {% pluralize %}
+ There are {{ n_esp }} unknown ESPs operating.
+ {% endtrans %}
+ <hr>
+ <a href="claim">Claim this device!</a>
+{% endblock %}
diff --git a/at/templates/post_claim.html b/at/templates/post_claim.html
new file mode 100644
index 0000000..858323b
--- /dev/null
+++ b/at/templates/post_claim.html
@@ -0,0 +1,11 @@
+{% extends "basic.html" %}
+{% block content %}
+{% if error %}
+<h2>Error!</h2>
+<p class="error">{{ error }}</p>
+{% else %}
+<h2>Success!</h2>
+Congratulations, you just claimed this device!
+<a href="/">go back</a>
+{% endif %}
+{% endblock %}
diff --git a/at/templates/register.html b/at/templates/register.html
new file mode 100644
index 0000000..9c6399f
--- /dev/null
+++ b/at/templates/register.html
@@ -0,0 +1,36 @@
+{% extends "basic.html" %}
+{% block content %}
+<h2>Register a new account</h2>
+{% for error in get_flashed_messages() %}
+<p class="error">{{ error }}</p>
+{% endfor %}
+<form action="" method="POST">
+ <table>
+ <label><tr>
+ <td>login</td>
+ <td><input type="text" name="login" value="{{ login }}"></td>
+ </tr></label>
+ <label><tr>
+ <td>password</td>
+ <td><input type="password" name="password"></td>
+ </tr></label>
+ <label><tr>
+ <td>confirm password</td>
+ <td><input type="password" name="password2"></td>
+ </tr></label>
+ <label><tr>
+ <td>homepage url</td>
+ <td><input type="text" name="url" value="{{ url }}"></td>
+ </tr></label>
+ <label><tr>
+ <td>use wiki page as url</td>
+ <td><input type="checkbox" name="wiki"
+ value="yes" {% if wiki == 'yes' %}checked="yes"{% endif %}></td>
+ </tr</label>
+ <tr>
+ <td></td>
+ <td><input type="submit" value="register"></input></td>
+ </tr>
+ </table>
+</form>
+{% endblock %}
diff --git a/at/web.py b/at/web.py
new file mode 100644
index 0000000..288a5bc
--- /dev/null
+++ b/at/web.py
@@ -0,0 +1,240 @@
+import json
+import requests
+import sqlite3
+from datetime import datetime
+from collections import namedtuple
+from urllib.parse import urlencode
+from functools import wraps
+from flask import Flask, render_template, abort, g, \
+ redirect, request, flash, url_for, make_response
+
+from .dhcp import DhcpdUpdater
+
+from spaceauth import SpaceAuth, login_required, current_user, cap_required
+
+DeviceInfo = namedtuple('DeviceInfo', ['hwaddr', 'name', 'owner', 'ignored'])
+
+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 req_to_ctx():
+ return dict(iter(request.form.items()))
+
+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)
+
+def app(instance_path):
+ app = Flask('at', instance_path=instance_path, instance_relative_config=True)
+ 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)
+
+ 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
+
+
+ @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()
+
+
+ @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'])
+ app.logger.info('parsing leases file for the first time')
+ updater._trigger_update()
+ app.logger.info('leases file parsed')
+ updater.start()
+ app.updater = updater
+
+ setup()
+
+ return app
+
+if __name__ == '__main__':
+ app.logger.setLevel(logging.DEBUG)
+ app.run('0.0.0.0', 8080, debug=True)