preparation for parsing and webapp split
commit
68508440d1
|
@ -0,0 +1,4 @@
|
||||||
|
at.cfg
|
||||||
|
at.db
|
||||||
|
*.egg-info
|
||||||
|
venv
|
|
@ -0,0 +1,32 @@
|
||||||
|
`Warsaw Hackerspace`_ presence tracker hosted on https://at.hackersapce.pl. It
|
||||||
|
uses dhcpd.leases file to track MAC adressess of devices connected to hs LAN
|
||||||
|
network.
|
||||||
|
|
||||||
|
.. _Warsaw Hackerspace: https://hackerspace.pl
|
||||||
|
|
||||||
|
Setup
|
||||||
|
-----
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
cp at.cfg.dist at.dist
|
||||||
|
|
||||||
|
# edit config file using your favourite editor
|
||||||
|
$EDITOR at.cfg
|
||||||
|
|
||||||
|
# create new database file (or copy existing one)
|
||||||
|
sqlite3 at.db < dbsetup.sql
|
||||||
|
|
||||||
|
# create python virtual environment
|
||||||
|
python3 -m venv vevnv
|
||||||
|
./venv/bin/python3 -m pip install -r requirements
|
||||||
|
./venv/bin/python3 -m pip install gunicorn
|
||||||
|
|
||||||
|
Running
|
||||||
|
-------
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
./venv/bin/gunicorn run:app
|
||||||
|
|
||||||
|
When running on OpenBSD make sure to pass '--no-sendfile' argument to gunicorn
|
||||||
|
command. This will prevent AttributeError on os.sendfile that seems to be
|
||||||
|
missing in this marvelous OS-es python3 stdlib.
|
17
at.cfg.dist
17
at.cfg.dist
|
@ -1,21 +1,26 @@
|
||||||
DB = './at.db'
|
DB = './at.db'
|
||||||
|
DEBUG = False
|
||||||
CAP_FILE = './dhcp-cap'
|
CAP_FILE = './dhcp-cap'
|
||||||
LEASE_FILE = './dhcpd.leases'
|
LEASE_FILE = './dhcpd.leases'
|
||||||
LEASE_OFFSET = -60 * 60
|
LEASE_OFFSET = -60 * 60
|
||||||
TIMEOUT = 1500
|
TIMEOUT = 1500
|
||||||
|
|
||||||
WIKI_URL = 'http://hackerspace.pl/wiki/doku.php?id=people:%(login)s:start'
|
WIKI_URL = 'https://wiki.hackerspace.pl/people:%(login)s:start'
|
||||||
|
|
||||||
CLAIMABLE_PREFIX = '10.8.0.' #'192.168.1.'
|
CLAIMABLE_PREFIX = '10.8.0.'
|
||||||
CLAIMABLE_EXCLUDE = [
|
CLAIMABLE_EXCLUDE = [
|
||||||
# '127.0.0.1',
|
# '127.0.0.1',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SECRET_KEY = 'CHANGEME'
|
||||||
|
|
||||||
|
SPACEAUTH_CONSUMER_KEY = 'checkinator'
|
||||||
|
SPACEAUTH_CONSUMER_SECRET = 'CHANGEME'
|
||||||
|
|
||||||
SPECIAL_DEVICES = {
|
SPECIAL_DEVICES = {
|
||||||
'kektops': ('90:e6:ba:84'),
|
'kektops': ('90:e6:ba:84'),
|
||||||
'esps': ('ec:fa:bc', 'dc:4f:22', 'd8:a0:1d', 'b4:e6:2d', 'ac:d0:74', 'a4:7b:9d', 'a0:20:a6', '90:97:d5', '68:c6:3a', '60:01:94', '5c:cf:7f', '54:5a:a6', '30:ae:a4', '2c:3a:e8', '24:b2:de', '24:0a:c4', '18:fe:34'),
|
'esps': ('ec:fa:bc', 'dc:4f:22', 'd8:a0:1d', 'b4:e6:2d', 'ac:d0:74', 'a4:7b:9d', 'a0:20:a6', '90:97:d5', '68:c6:3a', '60:01:94', '5c:cf:7f', '54:5a:a6', '30:ae:a4', '2c:3a:e8', '24:b2:de', '24:0a:c4', '18:fe:34', '38:2b:78', 'bc:dd:c2:'),
|
||||||
|
'vms': ('52:54:00'), # craptrap VMs
|
||||||
}
|
}
|
||||||
|
|
||||||
SECRET_KEY = 'adaba'
|
|
||||||
|
|
||||||
PROXY_FIX = False
|
PROXY_FIX = False
|
||||||
|
|
422
at.py
422
at.py
|
@ -1,422 +0,0 @@
|
||||||
#!/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)
|
|
|
@ -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
|
|
@ -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)
|
Loading…
Reference in New Issue