split at/__init__.py into dhcpd.py and web.py

master^2
vuko 2020-06-13 12:03:53 +02:00
parent 21f8891b0f
commit de59a1eb03
11 changed files with 427 additions and 422 deletions

View File

@ -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)

187
at/dhcp.py Normal file
View File

@ -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

240
at/web.py Normal file
View File

@ -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)