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'
|
||||
DEBUG = False
|
||||
CAP_FILE = './dhcp-cap'
|
||||
LEASE_FILE = './dhcpd.leases'
|
||||
LEASE_OFFSET = -60 * 60
|
||||
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 = [
|
||||
# '127.0.0.1',
|
||||
# '127.0.0.1',
|
||||
]
|
||||
|
||||
SECRET_KEY = 'CHANGEME'
|
||||
|
||||
SPACEAUTH_CONSUMER_KEY = 'checkinator'
|
||||
SPACEAUTH_CONSUMER_SECRET = 'CHANGEME'
|
||||
|
||||
SPECIAL_DEVICES = {
|
||||
'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
|
||||
|
|
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