from pvl.verkko import web, db, table
from pvl.verkko.utils import parse_timedelta, IPv4Network
from pvl.web import html, response
import re
import datetime
import socket # dns
import logging; log = logging.getLogger('pvl.verkko.hosts')
## Model
import json
import time
def dt2ts (dt) :
return int(time.mktime(dt.timetuple()))
def ts2dt (ts) :
return datetime.datetime.fromtimestamp(ts)
# TODO: this should be DHCPHost
class Host (object) :
DATE_FMT = '%Y%m%d'
TIME_FMT = '%H:%M:%S'
MAC_HEX = r'([A-Za-z0-9]{2})'
MAC_SEP = r'[-:.]?'
MAC_RE = re.compile(MAC_SEP.join([MAC_HEX] * 6))
@classmethod
def normalize_mac (cls, mac) :
match = cls.MAC_RE.search(mac)
if not match :
raise ValueError(mac)
else :
return ':'.join(hh.lower() for hh in match.groups())
def __init__ (self, ip, mac, name=None) :
self.ip = ip
self.mac = mac
self.name = name
def render_mac (self) :
if not self.mac :
return None
elif len(self.mac) > (6 * 2 + 5) :
return u'???'
else :
return unicode(self.mac)
def network (self) :
return self.gw
def render_name (self) :
if self.name :
return self.name.decode('ascii', 'replace')
else :
return None
STATES = {
'DHCPACK': 'ack',
'DHCPNAK': 'nak',
'DHCPRELEASE': 'release',
'DHCPDISCOVER': 'search',
'DHCPREQUEST': 'search',
'DHCPOFFER': 'search',
}
def state_class (self) :
if self.error :
return 'dhcp-error'
elif self.state in self.STATES :
return 'dhcp-' + self.STATES[self.state]
else :
return None
def state_title (self) :
return self.error # or None
def render_state (self) :
if self.error :
return "{self.state}: {self.error}".format(self=self)
else :
return self.state
@classmethod
def format_datetime (cls, dt) :
if (datetime.datetime.now() - dt).days :
return dt.strftime(cls.DATE_FMT)
else :
return dt.strftime(cls.TIME_FMT)
def seen (self) :
return (
html.span(title=self.first_seen)(self.format_datetime(self.first_seen)),
'-',
html.span(title=self.last_seen)(self.format_datetime(self.last_seen))
)
def dns (self) :
"""
Reverse-DNS lookup.
"""
if not self.ip :
return None
sockaddrs = set(sockaddr for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(self.ip, 0, 0, 0, 0, socket.AI_NUMERICHOST))
for sockaddr in sockaddrs :
try :
host, port = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD)
except socket.gaierror :
continue
return host
def __unicode__ (self) :
return u"{host.ip} ({host.mac})".format(host=self)
db.mapper(Host, db.dhcp_hosts, properties=dict(
#id = db.dhcp_hosts.c.rowid,
#state = db.dhcp_hosts.c.,
))
## Controller
class HostsTable (table.Table) :
"""
<table> of hosts.
"""
ITEMS = "Hosts"
COLUMNS = (
table.Column('ip', "IP", Host.ip,
rowfilter = True,
),
table.Column('mac', "MAC", Host.mac, Host.render_mac,
rowfilter = True,
),
table.Column('name', "Hostname", Host.name, Host.render_name, ),
table.Column('gw', "Network", Host.gw, Host.network, ),
table.Column('seen', "Seen", Host.last_seen, Host.seen, ),
table.Column('state', "State", Host.count,
rowtitle = Host.state_title,
rowcss = Host.state_class,
),
)
# XXX: have to set again
ATTRS = dict((col.attr, col) for col in COLUMNS)
# default
SORT = Host.last_seen.desc()
PAGE = 10
class HostsHandler (table.TableHandler, web.DatabaseHandler) :
"""
Combined database + <table>
"""
CSS = web.DatabaseHandler.CSS + table.TableHandler.CSS + (
"/static/dhcp/hosts.css",
)
# view
TABLE = HostsTable
def query (self) :
"""
Database SELECT query.
"""
return self.db.query(Host)
def filter_seen (self, value) :
"""
Return filter expression for given attr == value
"""
column = Host.last_seen
if value.isdigit() :
# specific date
date = datetime.datetime.strptime(value, Host.DATE_FMT).date()
return db.between(date.strftime(Host.DATE_FMT),
db.func.strftime(Host.DATE_FMT, Host.first_seen),
db.func.strftime(Host.DATE_FMT, Host.last_seen)
)
else :
# recent
timedelta = parse_timedelta(value)
return ((db.func.now() - Host.last_seen) < timedelta)
# XXX: for sqlite, pgsql should handle this natively?
# to seconds
#timeout = timedelta.days * (24 * 60 * 60) + timedelta.seconds
# WHERE strftime('%s', 'now') - strftime('%s', last_seen) < :timeout
#filter = (db.func.strftime('%s', 'now') - db.func.strftime('%s', Host.last_seen) < timeout)
def filter_ip (self, value) :
column = Host.ip
# column is IPv4 string literal format...
if '/' in value :
return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value)))
else :
return (db.func.inet(Host.ip) == db.func.inet(value))
def filter_mac (self, value) :
return self.filter_attr('mac', Host.normalize_mac(value))
class ItemHandler (HostsHandler) :
"""
A specific DHCP host, along with a list of related hosts.
"""
def process (self, id) :
self.hosts = self.query()
self.host = self.hosts.get(id)
if not self.host :
raise web.NotFound("No such host: {id}".format(id=id))
self.sorts, self.hosts = self.sort(self.hosts.filter((Host.ip == self.host.ip) | (Host.mac == self.host.mac)))
def title (self) :
return u"DHCP Host: {self.host}".format(self=self)
def render_host (self, host) :
"""
Details for specific host.
"""
attrs = (
('Network', host.gw),
('IP', host.ip),
('MAC', host.mac),
('Hostname', host.name),
('DNS', host.dns()),
('First seen', host.first_seen),
('Last seen', host.last_seen),
('Last state', host.render_state()),
('Total messages', host.count),
)
return (
html.dl(
(html.dt(title), html.dd(value)) for title, value in attrs
)
)
def render (self) :
return (
html.h2('Host'),
self.render_host(self.host),
html.h2('Related'),
self.render_table(self.hosts, sort=self.sorts, hilight=dict(ip=self.host.ip, mac=self.host.mac)),
html.a(href=self.url(ListHandler))(html('«'), 'Back'),
)
class ListHandler (HostsHandler) :
"""
List of DHCP hosts for given filter.
"""
TABLE_ITEM_URL = ItemHandler
def process (self) :
# super
table.TableHandler.process(self)
def title (self) :
if self.filters :
return "DHCP Hosts: {filters}".format(filters=self.filters_title())
else :
return "DHCP Hosts"
def render (self) :
return (
self.render_table(self.query, filters=self.filters, sort=self.sorts, page=self.page),
#html.a(href=self.url())(html('«'), 'Back') if self.filters else None,
)
class RealtimeHandler (HostsHandler) :
TITLE = "DHCP Hosts: Pseudo-Realtime.."
CSS = HostsHandler.CSS + (
'http://code.jquery.com/ui/1.9.0/themes/base/jquery-ui.css',
)
JS = (
#"/static/jquery/jquery.js",
'http://code.jquery.com/jquery-1.8.2.js',
'http://code.jquery.com/ui/1.9.0/jquery-ui.js',
'/static/dhcp/spin.js',
'/static/dhcp/table.js',
'/static/dhcp/hosts.js',
)
COLUMNS = (
( 'ip', 'IP', lambda host: host.ip ),
( 'mac', 'MAC', lambda host: host.mac ),
( 'name', 'Hostname', lambda host: host.name ),
( 'gw', 'Network', lambda host: host.gw ),
( 'seen', 'Seen', Host.seen, ),
( 'state', 'State', lambda host: host.state ),
)
def process (self) :
"""
Either return JSON (if ?t=...), or fetch hosts/t for rendering.
"""
t = self.request.args.get('t')
if t :
# return json
t = ts2dt(int(t))
# query
hosts = self.query()
# always sorted by last_seen
hosts = hosts.order_by(Host.last_seen.desc())
# filter
self.filters, hosts = self.filter(hosts)
if t :
# return json
hosts = hosts.filter(Host.last_seen > t)
hosts = list(hosts)
hosts.reverse()
if hosts :
# update timestamp to most recent
t = hosts[-1].last_seen
# json
data = dict(
t = dt2ts(t),
hosts = [dict(self.table.json(host)) for host in hosts],
)
return response.json(data)
else :
# render html
hosts = hosts.limit(self.table.PAGE)
# XXX: testing
hosts = hosts.offset(1)
# extract timestamp
for host in hosts :
self.t = host.last_seen
break
else :
# no hosts :<
self.t = datetime.datetime.now()
# store
self.hosts = hosts
def title (self) :
if self.filters :
return "{title}: {filters}".format(title=self.TITLE, filters=self.filters_title())
else :
return self.TITLE
def render (self) :
"""
Render page HTML and initial <table>, along with bootstrap JS (t0, configuration).
"""
return html.div(id='wrapper')(
html.input(type='submit', id='refresh', value="Refresh"),
html.input(type='reset', id='pause', value="Pause"),
self.table.render(self.hosts)(id='hosts-realtime'),
html.script(type='text/javascript')(
"""
$(document).ready(HostsRealtime(Table($('#hosts-realtime'), {table_params}), {params}));
""".format(
table_params = json.dumps(dict(
item_url = self.url(ItemHandler, id='0'),
columns = [column.attr for column in self.table.columns],
)),
params = json.dumps(dict(
url = self.url(),
filters = self.filters,
t = dt2ts(self.t),
)),
)
)
)