split out pvl-hosts from pvl-verkko
authorTero Marttila <tero.marttila@aalto.fi>
Tue, 24 Feb 2015 14:50:31 +0200
changeset 438 d45fc43c6073
parent 437 5100b359906c
child 439 6a8ea0d363c1
split out pvl-hosts from pvl-verkko
MANIFEST.in
README
bin/pvl.login-server
bin/pvl.rrd-graph
bin/pvl.rrd-interfaces
bin/pvl.switches-traps
bin/pvl.verkko-dhcp
bin/pvl.verkko-rrd
bin/pvl.wlan-syslog
etc/snmptrapd.conf
pvl/hosts.py
pvl/login/__init__.py
pvl/login/auth.py
pvl/login/pubtkt.py
pvl/login/server.py
pvl/login/ssl.py
pvl/login/static/pubtkt-expire.js
pvl/rrd/__init__.py
pvl/rrd/api.py
pvl/rrd/args.py
pvl/rrd/graph.py
pvl/rrd/hosts.py
pvl/rrd/rrds.py
pvl/verkko/__init__.py
pvl/verkko/db.py
pvl/verkko/dhcp.py
pvl/verkko/hosts.py
pvl/verkko/leases.py
pvl/verkko/rrd.py
pvl/verkko/table.py
pvl/verkko/utils.py
pvl/verkko/web.py
pvl/verkko/wlan.py
pvl/web/__init__.py
pvl/web/application.py
pvl/web/args.py
pvl/web/html.py
pvl/web/response.py
pvl/web/urls.py
setup.py
static/dhcp/forms.css
static/dhcp/hosts.css
static/dhcp/hosts.js
static/dhcp/spin.js
static/dhcp/table.css
static/dhcp/table.js
static/rrd/rrd.css
static/wlan.css
--- a/MANIFEST.in	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-include etc/*.conf.dist
-include static/dhcp/*.css static/dhcp/*.js
-include static/rrd/*.css static/rrd/*.js
--- a/README	Tue Feb 24 12:47:09 2015 +0200
+++ b/README	Tue Feb 24 14:50:31 2015 +0200
@@ -1,3 +1,3 @@
-== Requirements ==
-* python-psycopg2
+= pvl-hosts =
+DNS/DHCP hosts management for ISC bind9 and dhcpd
 
--- a/bin/pvl.login-server	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,51 +0,0 @@
-#!/usr/bin/python
-
-"""
-    pvl.verkko.rrd wsgi development server
-"""
-
-
-import pvl.args
-import pvl.ldap.args
-import pvl.login.auth
-import pvl.login.server
-import pvl.login.ssl
-import pvl.web.args
-
-
-import optparse
-import logging; log = logging.getLogger('pvl.login-server')
-
-       
-def main (argv) :
-    """
-        pvl.login server
-    """
-
-    parser = optparse.OptionParser(main.__doc__)
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.web.args.parser(parser))
-    parser.add_option_group(pvl.ldap.args.parser(parser))
-
-    options, args = parser.parse_args(argv[1:])
-    pvl.args.apply(options)
-
-    # ldap
-    ldap = pvl.ldap.args.apply(options)
-
-    # app
-    application = pvl.web.args.apply(options,
-            pvl.login.server.LoginApplication,
-            auth    = pvl.login.auth.LDAPAuth(ldap),
-            ssl     = pvl.login.ssl.UsersCA('ssl/userca', 'ssl/users'),
-    )
-
-    # behind a reverse-proxy
-    import werkzeug.contrib.fixers
-
-    application = werkzeug.contrib.fixers.ProxyFix(application)
-
-    pvl.web.args.main(options, application)
-
-if __name__ == '__main__':
-    pvl.args.main(main)
--- a/bin/pvl.rrd-graph	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-#!/usr/bin/env python
-
-"""
-    pvl.rrd graph output
-"""
-
-__version__ = '0.1'
-
-import pvl.args
-import pvl.rrd.args
-import pvl.rrd.graph
-
-import os.path
-
-import logging, optparse
-
-log = logging.getLogger('main')
-
-def parse_options (argv) :
-    """
-        Parse command-line arguments.
-    """
-
-    prog = argv[0]
-
-    parser = optparse.OptionParser(
-            prog        = prog,
-            usage       = '%prog: [options]',
-            version     = __version__,
-
-            # module docstring
-            description = __doc__,
-    )
-    
-    # options
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.rrd.args.parser(parser))
-
-    parser.add_option('--style',        metavar='STYLE',        default='detail',
-            help="overview/detail")
-
-    parser.add_option('--interval',     metavar='INTERVAL',     default='daily',
-            help="daily/weekly/yearly")
-
-    # parse
-    options, args = parser.parse_args(argv[1:])
-    
-    # apply
-    pvl.args.apply(options, prog)
-
-    return options, args
-
-def graph (options, rrds, rrd) :
-    """
-        Graph given rrd.
-    """
-
-
-    # out
-    path, ext = os.path.splitext(rrd)
-    ext = options.graph
-    out = path + ext
-    
-    # graph
-    log.info("%s -> %s", rrd, out)
-    
-    pvl.rrd.graph.collectd_ifoctets(options.style, options.interval, "Test", rrd, out)
-
-def main (argv) :
-    """
-        Usage: [options] rrd
-    """
-
-    options, args = parse_options(argv)
-
-    # RRDDatabase
-    rrds = pvl.rrd.args.apply(options)
-    
-    for rrd in args :
-        graph(options, rrds, rrd)
-
-    # done
-    log.info("Exiting...")
-    return 0
-
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
--- a/bin/pvl.rrd-interfaces	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,182 +0,0 @@
-#!/usr/bin/python
-
-"""
-    Setup symlinks for pvl.verkko-rrd -> collectd based on define host/interface names
-"""
-
-__version__ = '0.1'
-
-import os
-
-import pvl.args
-import pvl.rrd.hosts
-from pvl.rrd.hosts import hostreverse
-
-import optparse
-import logging; log = logging.getLogger('main')
-
-COLLECTD_RRD = '/var/lib/collectd/rrd'
-COLLECTD_PLUGIN = 'interfaces'
-COLLECTD_TYPE = 'if_octets'
-
-def parse_argv (argv, doc = __doc__) :
-    """
-        Parse command-line argv, returning (options, args).
-    """
-
-    prog = argv.pop(0)
-    args = argv
-
-    # optparse
-    parser = optparse.OptionParser(
-        prog        = prog,
-        usage       = '%prog: [options] [<input.txt> [...]]',
-        version     = __version__,
-        description = doc,
-    )
-
-    # common
-    parser.add_option_group(pvl.args.parser(parser))
-
-    # options
-    parser.add_option('--collectd-rrd',     metavar='PATH',     default=COLLECTD_RRD,
-            help="Path to collectd rrd: %default")
-    parser.add_option('--collectd-plugin',  metavar='PLUGIN',   default=None,
-            help="Collectd plugin to use: <input>-<plugin>.txt")
-    parser.add_option('--collectd-type',    metavar='TYPE',     default=COLLECTD_TYPE,
-            help="Collectd type to use: %default")
-    
-    # interface is by plugin, snmp is by type...
-    parser.add_option('--collectd-instance-plugin',     action='store_const', dest='collectd_instance', const='plugin',
-            help="Collectd by plugin instance")
-    parser.add_option('--collectd-instance-type',       action='store_const', dest='collectd_instance', const='type',
-            help="Collectd by type instance")
-    
-    # hostnames
-    parser.add_option('--reverse-host',     action='store_true',
-            help="Flip host.domain -> domain.host (default)")
-    parser.add_option('--no-reverse-host',  action='store_false', dest='reverse_host',
-            help="Keep host.domain as host.domain")
-    parser.add_option('--domain',           metavar='DOMAIN',  
-            help="Append domain to collectd hostnames: <input>.txt -> <input>")
-
-    # output
-    parser.add_option('--rrd',              metavar='PATH',
-            help="Output directory for .rrd symlinks: <input>.txt -> <input>/")
-    parser.add_option('--noop',             action='store_true',
-            help="Scan symlinks, but do not update")
-
-    parser.set_defaults(
-            collectd_instance   = 'type',
-            reverse_host        = True,
-    )
-
-    # parse
-    options, args = parser.parse_args(args)
-
-    # apply
-    pvl.args.apply(options)
-
-    return options, args
-
-def sync_links (options, links, rrddir) :
-    """
-        Sync given (collectd, name) symlinks in given dir.
-    """
-
-    log.info("%s", rrddir)
-
-    for rrdpath, (domain, host, port) in links :
-        linkpath = os.path.join(rrddir,
-                hostreverse(domain) if options.reverse_host else domain,
-                hostreverse(host) if options.reverse_host else host,
-                port + '.rrd'
-        )
-
-        # sync
-        if os.path.exists(linkpath) and os.readlink(linkpath) == rrdpath :
-            continue
-            
-        log.info("%s: %s", linkpath, rrdpath)
-        
-        yield linkpath, rrdpath
-
-def apply_links (options, links) :
-    """
-        Apply given symlinks
-    """
-
-    for link, path in links :
-        linkdir = os.path.dirname(link)
-
-        # do
-        if not os.path.exists(linkdir) :
-            log.warn("makedirs: %s", linkdir)
-            os.makedirs(linkdir)
-
-        os.symlink(path, link)
-
-def main (argv) :
-    options, args = parse_argv(argv)
-    
-    for path in args :
-        # <path>/<domain>-<plugin>.txt -> <path>/<domain>-<plugin>
-        if '.txt' in path:
-            basepath, _ = os.path.splitext(path)
-        else:
-            basepath = path
-        
-        # <path>/<domain> -> <domain>
-        _, basename = os.path.split(basepath)
-        
-        # <path>/<domain>-<plugin> -> <path>/<domain>, <plugin>
-        if '-' in basename :
-            basename, collectd_plugin = basename.rsplit('-', 1)
-        else :
-            collectd_plugin = None
-        
-        # domain?
-        if options.domain is None :
-            # reverse-order hostname
-            domain = hostreverse(basename)
-        else :
-            # may be ''
-            domain = options.domain
-        
-        # output dir?
-        if not options.rrd:
-            log.error("no --rrd output dir given")
-            return 1
-
-        rrddir = options.rrd
-
-        # generate links from spec
-        links = list(pvl.rrd.hosts.collectd_interfaces(options, open(path),
-            collectd_domain     = domain,
-            collectd_plugin     = options.collectd_plugin or collectd_plugin or COLLECTD_PLUGIN,
-        ))
-
-        if not os.path.exists(rrddir) :
-            log.error("given --rrd must already exist: %s", rrddir)
-            return 1
-
-        # sync missing links
-        links = list(sync_links(options, links,
-            rrddir  = rrddir,
-        ))
-        
-        # verbose
-        if not options.quiet :
-            for link, path in links :
-                print link, '->', path
-        
-        # apply
-        if not options.noop :
-            apply_links(options, links)
-
-    return 0
-    
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
--- a/bin/pvl.switches-traps	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,48 +0,0 @@
-#!/usr/bin/env python
-
-import pvl.args
-import pvl.hosts
-import pvl.snmp.traps
-import pvl.syslog.args
-
-import collections
-import logging; log = logging.getLogger('pvl.switches-traps')
-import optparse
-
-
-def main (argv) :
-    """
-        Process SNMP traps from snmptrapd.
-    """
-
-    parser = optparse.OptionParser(main.__doc__)
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.hosts.optparser(parser))
-    parser.add_option_group(pvl.syslog.args.parser(parser))
-
-    # input
-    options, args = parser.parse_args(argv[1:])
-    pvl.args.apply(options)
-    
-    # syslog source
-    syslog = pvl.syslog.args.apply(options)
-
-    # XXX: associate with host data
-    #hosts = pvl.hosts.apply(options, args)
-
-    # main
-    for item in syslog:
-        host, values = pvl.snmp.traps.parse_snmptrapd_log(item['msg'])
-
-        print '{}'.format(host)
-
-        for field, value in values.iteritems():
-            print '\t{:55} {}'.format(field, value)
-
-        print
-    
-    return 0
-
-if __name__ == '__main__':
-    pvl.args.main(main)
-
--- a/bin/pvl.verkko-dhcp	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-#!/usr/bin/python
-
-from werkzeug.serving import run_simple
-
-import pvl.args
-import pvl.web.args
-import pvl.verkko
-import pvl.verkko.dhcp
-
-from pvl.verkko import __version__
-import optparse
-import logging; log = logging.getLogger('main')
-
-def parse_argv (argv, doc = __doc__) :
-    """
-        Parse command-line argv, returning (options, args).
-    """
-
-    prog = argv.pop(0)
-    args = argv
-
-    # optparse
-    parser = optparse.OptionParser(
-        prog        = prog,
-        usage       = '%prog: [options] [<user> [...]]',
-        version     = __version__,
-        description = doc,
-    )
-
-    # common
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.web.args.parser(parser))
-
-    parser.add_option('-d', '--database-read', metavar='URI', default='sqlite:///var/verkko.db',
-        help="Database to use (readonly)")
-
-    # parse
-    options, args = parser.parse_args(args)
-
-    # apply
-    pvl.args.apply(options)
-
-    return options, args
-
-def main (argv) :
-    """
-        pvl.verkko wsgi development server.
-    """
-
-    # parse cmdline
-    options, args = parse_argv(argv, doc=__doc__)
-
-    # open
-    database = pvl.verkko.Database(options.database_read)
-
-    # app
-    application = pvl.web.args.apply(options, pvl.verkko.dhcp.Application, database)
-    pvl.web.args.main(options, application)
-
-if __name__ == '__main__' :
-    import sys
-
-    sys.exit(main(sys.argv))
-
-
--- a/bin/pvl.verkko-rrd	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-#!/usr/bin/python
-
-"""
-    pvl.verkko.rrd wsgi development server
-"""
-
-import werkzeug.serving 
-
-import pvl.args
-import pvl.rrd.args
-import pvl.web.args
-import pvl.verkko.rrd
-
-from pvl.verkko import __version__
-import optparse
-import logging; log = logging.getLogger('main')
-
-def parse_argv (argv, doc = __doc__) :
-    """
-        Parse command-line argv, returning (options, args).
-    """
-
-    prog = argv.pop(0)
-    args = argv
-
-    # optparse
-    parser = optparse.OptionParser(
-        prog        = prog,
-        usage       = '%prog: [options] [<user> [...]]',
-        version     = __version__,
-        description = doc,
-    )
-
-    # common
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.rrd.args.parser(parser))
-    parser.add_option_group(pvl.web.args.parser(parser))
-
-    # parse
-    options, args = parser.parse_args(args)
-
-    # apply
-    pvl.args.apply(options)
-
-    return options, args
-
-def main (argv) :
-    """
-        pvl.verkko wsgi development server.
-    """
-
-    # parse cmdline
-    options, args = parse_argv(argv, doc=__doc__)
-
-    # rrd
-    rrd = pvl.rrd.args.apply(options)
-
-    # app
-    application = pvl.web.args.apply(options, pvl.verkko.rrd.Application, rrd)
-    pvl.web.args.main(options, application)
-
-if __name__ == '__main__' :
-    import sys
-
-    sys.exit(main(sys.argv))
-
-
--- a/bin/pvl.wlan-syslog	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,227 +0,0 @@
-#!/usr/bin/python
-
-"""
-    Analyze WLAN STA logs.
-
-        Jul  3 23:05:04 buffalo-g300n-647682 daemon.info hostapd: wlan0-1: STA aa:bb:cc:dd:ee:ff WPA: group key handshake completed (RSN)
-
-"""
-
-__version__ = '0.1'
-
-import pvl.args
-import pvl.syslog.args
-import pvl.web.args
-import pvl.rrd.hosts
-import pvl.verkko.wlan
-
-import optparse
-import logging; log = logging.getLogger('main')
-
-WLAN_STA_PROG = 'hostapd'
-
-def parse_argv (argv, doc = __doc__) :
-    """
-        Parse command-line argv, returning (options, args).
-    """
-
-    prog = argv.pop(0)
-    args = argv
-
-    # optparse
-    parser = optparse.OptionParser(
-        prog        = prog,
-        usage       = '%prog: [options] [<input.txt> [...]]',
-        version     = __version__,
-        description = doc,
-    )
-
-    # common
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.syslog.args.parser(parser, prog=WLAN_STA_PROG))
-    parser.add_option_group(pvl.verkko.db.parser(parser, table=db.wlan_sta))
-    parser.add_option_group(pvl.web.args.parser(parser))
-
-    parser.add_option('--interfaces', metavar='PATH',
-            help="Load interface/node names from mapping file")
-
-    # parse
-    options, args = parser.parse_args(args)
-
-    # apply
-    pvl.args.apply(options)
-
-    return options, args
-
-import re
-from pvl.verkko import db
-
-class KeyTimestampDatabase (object) :
-    """
-        A pvl.verkko.db table that tracks events by key/timestamp.
-    """
-
-    DB_TABLE = None
-    DB_LAST_SEEN = None
-    DB_COUNT = None
-    DB_DISTINCT = None
-
-    def __init__ (self, db) :
-        """
-            db      - pvl.verkko.db.Database
-        """
-
-        self.db = db
-
-    def select (self, interval=None) :
-        """
-            SELECT unique gw/ip hosts, for given interval.
-        """
-
-        query = db.select(self.DB_DISTINCT, distinct=True)
-
-        if interval :
-            # timedelta
-            query = query.where(db.func.now() - self.DB_LAST_SEEN < interval)
-
-        return self.db.select(query)
-
-    def insert (self, key, timestamp, update) :
-        """
-            INSERT new row.
-        """
-
-        query = self.DB_TABLE.insert().values(**key).values(**update).values(
-                first_seen  = timestamp,
-                last_seen   = timestamp,
-        )
-
-        if self.DB_COUNT is not None :
-            query = query.values(count=1)
-        
-        # -> id
-        return self.db.insert(query)
-
-    def update (self, key, timestamp, update) :
-        """
-            UPDATE existing row, or return False if not found.
-        """
-
-        table = self.DB_TABLE
-        query = table.update()
-
-        for col, value in key.iteritems() :
-            query = query.where(table.c[col] == value)
-
-        query = query.values(last_seen=timestamp)
-
-        if self.DB_COUNT is not None :
-            query = query.values(count=db.func.coalesce(self.DB_COUNT, 0) + 1)
-
-        query = query.values(**update)
-        
-        # -> any matched rows?
-        return self.db.update(query)
-
-    def touch (self, key, timestamp, update, **opts) :
-        # update existing?
-        if self.update(key, timestamp, update, **opts) :
-            log.info("Update: %s: %s: %s", key, timestamp, update)
-        else :
-            log.info("Insert: %s: %s: %s", key, timestamp, update)
-            self.insert(key, timestamp, update, **opts)
-
-
-class WlanStaDatabase (KeyTimestampDatabase) :
-    HOSTAPD_STA_RE = re.compile(r'(?P<wlan>.+?): STA (?P<sta>.+?) (?P<msg>.+)')
-
-    DB_TABLE = db.wlan_sta
-    DB_LAST_SEEN = db.wlan_sta.c.last_seen
-    DB_COUNT = db.wlan_sta.c.count
-
-    DB_DISTINCT = (db.wlan_sta.c.sta, )
-
-    def __init__ (self, db, interface_map=None) :
-        """
-            interface_map       - {(hostname, interface): (nodename, wlan)}
-        """
-        KeyTimestampDatabase.__init__(self, db)
-        self.interface_map = interface_map
-
-    def parse (self, item) :
-        """
-            Parse fields from a hostapd syslog message.
-        """
-        
-        match = self.HOSTAPD_STA_RE.match(item['msg'])
-
-        if not match :
-            return None
-
-        return match.groupdict()
-
-    def lookup_wlan (self, host, iface) :
-        """
-            Lookup ap/ssid by host/iface.
-        """
-        mapping = None
-        if self.interface_map :
-            mapping = self.interface_map.get((host, iface))
-        
-        if mapping :
-            return mapping
-        else :
-            # as-is
-            log.warning("Unknown host/iface: %s/%s", host, iface)
-            return host, iface
-
-    def __call__ (self, item) :
-        match = self.parse(item)
-
-        if not match :
-            return
-
-        # lookup?
-        ap, ssid = self.lookup_wlan(item['host'], match['wlan'])
-        
-        # update/insert
-        self.touch(
-            dict(ap=ap, wlan=ssid, sta=match['sta']),
-            item['timestamp'],
-            dict(msg=match['msg']),
-        )
-
-def main (argv) :
-    options, args = parse_argv(argv)
-    
-    # database
-    db = pvl.verkko.db.apply(options)
-
-    if options.interfaces :
-        interfaces = dict(pvl.rrd.hosts.map_interfaces(options, open(options.interfaces)))
-    else :
-        interfaces = None
-
-    # syslog
-    log.info("Open up syslog...")
-    syslog = pvl.syslog.args.apply(options, optional=True)
-        
-    if syslog :
-        # handler
-        handler = WlanStaDatabase(db, interface_map=interfaces)
-
-        log.info("Enter mainloop...")
-        for source in syslog.main() :
-            for item in source:
-                handler(item)
-    else :
-        # run web
-        application = pvl.web.args.apply(options, pvl.verkko.wlan.Application, db)
-        return pvl.web.args.main(options, application)
-
-    return 0
-    
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
--- a/etc/snmptrapd.conf	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-authCommunity   log         we1ooL2ru5ohnael8
-
-outputOption X
-format1     %B %N %w %q %W\t%v\n
-format2     %B\t%v\n
--- a/pvl/hosts.py	Tue Feb 24 12:47:09 2015 +0200
+++ b/pvl/hosts.py	Tue Feb 24 14:50:31 2015 +0200
@@ -13,6 +13,8 @@
 
 import logging; log = logging.getLogger('pvl.hosts')
 
+__version__ = '0.7.3'
+
 def optparser (parser) :
     hosts = optparse.OptionGroup(parser, "Hosts input")
     hosts.add_option('--hosts-charset',         metavar='CHARSET',  default='utf-8', 
--- a/pvl/login/auth.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,103 +0,0 @@
-import ldap
-
-import pvl.ldap.domain
-import pvl.users.group
-
-import logging; log = logging.getLogger('pvl.login.auth')
-
-class AuthError (Exception) :
-    def __init__ (self, error) :
-        self.error = error
-
-    def __unicode__ (self) :
-        return u"Authenticating against the backend failed: {self.error}".format(self=self)
-
-class LDAPAuth (object) :
-    def __init__ (self, ldap) :
-        self.ldap = ldap
-
-    def auth (self, username, password) :
-        """
-            Attempt to bind against LDAP with given user object and password.
-
-            Returns None if the user does not seem to exist, False on invalid auth, True on valid auth.
-
-            Raises AuthError.
-        """
-        
-        # search
-        try :
-            user = self.ldap.users.get(username)
-        except KeyError :
-            log.info("%s: not found", username)
-            return None
-        else :
-            log.info("%s: %s", username, user)
-        
-        # bind
-        bind = self.bind(user, password)
-        
-        if bind :
-            return user
-        else :
-            return False
-
-    def bind (self, user, password) :
-        """
-            Attempt to bind against LDAP with given user object and password.
-        
-            Returns the bound connection, or
-                None        - if the user does not seem toe xist
-                False       - invalid auth
-
-            Raises AuthError.
-        """
-
-        conn = self.ldap.open()
-        
-        try :
-            conn.bind(user.dn, password)
-
-        except ldap.INVALID_CREDENTIALS as ex :
-            log.info("%s: INVALID_CREDENTIALS", user)
-            return False
-
-        except ldap.NO_SUCH_OBJECT as ex :
-            log.info("%s: ldap.NO_SUCH_OBJECT", user)
-            return None
-    
-        except ldap.LDAPError as ex :
-            log.exception("%s", user)
-            raise AuthError(ex)
-
-        else :
-            log.info("%s", user)
-            return conn
-
-    def access (self, user) :
-        """
-            Yield a list of access control tokens for the given auth username.
-        """
-        
-        yield pvl.users.group.Group.fromldap(self.ldap.users.group(user))
-
-        for group in self.ldap.users.groups(user) :
-            yield pvl.users.group.Group.fromldap(group)
-    
-    def userdata (self, user) :
-        """
-            Yield arbitrary userdata for given auth state.
-        """
-
-        return user.get('cn')
-
-    def renew (self, username) :
-        """
-            Re-lookup auth state for given username.
-        """
-
-        try :
-            return self.ldap.users.get(username)
-        except KeyError :
-            return None
-        
--- a/pvl/login/pubtkt.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,291 +0,0 @@
-import base64
-import calendar
-import datetime
-import ipaddr
-import hashlib
-import M2Crypto
-
-import logging; log = logging.getLogger('pvl.login.pubtkt')
-
-def datetime2unix (dt) :
-    """
-        datetime.datetime -> float
-    """
-
-    return calendar.timegm(dt.utctimetuple())
-
-def unix2datetime (unix) :
-    return datetime.datetime.utcfromtimestamp(unix)
-
-class Error (Exception) :
-    """
-        Error
-    """
-
-    def __init__ (self, error) :
-        self.error = error
-
-    def __unicode__ (self) :
-        return u"{doc}: {self.error}".format(self=self, doc=self.__doc__.strip())
-
-class ParseError (Error) :
-    """
-        Unable to parse PubTkt from cookie
-    """
-
-class VerifyError (Error) :
-    """
-        Invalid login token sigunature
-    """
-
-    def __init__ (self, pubtkt, error) :
-        self.pubtkt = pubtkt
-        self.error = error
-
-class ExpiredError (Error) :
-    """
-        Login token has expired
-    """
-
-    def __init__ (self, pubtkt, expire) :
-        self.pubtkt = pubtkt
-        self.error = expire
-
-class RenewError (Error) :
-    """
-        Unable to renew login token
-    """
-
-class ServerError (Error) :
-    """
-        Login request from invalid server
-    """
-    
-class ServerKeys (object) :
-    @classmethod
-    def config (cls, public_key, private_key) :
-        return cls(
-                public  = M2Crypto.RSA.load_pub_key(public_key),
-                private = M2Crypto.RSA.load_key(private_key),
-        )
-
-    def __init__ (self, public, private) :
-        self.public = public
-        self.private = private
-
-class PubTkt (object) :
-    @staticmethod
-    def now () :
-        return datetime.datetime.utcnow()
-
-    @classmethod
-    def load (cls, cookie, public_key) :
-        """
-            Load and verify a pubtkt from a cookie.
-
-            Raise ParseError, VerifyError.
-        """
-        
-        pubtkt, hash, sig = cls.parse(cookie)
-
-        log.debug("parsed %s hash=%s sig=%s", pubtkt, hash.encode('hex'), sig.encode('hex'))
-        
-        try :
-            if not public_key.verify(hash, sig, 'sha1') :
-                raise VerifyError(pubtkt, "Unable to verify signature")
-        except M2Crypto.RSA.RSAError as ex :
-            raise VerifyError(pubtkt, str(ex))
-        
-
-        log.debug("checking expiry %s", pubtkt.validuntil)
-        
-        if not pubtkt.valid() :
-            raise ExpiredError(pubtkt, pubtkt.validuntil)
-
-        return pubtkt
-
-    @classmethod
-    def parse (cls, cookie) :
-        """
-            Load a pubtkt from a cookie
-
-            Raises ParseError.
-        """
-        
-        if ';sig=' in cookie :
-            data, sig = cookie.rsplit(';sig=', 1)
-        else :
-            raise ParseError("Missing signature")
-        
-        try :
-            sig = base64.b64decode(sig)
-        except (ValueError, TypeError) as ex :
-            raise ParseError("Invalid signature")
-
-        hash = hashlib.sha1(data).digest()
-
-        try :
-            attrs = dict(field.split('=', 1) for field in data.split(';'))
-        except ValueError as ex :
-            raise ParseError(str(ex))
-        
-        if 'uid' not in attrs or 'validuntil' not in attrs :
-            raise ParseError("Missing parameters in cookie (uid, validuntil)")
-
-        try :
-            return cls.build(**attrs), hash, sig
-        except TypeError as ex :
-            raise ParseError("Invalid or missing parameters in cookie")
-        except ValueError as ex :
-            raise ParseError(str(ex))
-    
-    @classmethod
-    def build (cls, uid, validuntil, cip=None, tokens=None, udata=None, graceperiod=None, bauth=None) :
-        """
-            Build a pubtkt from items.
-
-            Raises TypeError or ValueError..
-        """
-        
-        return cls(uid,
-                validuntil  = unix2datetime(int(validuntil)),
-                cip         = ipaddr.IPAddress(cip) if cip else None,
-                tokens      = tokens.split(',') if tokens else (),
-                udata       = udata,
-                graceperiod = unix2datetime(int(graceperiod)) if graceperiod else None,
-                bauth       = bauth,
-        )
-
-    @classmethod
-    def new (cls, uid, valid, grace=None, **opts) :
-        now = cls.now()
-
-        return cls(uid, now + valid,
-            graceperiod = now + grace if grace else None,
-            **opts
-        )
-
-    def update (self, valid, grace, cip=None, tokens=None, udata=None, bauth=None) :
-        now = self.now()
-
-        return type(self)(self.uid, now + valid,
-            graceperiod = now + grace if grace else None,
-            cip         = self.cip if cip is None else cip,
-            tokens      = self.tokens if tokens is None else tokens,
-            udata       = self.udata if udata is None else udata,
-            bauth       = self.bauth if bauth is None else bauth,
-        )
-
-    def __init__ (self, uid, validuntil, cip=None, tokens=(), udata=None, graceperiod=None, bauth=None) :
-        self.uid = uid
-        self.validuntil = validuntil
-        self.cip = cip
-        self.tokens = tokens
-        self.udata = udata
-        self.graceperiod = graceperiod
-        self.bauth = bauth
-
-    def iteritems (self) :
-        yield 'uid', self.uid
-        yield 'validuntil', int(datetime2unix(self.validuntil))
-
-        if self.cip :
-            yield 'cip', self.cip
-        
-        if self.tokens :
-            yield 'tokens', ','.join(str(token) for token in self.tokens)
-        
-        if self.udata :
-            yield 'udata', self.udata
-        
-        if self.graceperiod :
-            yield 'graceperiod', int(datetime2unix(self.graceperiod))
-        
-        if self.bauth :
-            yield 'bauth', self.bauth
-
-    def __str__ (self) :
-        """
-            The (unsigned) pubtkt
-        """
-
-        return ';'.join('%s=%s' % (key, value) for key, value in self.iteritems())
-
-    def sign (self, private_key) :
-        data = str(self)
-        hash = hashlib.sha1(data).digest()
-        sign = private_key.sign(hash, 'sha1')
-
-        return '%s;sig=%s' % (self, base64.b64encode(sign))
-
-    def valid (self) :
-        """
-            Return remaining ticket validity.
-        """
-
-        now = self.now()
-
-        if self.validuntil > now :
-            return self.validuntil - now
-        else :
-            return False
-
-    def grace (self) :
-        """
-            Return remaining grace period.
-        """
-        
-        now = self.now()
-        
-        if not self.graceperiod :
-            return None
-
-        elif now < self.graceperiod :
-            # still valid
-            return None
-
-        elif now < self.validuntil :
-            # positive
-            return self.validuntil - now
-
-        else :
-            # expired
-            return False
-
-    def remaining (self) :
-        """
-            Return remaining validity before grace.
-        """
-
-        now = self.now()
-        
-        if not self.graceperiod :
-            return self.valid()
-
-        elif now < self.graceperiod :
-            return self.graceperiod - now
-
-        else :
-            # expired
-            return False
-
-    def grace_period (self) :
-        """
-            Return the length of the grace period.
-        """
-
-        if self.graceperiod :
-            return self.validuntil - self.graceperiod
-        else :
-            return None
-    
-    def renew (self, valid, grace=None) :
-        if not self.valid() :
-            raise ExpiredError(self, "Unable to renew expired pubtkt")
-
-        now = self.now()
-
-        self.validuntil = now + valid
-        self.graceperiod = now + grace if grace else None
-
-
--- a/pvl/login/server.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,701 +0,0 @@
-# encoding: utf-8
-
-import datetime
-import urlparse
-import werkzeug
-import werkzeug.urls
-
-import pvl.login.auth
-import pvl.web
-import pvl.web.response
-
-from pvl.login import pubtkt
-from pvl.web import urls
-from pvl.web import html5 as html
-
-import logging; log = logging.getLogger('pvl.login.server')
-
-class Handler (pvl.web.Handler) :
-    # Bootstrap
-    DOCTYPE = 'html'
-    HTML_XMLNS = None
-    HTML_LANG = 'en'
-    CSS = (
-            '//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css',
-    )
-    JS = (
-            '//code.jquery.com/jquery.js',
-            '//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js',
-    )
-
-    STYLE = """
-body {
-    padding-top: 2em;
-    text-align: center;
-}
-
-.container {
-    padding: 2em 1em;
-    text-align: left;
-}
-    """
-
-    def redirect (self, *url, **params) :
-        return pvl.web.response.redirect(self.url(*url, **params))
-    
-    pubtkt = None
-    invalid_pubtkt = None
-    valid_pubtkt = None
-
-    def init (self) :
-        self.alerts = []
-
-    def alert (self, type, alert, icon=None) :
-        log.info(u"%s: %s", type, alert)
-
-        self.alerts.append((type, icon, unicode(alert)))
-
-    def process_cookie (self) :
-        """
-            Reverse the urlencoding used for the cookie...
-        """
-        
-        log.debug("cookies: %s", self.request.cookies)
-
-        cookie = self.request.cookies.get(self.app.cookie_name)
-        
-        if not cookie :
-            return
-
-        log.debug("cookie %s=%s", self.app.cookie_name, cookie)
-
-        cookie = werkzeug.urls.url_unquote(cookie)
-        
-        log.debug("cookie decoded: %s", cookie)
-        
-        if not cookie :
-            return
-
-        try :
-            self.pubtkt = self.app.load(cookie)
-
-        except pubtkt.ParseError as ex :
-            self.alert('danger', ex, icon='compare')
-
-        except pubtkt.VerifyError as ex :
-            self.alert('danger', ex, icon='warning-sign')
-            
-            self.invalid_pubtkt = ex.pubtkt
-
-        except pubtkt.ExpiredError as ex :
-            self.alert('warning', ex, icon='clock')
-            
-            # store it anyways, but not as valid
-            self.pubtkt = ex.pubtkt
-
-        else :
-            # it's a parsed, verified and valid pubtkt
-            self.valid_pubtkt = self.pubtkt
-
-        return self.pubtkt
-
-    def process_back (self) :
-        self.server = None
-        self.back = urlparse.urlunparse((self.app.login_scheme, self.app.login_server, '/', '', '', ''))
-
-        back = self.request.args.get('back')
-
-        if back :
-            url = urlparse.urlparse(back, self.app.login_scheme)
-            
-            if not self.app.login_scheme :
-                scheme = url.scheme
-
-            elif url.scheme == self.app.login_scheme :
-                scheme = url.scheme
-
-            else :
-                self.alert('info', "Using SSL for application URL", icon='lock')
-                scheme = self.app.login_scheme
-
-            if url.hostname :
-                self.server = self.app.check_server(url.hostname)
-            else :
-                self.server = self.app.login_server
-
-            self.back = urlparse.urlunparse((scheme, self.server, url.path, url.params, url.query, url.fragment))
-
-    def render_alerts (self) :
-        for type, icon, alert in self.alerts :
-            yield html.div(class_='alert alert-{type}'.format(type=type))(
-                    html.span(class_='glyphicon glyphicon-{glyphicon}'.format(glyphicon=icon)) if icon else None,
-                    alert
-            )
-
-
-
-class Index (Handler) :
-    TITLE = u"Päivölä Network Login"
-
-    STYLE = Handler.STYLE + """
-.pubtkt {
-    width: 30em;
-    margin: 1em auto;
-}
-
-.pubtkt form {
-    display: inline;
-}
-
-.pubtkt .panel-heading {
-    line-height: 20px;
-}
-
-.pubtkt .panel-body .glyphicon {
-    width: 1em;
-    float: left;
-    line-height: 20px;
-}
-
-.pubtkt .panel-body .progress {
-    margin-bottom: 0;
-    margin-left: 2em;
-}
-    """
-    
-    JS = Handler.JS + (
-        '/static/pubtkt-expire.js',
-    )
-    
-    def process (self) :
-        self.process_cookie()
-            
-        if not self.pubtkt :
-            return self.redirect(Login)
-
-    def render_valid (self, valid) :
-        seconds = valid.seconds + valid.days * (24 * 60 * 60)
-        
-        minutes, seconds = divmod(seconds, 60)
-        hours, minutes = divmod(minutes, 60)
-
-        return "%2d:%02d:%02d" % (hours, minutes, seconds)
-
-    def render_status (self, pubtkt) :
-        valid = pubtkt.valid()
-        grace = pubtkt.grace()
-
-        if grace :
-            return 'warning'
-        elif valid :
-            return 'success'
-        else :
-            return 'danger'
-
-    def render_pubtkt_valid (self, pubtkt) :
-        """
-            Yield HTML for ticket validity.
-        """
-
-
-        lifetime = self.app.login_valid
-        valid = pubtkt.valid()
-        grace = pubtkt.grace()
-        grace_period = pubtkt.grace_period()
-        remaining = pubtkt.remaining()
-
-        if valid :
-            progress = float(valid.seconds) / float(lifetime.seconds)
-        else :
-            progress = None
-
-        if grace :
-            title = "Remaining renewal period"
-            label = "{grace} (Renew)".format(grace=self.render_valid(grace))
-            status = 'warning'
-        elif valid :
-            title = "Remaining validity period"
-            label = "{valid}".format(valid=self.render_valid(valid))
-            status = 'success'
-        else :
-            title = "Expired"
-            label = "Expired"
-            status = 'danger'
-        
-        if progress :
-            return html.div(class_='panel-body', title=title)(
-                html.span(class_='glyphicon glyphicon-time'),
-                html.div(class_='progress pubtkt-progress',
-                    data_start=valid.seconds,
-                    data_refresh=grace_period.seconds if remaining else None,
-                    data_end=lifetime.seconds,
-                )(
-                    html.div(class_='progress-bar progress-bar-{status}'.format(status=status),
-                        role='progressbar',
-                        style='width: {pp:.0f}%'.format(pp=progress*100),
-                    )(
-                        html.span(class_='pubtkt-progress-label')(label)
-                    )
-                )
-            )
-        else :
-            return None # html.p(label)
-
-    def render_pubtkt_fields (self, pubtkt) :
-        """
-            Yield (glyphicon, text) to render as fields for ticket.
-        """
-
-        if pubtkt.cip :
-            yield 'cloud', None, "Network address", pubtkt.cip
-
-        if pubtkt.udata :
-            yield 'comment', None, "User data", pubtkt.udata
-
-        for token in pubtkt.tokens :
-            yield 'flag', None, "Access token", token
-
-        if pubtkt.bauth :
-            yield 'keys', None, "Authentication token", pubtkt.bauth
-
-    def render_pubtkt (self, pubtkt) :
-        status = self.render_status(pubtkt)
-        domain = self.app.login_domain
-
-        return html.div(class_='pubtkt panel panel-{status}'.format(status=status))(
-            html.div(class_='panel-heading')(
-                html.span(class_='glyphicon glyphicon-user'),
-                html.strong(pubtkt.uid),
-                html.span("@", domain),
-            ),
-            self.render_pubtkt_valid(pubtkt),
-            html.ul(class_='list-group')(
-                html.li(class_='list-group-item {status}'.format(status=('alert-'+status if status else '')), title=title)(
-                    html.span(class_='glyphicon glyphicon-{glyphicon}'.format(glyphicon=icon)) if icon else None,
-                    data,
-                ) for icon, status, title, data in self.render_pubtkt_fields(pubtkt)
-            ),
-            html.div(class_='panel-footer')(
-                #html.div(class_='btn-toolbar', role='toolbar')(
-                    (
-                        html.form(action=self.url(Login), method='post', class_='form-inline')(
-                            html.button(type='submit', class_='btn btn-success')(
-                                html.span(class_='glyphicon glyphicon-time'), "Renew"
-                            )
-                        )
-                    ) if pubtkt.valid() else (
-                        html.form(action=self.url(Login), method='get', class_='form-inline')(
-                            html.button(type='submit', class_='btn btn-info')(
-                                html.span(class_='glyphicon glyphicon-log-in'), "Login"
-                            )
-                        ),
-                    ),
-
-                    html.form(action=self.url(Logout), method='post', class_='form-inline pull-right')(
-                        html.button(type='submit', class_='btn btn-warning')(
-                            html.span(class_='glyphicon glyphicon-log-out'), "Logout"
-                        )
-                    ),
-                #),
-            ),
-        )
-
-    def render (self) :
-        return html.div(class_='container')(
-                self.render_alerts(),
-                self.render_pubtkt(self.pubtkt) if self.pubtkt else None,
-        )
-
-class Login (Handler) :
-    TITLE = "Login"
-    
-    STYLE = Handler.STYLE + """
-form#login {
-    max-width:  50%;
-    padding:    1em;
-    margin:     0 auto;
-}
-
-    """
-    
-    login_failure = None
-
-    def process (self) :
-        self.process_cookie()
-        
-        try :
-            self.process_back()
-        except pubtkt.Error as ex :
-            self.alert('danger', ex)
-
-        if self.pubtkt :
-            self.username = self.pubtkt.uid
-        else :
-            self.username = None
-            
-        # update cookie?
-        set_pubtkt = None
-
-        if self.request.method == 'POST' :
-            username = self.request.form.get('username')
-            password = self.request.form.get('password')
-                
-            if username :
-                # preprocess
-                username = username.strip().lower()
-
-            if username and password :
-                self.username = username
-                
-                try :
-                    set_pubtkt = self.app.auth(username, password)
-
-                except pvl.login.auth.AuthError as ex :
-                    self.alert('danger', "Internal authentication error, try again later?")
-
-                else :
-                    if not set_pubtkt :
-                        self.alert('danger', "Invalid authentication credentials, try again.")
-            
-            elif self.pubtkt and self.pubtkt.valid() :
-                # renew manually if valid
-                set_pubtkt = self.app.renew(self.pubtkt)
-            
-            # a POST request that does not modify state is a failure
-            if not set_pubtkt :
-                self.login_failure = True
-
-        elif 'renew' in self.request.args :
-            # renew automatically if in grace period
-            if self.pubtkt and self.pubtkt.grace() :
-                set_pubtkt = self.app.renew(self.pubtkt)
-            
-        if set_pubtkt :
-            signed = self.app.sign(set_pubtkt)
-            
-            self.pubtkt = set_pubtkt
-            
-            # browsers and mod_pubtkt seem to be very particular about quoting ;'s in cookie values...
-            # this follows PHP's setcookie() encoding, without any quoting of the value..
-            cookie = '{cookie}={value}; Domain={domain}; Secure; HttpOnly'.format(
-                    cookie  = self.app.cookie_name,
-                    value   = werkzeug.urls.url_quote(signed),
-                    domain  = self.app.cookie_domain,
-            )
-
-            # redirect with cookie
-            response = pvl.web.response.redirect(self.back)
-            response.headers.add('Set-Cookie', cookie)
-
-            return response
-
-    def status (self) :
-        if self.login_failure :
-            return 400
-        else :
-            return 200
-
-    def render (self) :
-        domain = self.app.login_domain
-
-        if 'logout' in self.request.args :
-            self.alert('info', "You have been logged out.", icon='log-out')
-
-        if self.pubtkt and self.pubtkt.valid() :
-            renew = True
-
-            # within validity period...
-            self.alert('info', "Login or renew ticket.", icon='log-in')
-
-        else :
-            renew = False
-
-        return html.div(class_='container')(
-            html.form(action=self.url(back=self.back), method='POST', id='login')(
-                self.render_alerts(),
-
-                html.fieldset(
-                    html.legend(
-                        (
-                            "Login @ ",
-                            html.a(href=self.back)(self.server),
-                        ) if self.server else (
-                            "Login"
-                        )
-                   ),
-                
-                    html.div(class_='form-group')(
-                        html.div(class_='input-group')(
-                            html.label(for_='username', class_='sr-only')("Username"),
-                            html.input(name='username', type='text', class_='form-control', placeholder="username", required=True, autofocus=(not self.username), value=self.username),
-                            html.span(class_='input-group-addon')("@{domain}".format(domain=domain)),
-                        ),
-
-                        html.label(for_='password', class_='sr-only')("Password"),
-                        html.input(name='password', type='password', class_='form-control', placeholder="Password", required=(not renew), autofocus=bool(self.username)),
-                    ),
-
-                    html.button(type='submit', class_='btn btn-primary')(
-                        html.span(class_='glyphicon glyphicon-log-in'), "Login"
-                    ),
-
-                    html.button(type='submit', class_='btn btn-success')(
-                        html.span(class_='glyphicon glyphicon-time'), "Renew"
-                    ) if renew else None,
-                )
-            )
-        )
-
-class Logout (Handler) :
-    TITLE = "Logout"
-
-    def process (self) :
-        self.process_cookie()
- 
-        if not self.pubtkt :
-            return self.redirect(Login)
-
-        if self.request.method == 'POST' :
-            response = pvl.web.response.redirect(self.url(Login, logout=1))
-
-            response.set_cookie(self.app.cookie_name, '',
-                    expires = 0,
-                    domain      = self.app.cookie_domain,
-                    secure      = self.app.cookie_secure,
-                    httponly    = self.app.cookie_httponly,
-            )
-            
-            return response
-    
-    def render (self) :
-        return html.div(class_='container')(
-            html.form(action=self.url(), method='post')(
-                self.render_alerts(),
-
-                html.fieldset(
-                    html.legend("Logout {pubtkt.uid}".format(pubtkt=self.pubtkt)),
-            
-                    html.button(type='submit', class_='btn btn-warning')(
-                        html.span(class_='glyphicon glyphicon-log-out'), "Logout"
-                    ),
-                )
-            )
-        )
-
-class SSL (Handler) :
-    TITLE = "SSL"
-
-    OUT = 'tmp/spkac'
-
-    def render_cert (self) :
-        return html.div(class_='container')(
-            self.render_alerts(),
-            html.div(class_='alert alert-success')(
-                "Your new SSL client cert has been signed, and should shortly be installed within your browser."
-            )
-        )
-
-    def respond_cert (self, cert) :
-        """
-            Generate a response for a signed cert, showing the user an informational page, and redirecting to the cert itself..
-        """
-
-        location = self.url(SSL, cert=cert)
-
-        return pvl.web.Response(
-                self.render_html(
-                    body        = self.render_cert(),
-                    extrahead   = html.meta(http_equiv='refresh', content='0;{location}'.format(location=location)),
-                ),
-                status      = 200,
-                #headers     = {
-                #    'Location': location
-                #},
-                mimetype    = 'text/html',
-        )
-
-    def process_spkac (self, spkac) :
-        log.info("SPKAC: %s", spkac)
-        
-        try :
-            cert = self.app.ssl_sign(self.pubtkt, spkac)
-        except pvl.login.ssl.Error as ex :
-            self.alert('danger', ex)
-            return
-        
-        log.info("Redirecting to client cert: %s", cert)
-        return self.respond_cert(cert)
-
-    def process_cert (self, cert) :
-        """
-            Return user cert as download.
-
-            Uses the application/x-x509-user-cert mimetype per
-                https://developer.mozilla.org/en-US/docs/NSS_Certificate_Download_Specification
-        """
-        
-        try :
-            file = self.app.ssl_open(self.pubtkt, cert)
-        except pvl.login.ssl.Error as ex :
-            self.alert('danger', ex)
-            return
-        
-        log.info("Returning client cert: %s", file)
-
-        return pvl.web.Response(self.response_file(file), mimetype='application/x-x509-user-cert')
-
-    def process (self, cert=None) :
-        if not self.process_cookie() :
-            return self.redirect(Login, back=self.url())
-
-        self.sslcert_dn = self.request.headers.get('X-Forwarded-SSL-DN')
-
-        if cert :
-            return self.process_cert(cert)
-
-        if self.request.method == 'POST' :
-            spkac = self.request.form.get('spkac')
-
-            if spkac:
-                return self.process_spkac(spkac)
-    
-    def render (self) :
-        if self.sslcert_dn :
-            self.alert('info', "You are currently using a client SSL cert: {self.sslcert_dn}".format(self=self))
-
-        return html.div(class_='container')(
-            html.form(action=self.url(), method='post')(
-                self.render_alerts(),
-                html.fieldset(
-                    html.legend("SSL Login"),
-
-                    html.keygen(name='spkac', challenge='foo', keytype='RSA'),
-
-                    html.button(type='submit', class_='btn')(
-                        "Generate Certificate"
-                    ),
-                )
-            )
-        )
-
-class LoginApplication (pvl.web.Application) :
-    URLS = urls.Map((
-        urls.rule('/',              Index),
-        urls.rule('/login',         Login),
-        urls.rule('/logout',        Logout),
-
-        # proto
-        urls.rule('/ssl',           SSL),
-        urls.rule('/ssl/<cert>',    SSL),
-    ))
-
-    PUBLIC_KEY = 'etc/login/public.pem'
-    PRIVATE_KEY = 'etc/login/private.pem'
-    
-    login_domain = 'test.paivola.fi'
-    login_server = 'login.test.paivola.fi'
-    login_valid = datetime.timedelta(minutes=60)
-    login_grace = datetime.timedelta(minutes=15)
-    login_scheme = 'https'
-
-    cookie_name = 'auth_pubtkt'
-    cookie_domain = 'test.paivola.fi'
-    cookie_secure = True
-    cookie_httponly = True
-
-    def __init__ (self, auth, ssl=None, public_key=PUBLIC_KEY, private_key=PRIVATE_KEY, **opts) :
-        super(LoginApplication, self).__init__(**opts)
-        
-        self._auth = auth
-        self._ssl = ssl
-        self.server_keys = pubtkt.ServerKeys.config(
-                public_key  = public_key,
-                private_key = private_key,
-        )
-
-    def check_server (self, server) :
-        """
-            Check that the given target server is valid.
-        """
-
-        server = server.lower()
-
-        if server == self.login_domain or server.endswith('.' + self.login_domain) :
-            return server
-        else :
-            raise pubtkt.ServerError("Target server is not covered by our authentication domain: {domain}".format(domain=self.login_domain))
-
-    def load (self, cookie) :
-        """
-            Load a pubtkt from a cookie, and verify it.
-        """
-
-        return pubtkt.PubTkt.load(cookie, self.server_keys.public)
-
-    def auth (self, username, password) :
-        """
-            Perform authentication, returning a PubTkt (unsiigned) or None.
-
-            Raises auth.AuthError.
-        """
-
-        auth = self._auth.auth(username, password)
-        
-        if not auth :
-            return None
-
-        return pubtkt.PubTkt.new(username,
-                valid   = self.login_valid,
-                grace   = self.login_grace,
-                tokens  = list(self._auth.access(auth)),
-                udata   = self._auth.userdata(auth),
-        )
-
-    def sign (self, pubtkt) :
-        """
-            Create a cookie by signing the given pubtkt.
-        """
-        
-        return pubtkt.sign(self.server_keys.private)
- 
-    def renew (self, pubtkt) :
-        """
-            Renew and re-sign the given pubtkt.
-        """
-
-        auth = self._auth.renew(pubtkt.uid)
-
-        if not auth :
-            raise pubtkt.RenewError("Unable to re-authenticate")
-    
-        return pubtkt.update(
-                valid   = self.login_valid,
-                grace   = self.login_grace,
-                tokens  = list(self._auth.access(auth)),
-                udata   = self._auth.userdata(auth),
-        )
-
-    def ssl_sign (self, pubtkt, spkac) :
-        """
-            Generate a SSL client cert for the given user.
-
-            Returns the redirect token for downloading it.
-            
-            Raises pvl.login.ssl.Error
-        """
-
-        if not self._ssl :
-            raise pvl.login.ssl.Error("No ssl CA available for signing")
-
-        return self._ssl.sign_user(pubtkt.uid, spkac,
-                userinfo    = pubtkt.udata,
-        )
-    
-    def ssl_open (self, pubtkt, cert) :
-        """
-            Open and return an SSL cert file.
-
-            Raises pvl.login.ssl.Error
-        """
-
-        return self._ssl.open_cert(pubtkt.uid, cert)
--- a/pvl/login/ssl.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,149 +0,0 @@
-# encoding: utf-8
-
-import base64
-import datetime
-import hashlib
-import os
-import os.path
-import string
-
-import pvl.invoke
-
-import logging; log = logging.getLogger('pvl.login.ssl')
-
-class Error (Exception) :
-    pass
-
-class UsersCA (object) :
-    OPENSSL = '/usr/bin/openssl'
-
-    SIGN_DAYS = 1
-
-    VALID_USER = set(string.letters + string.digits + '-.')
-    
-    O = u"Päivölän Kansanopisto"
-    OU = u"People"
-    DC = ('paivola', 'fi')
-
-    def __init__ (self, ca, users) :
-        self.ca = ca
-        self.users = users
-
-        self.ca_config = os.path.join(ca, 'openssl.cnf')
-
-    def sign_spkac (self, out, spkac, days=SIGN_DAYS) :
-        """
-            Sign given request file (path).
-
-            Creates the given output file (path). Empty file on errors..
-        """
-
-        pvl.invoke.invoke(self.OPENSSL, ('ca',
-                '-config', self.ca_config,
-                '-spkac', spkac,
-                '-out', out,
-                '-policy', 'policy_user',
-                '-days', str(days),
-                '-utf8',
-            ),
-            setenv={
-                'CA':   self.ca,
-            },
-        )
-
-    def generate_dn (self, uid, cn=None) :
-        """
-            Generate OpenSSL (rdn, value) pairs for given user.
-        """
-
-        if self.O :
-            yield 'O', self.O
-
-        elif self.DC :
-            for index, dc in enumerate(self.DC, 1) :
-                yield '{index}.DC'.format(index=index), dc
-        
-        yield 'OU', self.OU
-         
-        yield 'UID', uid 
-        
-        if cn :
-            yield 'CN', cn
-
-    def write_spkac (self, path, spkac, dn) :
-        """
-            Write out a spkac file to the given path, containing the given base64-encoded spkac and DN.
-        """
-
-        # roundtrip the spkac for consistent formatting
-        spkac = base64.b64encode(base64.b64decode(spkac))
-
-        file = open(path, 'w')
-
-        file.write('SPKAC=')
-        file.write(spkac)
-        file.write('\n')
-
-        for rdn, value in dn :
-            file.write(u'{rdn}={value}\n'.format(rdn=rdn, value=value).encode('utf-8'))
-        
-        file.close()
-
-    def sign_user (self, user, spkac, userinfo=None) :
-        """
-            Sign given spkac string (base64-encoded) for given user.
-
-            Returns a name for the signed cert.
-        """
-
-        if not set(user).issubset(self.VALID_USER) :
-            raise Error("Invalid username: {user}".format(user=user))
-
-        dir = os.path.join(self.users, user)
-
-        if not os.path.exists(dir) :
-            os.mkdir(dir)
-
-        name = hashlib.sha1(user + spkac).hexdigest()
-        spkac_file = os.path.join(dir, name) + '.spkac'
-        cert_file = os.path.join(dir, name)
-        tmp_file = os.path.join(dir, name) + '.tmp'
-        
-        # the req to sign
-        if os.path.exists(spkac_file) :
-            log.warning("spkac already exists: %s", spkac_file)
-        else :
-            log.info("%s: write spkac: %s", user, spkac_file)
-            self.write_spkac(os.path.join(dir, name) + '.spkac', spkac, self.generate_dn(user, userinfo))
-        
-        # sign it
-        if os.path.exists(cert_file) :
-            log.warning("cert already exists: %s", cert_file)
-            return name
-        
-        if os.path.exists(tmp_file) :
-            log.warning("cleaning out previous tmp file: %s", tmp_file)
-            os.unlink(tmp_file)
-
-        log.info("%s: sign cert: %s", user, cert_file)
-        self.sign_spkac(tmp_file, spkac_file)
-
-        log.debug("%s: rename %s -> %s", user, tmp_file, cert_file)
-        os.rename(tmp_file, cert_file)
-
-        return name
-
-    def open_cert (self, user, name) :
-        """
-            Return an opened cert file by username / cert name.
-        """
-
-        if not set(user).issubset(self.VALID_USER) :
-            raise Error("Invalid username: {user}".format(user=user))
-
-        path = os.path.join(self.users, user, name)
-
-        if not os.path.exists(path) :
-            raise Error("No cert found on server")
-
-        return open(path)
--- a/pvl/login/static/pubtkt-expire.js	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-function pad2digits (i) {
-    if (i < 10) {
-        return "0" + i;
-    } else {
-        return "" + i;
-    }
-}
-
-function seconds2interval (s) {
-    var h, m;
-
-    s = Math.floor(s);
-    
-    m = Math.floor(s / 60);
-    s = s % 60;
-
-    h = Math.floor(m / 60);
-    m = m % 60;
-
-    return h + ":" + pad2digits(m) + ":" + pad2digits(s);
-}
-
-$(function () {
-    $('.pubtkt-progress').each(function () {
-        var item = $(this);
-
-        var progress_bar = item.find('.progress-bar');
-        var progress_label = item.find('.pubtkt-progress-label');
-
-        var progress_start = item.attr('data-start');
-        var progress_refresh = item.attr('data-refresh');
-        var progress_end = item.attr('data-end');
-        
-        var start_time = Date.now();
-
-        var reload = false;
-
-        function update_progress () {
-            var duration = (Date.now() - start_time) / 1000;
-
-            var progress = progress_start - duration;
-            
-            if (reload) {
-                // delayed reload
-                window.location.reload(true);
-            }
-            
-            if (progress <= 0 || (progress_refresh && progress < progress_refresh)) {
-                // done
-                reload = true;
-            } else {
-                // update
-                progress_bar.css('width', (progress * 100 / progress_end) + '%');
-
-                progress_label.html(seconds2interval(progress));
-            }
-        }
-        
-        window.setInterval(update_progress, 1000);
-    });
-});
-
--- a/pvl/rrd/__init__.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-"""
-    RRD Graphing
-
-    Requires:
-        python-rrdtool
-"""
-from pvl.rrd.rrds import (
-    RRDDatabase,
-    RRDCache,
-)
-
-from pvl.rrd import (
-    api as rrd,
-    graph,
-)
--- a/pvl/rrd/api.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,128 +0,0 @@
-import rrdtool
-
-import pvl.invoke
-import tempfile
-
-import logging; log = logging.getLogger('pvl.rrd.api')
-
-"""
-    Wrapper around the rrdtool python interface
-"""
-
-def timestamp (time=None) :
-    """
-        Format datetime value for rrdtool.
-    """
-
-    if not time :
-        return None
-
-    elif isinstance(time, datetime.datetime) :
-        return int(time.mktime(dt.timetuple()))
-
-    elif isinstance(time, datetime.timedelta) :
-        raise NotImplementedError("pvl.rrd.api.timestamp: timedelta")
-
-    else :
-        # dunno
-        return str(dt)
-
-def cmd (func, pre, opts, post) :
-    """
-        Run the given rrdtool.* function, formatting the given positional arguments and options.
-
-        Returns the return value, which varies...
-    """
-    
-    log.debug("%s: %s: %s: %s", func, pre, opts, post)
-    
-    # { opt: arg } -> [ '--opt', arg ]
-    opts = pvl.invoke.optargs(**opts)
-
-    # positional arguments
-    pre = pvl.invoke.optargs(*pre)
-    post = pvl.invoke.optargs(*post)
-
-    return func(*(pre + opts + post))
-
-def graph (out=None, *defs, **opts) :
-    """
-        Render a graph image and/or print a report from data stored in one or several RRDs.
-        
-            out     - None  -> tempfile
-                    - False -> stdout
-                    - path  -> write to file
-
-        Returns:
-            (width, height)         - pixel dimensions of the resulting graph image
-            report_output           - any PRINT'd output (?)
-            graph_file              - file-like object containing graph image, unless out=False -> stdout
-
-        With out=None, the returned graph_file is a tempfile which will be cleaned up by Python once close()'d!
-
-        XXX: tempfile suffix as .png?
-    """
-
-    if out is None :
-        # tempfile
-        out_file = tempfile.NamedTemporaryFile(suffix='.png', delete=True) # python2.6
-        out_path = out_file.name
-
-    elif out is False :
-        out_file = None
-        out_path = '-'
-
-    else :
-        # for reading
-        out_path = out
-        out_file = True # open later
-    
-    log.debug("%s", out_path)
-
-    # XXX: handle tempfile close?
-    width, height, out_lines = cmd(rrdtool.graph, (out_path, ), opts, defs)
-
-    if out_file is True :
-        out_file = open(out_path)
-            
-    return (width, height), out_lines, out_file
-
-def report (*defs, **opts) :
-    """
-        Render an output-less graph with only PRINT statements as a textual report.
-
-        Returns output lines.
-    """
-    
-    # exec
-    width, height, out_lines = cmd(rrdtool.graph, ('/dev/null', ), opts, defs)
-
-    return out_lines
-
-def fetch (rrd, cf, **opts) :
-    """
-        Fetch values from RRD.
-
-        Returns
-            (start, end, step)          - xrange(...) for row timestamps
-            (ds, ...)                   - columns (ds name)
-            ((value, ...), ...)         - rows (data by ds)
-    """
-
-    return cmd(rrdtool.fetch, (rrd, cf), opts, ())
-
-def _fetch (rrd, cf='AVERAGE', resolution=None, start=None, end=None, **opts) :
-    """
-        Yields (timestamp, { ds: value }) for given ds-values, or all.
-    """
-
-    steps, sources, rows = fetch(rrd, cf,
-        resolution  = resolution,
-        start       = timestamp(start),
-        end         = timestamp(end),
-        **opts
-    )
-
-    for ts, row in zip(xrange(*steps), rows) :
-        yield datetime.fromtimestamp(ts), dict(zip(sources, row))
-
--- a/pvl/rrd/args.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-import optparse
-
-import pvl.rrd.graph # interface
-from pvl.rrd import RRDDatabase, RRDCache
-
-import logging; log = logging.getLogger('pvl.rrd.args')
-import sys
-
-def parser (parser) :
-    """
-        optparse OptionGroup.
-    """
-
-    parser = optparse.OptionGroup(parser, "RRD options")
-
-    parser.add_option('--rrd-type', metavar='TYPE', default='collectd',
-        help="mrtg/collectd")
-
-    parser.add_option('--rrd', metavar='PATH',
-        help="Find RRD files")
-
-    parser.add_option('--rrd-cache', metavar='PATH',
-        help="Cache RRD graphs")
-
-    return parser
-
-def apply (options) :
-    """
-        Return RRDDatabase from options.
-    """
-    # path
-    if not options.rrd :
-        log.error("no --rrd given")
-        sys.exit(2)
-    
-    # type
-    graph_type = pvl.rrd.graph.interface_type(options.rrd_type)
-    
-    # cache
-    if options.rrd_cache :
-        cache = RRDCache(options.rrd_cache)
-    else :
-        cache = None
-
-    return RRDDatabase(options.rrd, graph_type, cache)
-
--- a/pvl/rrd/graph.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,299 +0,0 @@
-from pvl.rrd import api as rrd
-import time
-
-from pvl.invoke import merge # XXX
-
-import logging; log = logging.getLogger('pvl.rrd.graph')
-
-"""
-    RRDTool graph builders.
-
-    Includes support for Smokeping-inspired interface graphs from MRTG/Collectd.
-"""
-
-def timestamp () :
-    return time.strftime("%Y/%m/%d %H:%M:%S %Z")
-
-class Graph (object) :
-    """
-        Render an RRD graph from definitions/options.
-        
-        Acts as a semi-magical object, with immutable state, and methods that return updated copies of our state.
-
-        >>> Graph()
-        Graph()
-        >>> Graph('foo')
-        Graph('foo')
-        >>> Graph(bar='bar')
-        Graph(bar='bar')
-        >>> Graph('foo', bar='bar')
-        Graph('foo', bar='bar')
-        >>> Graph('foo')(bar='bar')
-        Graph('foo', bar='bar')
-    """
-
-    def __init__ (self, *defs, **opts) :
-        self.defs = defs
-        self.opts = opts
-
-    def __call__ (self, *defs, **opts) :
-        return type(self)(*(self.defs + defs), **merge(self.opts, opts))
-
-    def __getitem__ (self, key) :
-        return self.opts[key]
-
-    def graph (self, out) :
-        """
-            Render completed graph using pvl.rrd.api.graph()
-        """
-
-        return rrd.graph(out, *self.defs, **self.opts)
-    
-    def __repr__ (self) :
-        return "{type}({args})".format(
-                type    = self.__class__.__name__,
-                args    = ', '.join([repr(def_) for def_ in self.defs] + [str(opt) + '=' + repr(value) for opt, value in self.opts.iteritems()]),
-        )
-
-class Interface (Graph) :
-    """
-        An RRD graph showing in/out traffic in bits/s on an interface, with style/interval support.
-    """
-
-    @classmethod
-    def build (cls, title, rrd, style, interval) :
-        """
-            Return a simple Graph using the given title, RRD and style/interval
-
-                title       - common(title=...)
-                rrd         - data(rrd=...)
-                style       - style(style=...)
-                interval    - interval(interval=...)
-        """
-
-        return cls().common(title).source(rrd).style(style).interval(interval)
-
-    # builders
-    def common (self, title) :
-        """
-            Common options for all views
-        """
-
-        return self(
-            # output
-            imgformat           = "PNG",
-            #        lazy                = True,
-
-            color               = [
-                # disable border
-                # border            = 0,
-                "SHADEA#ffffff00",
-                "SHADEB#ffffff00",
-
-                # keep background transparent
-                "BACK#ffffff00",
-                "SHADEB#ffffff00",
-            ],
-             
-            # labels
-            vertical_label      = "bits/s",
-            title               = title,
-            units               = "si",
-
-            # use logarithmic scaling?
-            #        logarithmic         = True,
-            
-            # smooth out lines
-            slope_mode          = True,
-        )
-    
-    ## data
-    # DS names; in/out bytes
-    IN = OUT = None
-
-    def source (self, rrd) :
-        """
-            Abstract: rrd -> in/out
-        """
-
-        if not (self.IN and self.OUT) :
-            raise TypeError("No IN/OUT DS names for %s" % (self, ))
-
-        log.debug("%s", rrd)
-
-        return self(
-            # data sources, bytes/s
-            r'DEF:in0={rrd}:{ds_in}:AVERAGE'.format(rrd=rrd, ds_in=self.IN),
-            r'DEF:out0={rrd}:{ds_out}:AVERAGE'.format(rrd=rrd, ds_out=self.OUT),
-
-            # data, bits/s
-            'CDEF:in=in0,8,*',
-            'CDEF:out=out0,8,*',
-        )
-    
-    ## style
-    def style_overview (graph) :
-        """
-            in/out bps -> overview graph
-        """
-
-        return graph(
-            "CDEF:all=in,out,+",
-
-            "VDEF:max=all,MAXIMUM",
-            "VDEF:avg=all,AVERAGE",
-            "VDEF:min=all,MINIMUM",
-
-            "LINE1:in#0000FF:In",
-            "LINE1:out#00CC00:Out",
-
-            "GPRINT:max:%6.2lf %Sbps max",
-            "GPRINT:avg:%6.2lf %Sbps avg",
-            "GPRINT:min:%6.2lf %Sbps min\\l",
-
-            # dimensions
-            width               = 600,
-            height              = 50,
-        )
-
-    def style_detail (graph) :
-        """
-            in/out bps -> detail graph
-        """
-
-        return graph(
-            # values
-            'VDEF:in_max=in,MAXIMUM',
-            'VDEF:in_avg=in,AVERAGE',
-            'VDEF:in_min=in,MINIMUM',
-            'VDEF:in_cur=in,LAST',
-            'VDEF:out_max=out,MAXIMUM',
-            'VDEF:out_avg=out,AVERAGE',
-            'VDEF:out_min=out,MINIMUM',
-            'VDEF:out_cur=out,LAST',
-
-            # legend/graph
-            "COMMENT:%4s" % "",
-            "COMMENT:%11s" % "Maximum",
-            "COMMENT:%11s" % "Average",
-            "COMMENT:%11s" % "Minimum",
-            "COMMENT:%11s\\l" % "Current",
-
-            "LINE1:in#0000FF:%4s" % "In",
-            'GPRINT:in_max:%6.2lf %Sbps',
-            'GPRINT:in_avg:%6.2lf %Sbps',
-            'GPRINT:in_min:%6.2lf %Sbps',
-            'GPRINT:in_cur:%6.2lf %Sbps\\l',
-
-            "LINE1:out#00CC00:%4s" % "Out",
-            'GPRINT:out_max:%6.2lf %Sbps',
-            'GPRINT:out_avg:%6.2lf %Sbps',
-            'GPRINT:out_min:%6.2lf %Sbps',
-            'GPRINT:out_cur:%6.2lf %Sbps\\l',
-            
-            # mark
-            "COMMENT:Generated {now}\\r".format(now=timestamp().replace(':', '\\:')),
-
-            # dimensions
-            width               = 600,
-            height              = 200,
-        )
-
-    STYLE = {
-        'overview': style_overview,
-        'detail':   style_detail,
-    }
-
-    def style (self, style) :
-        return self.STYLE[style](self)
-    
-    ## interval
-    def interval_daily (graph) :
-        """
-            Common options for the 'daily' view
-        """
-
-        return graph(
-            # labels
-            x_grid              = "MINUTE:15:HOUR:1:HOUR:4:0:%H:%M",
-        
-            # general info
-            title               = "Daily {graph[title]}".format(graph=graph),
-        
-            # interval
-            start               = "-24h",
-        )
-
-    def interval_weekly (graph) :
-        return graph(
-            # labels
-            #        x_grid              = "MINUTE:15:HOUR:1:HOUR:4:0:%H:%M",
-        
-            # general info
-            title               = "Weekly {graph[title]}".format(graph=graph),
-        
-            # interval
-            start               = "-7d",
-        )
-
-    def interval_yearly (graph) :
-        return graph(
-            # labels
-            #        x_grid              = "MINUTE:15:HOUR:1:HOUR:4:0:%H:%M",
-        
-            # general info
-            title               = "Yearly {graph[title]}".format(graph=graph),
-        
-            # interval
-            start               = "-1y",
-        )
-
-    INTERVAL = {
-        'daily':    interval_daily,
-        'weekly':   interval_weekly,
-        'yearly':   interval_yearly,
-    }
-
-    def interval (self, interval) :
-        return self.INTERVAL[interval](self)
-
-# specific types
-class Mrtg (Interface) :
-    """
-        MRTG -> in/out
-    """
-
-    IN = 'ds0'
-    OUT = 'ds1'
-
-class CollectdIfOctets (Interface) :
-    """
-        Collectd if_octets -> in/out
-    """
-
-    IN = 'rx'
-    OUT = 'tx'
-
-INTERFACE = {
-    'mrtg':         Mrtg,
-    'collectd':     CollectdIfOctets,
-}
-
-def interface_type (type) :
-    """
-        Lookup Interface -subclass for given type.
-    """
-
-    return INTERFACE[type]
-
-# XXX: legacy
-def mrtg (style, interval, title, rrd, out) :
-    return MrtgInterface.build(title, rrd, style, interval).graph(out)
-
-def collectd_ifoctets (style, interval, title, rrd, out) :
-    return CollectdIfOctets.build(title, rrd, style, interval).graph(out)
-
-if __name__ == '__main__':
-    import doctest
-    doctest.testmod()
-
--- a/pvl/rrd/hosts.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,121 +0,0 @@
-"""
-    Handle host-interface mappings for stats.
-"""
-
-import shlex
-import os.path
-
-import logging; log = logging.getLogger('pvl.rrd.hosts')
-
-def hostjoin (*hosts) :
-    """
-        DNS hostname join.
-    """
-
-    return '.'.join(hosts)
-
-def hostreverse (host) :
-    """
-        Reverse hostname.
-    """
-    
-    return '.'.join(reversed(host.split('.')))
-
-def load (file, domain=None) :
-    """
-        Parse hosts from file, yielding
-            (host, node, iface, tag)
-            
-            file            - read host/ifaces from file
-            domain          - append given domain to hostname
-    """
-    
-    host = None
-
-    for idx, line in enumerate(file, 1) :
-        line = line.rstrip()
-
-        if not line :
-            continue
-        
-        # comment?
-        if line.startswith('#') :
-            continue
-        
-        # line
-        parts = shlex.split(line)
-
-        if not line[0].isspace() :
-            host = parts.pop(0)
-        
-            # host-spec?
-            if '=' in host :
-                host, node = host.split('=')
-            else :
-                node = host
-
-            if '@' in host :
-                host, host_domain = host.split('@')
-            else:
-                host_domain = domain
-
-            if '@' in node :
-                node, node_domain = node.split('@')
-            else:
-                node_domain = domain
-            
-            # host has domain in collectd?
-            if host_domain :
-                host = hostjoin(host, host_domain)
-
-        if not parts :
-            # keep host for following lines
-            continue
-
-        instance = parts.pop(0)
- 
-        # possibly multiple tags..
-        for tag in parts :
-            yield host, instance, (node_domain, node, tag)
-
-def map_interfaces (options, file):
-    """
-        Read (hostname, instance}: (nodename, tag) pairs from file.
-    """
-
-    for host, instance, node_info in load(file) :
-        log.debug("%s/%s -> %s", host, instance, node_info)
-        yield (host, instance), node_info
-
-def collectd_interfaces (options, file, collectd_domain, collectd_plugin) :
-    """
-        Read collectd (host, instance, (domain, node, tag)) items, and yield (collectd-rrd, node_info) tuples.
-            
-            file                - read host/ports from file
-            collectd_domain     - append given domain to collectd hostname
-            collectd_plugin     - use given collectd plugin's type-instances
-    """
-
-    log.info("%s/${host}.%s/%s/%s-${instance}.rrd", options.collectd_rrd, collectd_domain, collectd_plugin, options.collectd_type)
-
-    for host, instance, node_info in load(file, domain=collectd_domain) :
-        if options.collectd_instance == 'type' :
-            type = options.collectd_type + '-' + instance
-        else :
-            type = options.collectd_type
-
-        if options.collectd_instance == 'plugin' :
-            plugin = collectd_plugin + '-' + instance
-        else :
-            plugin = collectd_plugin
-
-        rrd = os.path.join(options.collectd_rrd, host, plugin, type) + '.rrd'
-
-        if not os.path.exists(rrd) :
-            log.warn("%s/%s: missing collectd rrd: %s", host, instance, rrd)
-            continue
-
-        # out
-        log.debug("%s: %s", rrd, node_info)
-
-        yield rrd, node_info
--- a/pvl/rrd/rrds.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,239 +0,0 @@
-"""
-    RRD file on filesystem.
-"""
-
-import os, os.path, errno
-
-import logging; log = logging.getLogger('pvl.rrd.rrds')
-
-class RRDDatabase (object) :
-    """
-        A filesystem directory containing .rrd files.
-    """
-
-    def __init__ (self, path, graph_type, cache=None) :
-        """
-            path        - path to rrd dirs
-            graph_type  - pvl.rrd.graph.InterfaceGraph type
-            cache       - optional RRDCache
-        """
-
-        if not path :
-            raise ValueError("RRDDatabase: no path given")
-
-        log.info("%s: graph_type=%s, cache=%s", path, graph_type, cache)
-        
-        self.graph_type = graph_type
-        self._path = path
-        self.cache = cache
-
-    def path (self, *nodes) :
-        """
-            Lookup and full filesystem path to the given relative RRD/dir path.
-
-            Raises ValueError if invalid path.
-        """
-        
-        # relative dir (no leading slash) -> absolute path
-        path = os.path.normpath(os.path.join(self._path, *nodes))
-
-        log.debug("%s: %s -> %s", self, nodes, path)
-
-        # check inside base path
-        if not path.startswith(self._path.rstrip('/')) :
-            # mask
-            raise ValueError("%s: Invalid path: %s" % (self, nodes))
-
-        # ok
-        return path
-
-    def tree (self, node=None) :
-        """
-            Lookup and return XXX:RRDTree for given node, or root tree.
-
-            Raises ValueError if invalid path, or no such tree.
-        """
-        
-        # lookup fs path
-        if node :
-            path = self.path(node)
-        else :
-            path = self.path()
-
-        # found?
-        if not os.path.isdir(path) :
-            raise ValueError("%s: Invalid tree: %s: %s" % (self, node, path))
-        
-        if node :
-            return node
-        else :
-            return '' # equivalent
-
-    def rrd (self, node, tree=None) :
-        """
-            Lookup and return RRD for given node.
-        """
-
-        if tree :
-            node = os.path.join(tree, node)
-        
-        path = self.path(node) + '.rrd'
-
-        if not os.path.isfile(path) :
-            raise ValueError("%: Invalid rrd: %s: %s" % (self, node, path))
-
-        return RRD(self, node, self.graph_type)
-
-    def list (self, tree=None) :
-        """
-            List (trees, rrds) under given tree.
-        """
-
-        dirs = []
-        rrds = []
-
-        path = self.path(tree)
-        
-        for name in os.listdir(path) :
-            if name.startswith('.') :
-                continue
-            
-            path = self.path(tree, name)
-
-            basename, extname = os.path.splitext(name)
-            
-            log.debug("%s: %s: %s: %s", self, tree, name, path)
-
-            if os.path.isdir(path) :
-                dirs.append(name)
-
-            elif extname == '.rrd' :
-                # without the .rrd
-                rrds.append(basename)
-
-        # return sorted lists
-        return sorted(dirs), sorted(rrds)
-    
-    def graph (self, rrd, style, interval, cache=True) :
-        """
-            Cached RRD.graph(), returning outfile.
-
-            Uses self.cache if cache, otherwise graphs to tempfile.
-        """
-        
-        path = rrd.path()
-        
-        if self.cache and cache :
-            # path in cache
-            out = self.cache.path(style, interval, str(rrd))
-
-            # hit/miss?
-            cache = self.cache.lookup(path, out)
-
-        else :
-            # tempfile
-            out = None
-            cache = None
-
-        log.debug("%s: %s: %s", self, rrd, out)
-        
-        if cache :
-            # from cache
-            outfile = open(out)
-
-        else :
-            # to cache/tempfile
-            dimensions, lines, outfile = rrd.graph(style, interval).graph(out)
-
-        return outfile
-
-    def __str__ (self) :
-        return self._path
-
-class RRD (object) :
-    """
-        An .rrd file on the filesystem, graphed using pvl.rrd.graph.Interface.
-    """
-
-    def __init__ (self, db, rrd, graph_type) :
-        self.db = db
-        self.rrd = rrd
-        self.graph_type = graph_type
-
-    def path (self) :
-        return self.db.path(self.rrd) + '.rrd'
-
-    def graph (self, style, interval) :
-        """
-            Build Graph given rrd using given style/interval.
-        """
-        
-        path = self.path()
-
-        title = str(self) # " / ".join(rrd.split('/'))
-        return self.graph_type.build(title, path, style, interval)
-
-    def __str__ (self) :
-        return self.rrd
-
-class RRDCache (object) :
-    """
-        Cache graph output in the filesystem.
-    """
-
-    def __init__ (self, path) :
-        log.info("%s", path)
-
-        self._path = path
-
-    def path (self, *key) :
-        """
-            Return cache path for key.
-        """
-
-        return os.path.join(self._path, *key) + '.png'
-
-    def _stat (self, path) :
-        """
-            os.stat or None.
-        """
-
-        try :
-            return os.stat(path)
-
-        except OSError as ex :
-            if ex.errno == errno.ENOENT :
-                return None
-            else :
-                raise
-
-    def lookup (self, source, path) :
-        """
-            Return hit from cache.
-        """
-
-        # create
-        dir = os.path.dirname(path)
-        if not os.path.isdir(dir) :
-            log.warn("makedirs %s", dir)
-            os.makedirs(dir)
-        
-        # stats's
-        src = self._stat(source)
-        dst = self._stat(path)
-
-        if dst and src.st_mtime < dst.st_mtime :
-            log.debug("%s: %s: %s: hit", self, source, path)
-
-            return True
-
-        elif not dst:
-            log.debug("%s: %s: %s: miss", self, source, path)
-            return False
-        
-        else :
-            log.debug("%s: %s: %s: update", self, source, path)
-            return None
-
-    def __str__ (self) :
-        return self._path
--- a/pvl/verkko/__init__.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-
-from pvl.verkko import db
-from pvl.verkko.db import Database
-
-__version__ = '0.7.3-1'
--- a/pvl/verkko/db.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,167 +0,0 @@
-"""
-    SQLAlchemy Database tables model for pvl.verkko
-"""
-
-import sqlalchemy
-from sqlalchemy import *
-
-import logging; log = logging.getLogger('pvl.verkko.db')
-
-# schema
-metadata = MetaData()
-
-dhcp_hosts = Table('dhcp_hosts', metadata,
-    Column('id',            Integer,    primary_key=True),
-
-    # unique
-    Column('ip',            String,     nullable=False),
-    Column('mac',           String,     nullable=False),
-    Column('gw',            String,     nullable=False),
-    
-    # updated
-    Column('first_seen',    DateTime,   nullable=False),
-    Column('last_seen',     DateTime,   nullable=False),
-    
-    # scalar; updated
-    Column('name',          String,     nullable=True),
-    Column('state',         String,     nullable=True),
-    Column('error',         String,     nullable=True),
-    
-    # counters
-    Column('count',         Integer,    default=1),
-
-    UniqueConstraint('ip', 'mac', 'gw'),
-)
-
-dhcp_leases = Table('dhcp_leases', metadata,
-    Column('id',            Integer,    primary_key=True),
-
-    Column('ip',            String,     nullable=False),
-    Column('mac',           String,     nullable=False),
-    Column('hostname',      String,     nullable=True),
-
-    Column('starts',        DateTime,   nullable=False),
-    Column('ends',          DateTime,   nullable=True),  # never
-    # TODO: renewed
-
-    Column('state',         String,     nullable=True),
-    Column('next',          String,     nullable=True),
-)
-
-wlan_sta = Table('wlan_sta', metadata,
-    Column('id',            Integer,    primary_key=True),
-    
-    Column('ap',            String,     nullable=False),
-    Column('wlan',          String,     nullable=False),
-    Column('sta',           String,     nullable=False),
-    
-    # updated
-    Column('first_seen',    DateTime,   nullable=False),
-    Column('last_seen',     DateTime,   nullable=False),
-
-    Column('count',         Integer,    default=1),
-    Column('msg',           String,     nullable=True),
-           
-    UniqueConstraint('ap', 'wlan', 'sta'),
-)
-
-# for ORM models
-from sqlalchemy.orm import mapper, sessionmaker
-
-Session = sessionmaker()
-
-class Database (object) :
-    """
-        Our underlying database.
-    """
-    
-    def __init__ (self, database) :
-        """
-            database        - sqlalchemy connection URI
-        """
-
-        self.engine = create_engine(database, 
-            echo    = (log.isEnabledFor(logging.DEBUG)),
-        )
-
-    # ORM
-    def session (self) :
-        """
-            Return a new ORM session bound to our engine.
-
-            XXX: session lifetimes? Explicit close?
-        """
-
-        return Session(bind=self.engine)
-   
-    # SQL
-    def connect (self) :
-        return self.engine.connect()
-
-    def execute (self, query) :
-        return self.engine.execute(query)
-
-    def select (self, query) :
-        return self.engine.execute(query)
-
-    def get (self, query) :
-        """
-            Fetch a single row.
-            
-            XXX: returning None if not found
-        """
-
-        return self.select(query).fetchone()
-
-    def insert (self, insert) :
-        """
-            Execute given INSERT query, returning the inserted primary key.
-        """
-
-        result = self.engine.execute(insert)
-
-        id, = result.inserted_primary_key
-        
-        return id
-
-    def update (self, update) :
-        """
-            Execute given UPDATE query, returning the number of matched rows.
-        """
-
-        result = self.engine.execute(update)
-        
-        return result.rowcount
-
-# command-line
-import optparse
-import sys
-
-def parser (parser, table=None) :
-    parser.set_defaults(
-            create_table    = table,
-    )
-    parser = optparse.OptionGroup(parser, "Verkko Database")
-    parser.add_option('--database',             metavar='URI',
-            help="Connect to given database")
-
-    if table is not None :
-        parser.add_option('--create',               action='store_true',
-                help="Initialize database")
-    
-    return parser
-
-def apply (options, required=True) :
-    # db
-    if not options.database and required :
-        log.error("No database given")
-        sys.exit(1)
-
-    log.info("Open up database: %s", options.database)
-    db = Database(options.database)
-
-    if options.create_table is not None and options.create :
-        log.info("Creating database tables: %s", options.create_table)
-        options.create_table.create(db.engine, checkfirst=True)
-
-    return db
--- a/pvl/verkko/dhcp.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,79 +0,0 @@
-# encoding: utf-8
-import pvl.web
-import pvl.verkko.web
-
-from pvl.web import html, urls
-from pvl.verkko import hosts, leases
-
-import logging; log = logging.getLogger('pvl.verkko.dhcp')
-
-class Index (pvl.verkko.web.DatabaseHandler) :
-    TITLE = u"Päivölä Verkko"
-
-    CSS = pvl.verkko.web.DatabaseHandler.CSS + (
-            '/static/dhcp/forms.css',
-    )
-
-    def render_link (self, title, **opts) :
-        return html.a(href=self.url(hosts.ListHandler, **opts))(title)
-
-    def render_links (self, attr, titlevalues) :
-        for title, value in titlevalues :
-            yield html.li(
-                self.render_link(title, **{attr: value})
-            ) 
-
-    def render (self) :
-        return (
-            html.h2("Interval"),
-            html.ul(
-                self.render_links('seen', (
-                            ("Hour",    '1h'),
-                            ("Day",     '1d'),
-                            #("Month",   '30d'),
-                            #("Year",    '365d'),
-                )),
-                html.li(
-                    html.a(href=self.url(hosts.RealtimeHandler))("Realtime"),
-                ),
-            ),
-            html.h2("State"),
-            html.ul(
-                self.render_links('state', (
-                            ("Valid",       ('DHCPACK', 'DHCPRELEASE')),
-                            ("Incomplete",  ('DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST')),
-                            ("Invalid",     ('DHCPNAK', )),
-                )),
-            ),
-
-            html.h2("IP/MAC"),
-            html.form(action=self.url(hosts.ListHandler), method='get')(
-                html.fieldset(
-                    html.ul(
-                        html.li(
-                            html.label(for_='ip')("IP"),
-                            html.input(type='text', name='ip'),
-                        ),
-
-                        html.li(
-                            html.label(for_='mac')("MAC"),
-                            html.input(type='text', name='mac'),
-                        ),
-
-                        html.li(
-                            html.input(type='submit', value="Search"),
-                        ),
-                    )
-                )
-            ),
-        )
-
-class Application (pvl.verkko.web.Application) :
-    URLS = urls.Map((
-        urls.rule('/',                       Index),
-        urls.rule('/hosts/',                 hosts.ListHandler),
-        urls.rule('/hosts/<int:id>',         hosts.ItemHandler),
-        urls.rule('/hosts/realtime',         hosts.RealtimeHandler),
-        urls.rule('/leases/',                leases.ListHandler),
-    ))
-
--- a/pvl/verkko/hosts.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,412 +0,0 @@
-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('&laquo;'), '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('&laquo;'), '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),
-                    )),
-                )
-            )
-        )
-
--- a/pvl/verkko/leases.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,149 +0,0 @@
-from pvl.verkko import web, db, table
-from pvl.verkko.utils import parse_timedelta, format_timedelta
-
-from pvl.web import html
-
-import datetime
-
-import logging; log = logging.getLogger('pvl.verkko.leases')
-
-class DHCPLease (object) :
-    """
-        A DHCP lease with ip/mac and starts/ends
-    """
-
-    DATE_FMT = '%Y%m%d %H:%M'
-    TIME_FMT = '%H:%M:%S'
-
-    @classmethod
-    def format_datetime (cls, dt) :
-        if dt.date() == datetime.date.today() :
-            return dt.strftime(cls.TIME_FMT)
-        else :
-            return dt.strftime(cls.DATE_FMT)
-    
-    def render_starts (self) :
-        return self.format_datetime(self.starts)
-
-    def render_ends (self) :
-        if self.ends :
-            return self.format_datetime(self.ends)
-        else :
-            return None
-    
-    def ends_class (self) :
-        if self.ends and self.ends > datetime.datetime.now() :
-            return 'active'
-        else :
-            return None
-
-    @property
-    def length (self) :
-        if self.ends :
-            return self.ends - self.starts
-        else :
-            return datetime.datetime.now() - self.starts
-
-    def render_length (self) :
-        return format_timedelta(self.length)
-
-db.mapper(DHCPLease, db.dhcp_leases, properties=dict(
-
-))
-
-class LeasesTable (table.Table) :
-    """
-        <table> of leases.
-    """
-
-    ITEMS   = "Leases"
-    COLUMNS = (
-        table.Column('ip',          "IP",       DHCPLease.ip,        
-            rowfilter   = True,
-        ),
-        table.Column('mac',         "MAC",      DHCPLease.mac,
-            rowfilter   = True,
-        ),
-        table.Column('hostname',    "Hostname", DHCPLease.hostname),
-        table.Column('starts',      "Starts",   DHCPLease.starts,       DHCPLease.render_starts),
-        table.Column('ends',        "Ends",     DHCPLease.ends,         DHCPLease.render_ends,
-            rowcss  = DHCPLease.ends_class
-        ),
-        table.Column('length',      "Lease",    DHCPLease.ends - DHCPLease.starts,  DHCPLease.render_length),
-    )
-    
-    # XXX: have to set again
-    ATTRS = dict((col.attr, col) for col in COLUMNS)
-
-    # default
-    SORT = DHCPLease.starts.desc()
-    PAGE = 20
-
-class LeasesHandler (table.TableHandler, web.DatabaseHandler) :
-    """
-        Combined database + <table>
-    """
-
-    CSS = web.DatabaseHandler.CSS + table.TableHandler.CSS + (
-        "/static/dhcp/hosts.css", 
-    )
-    
-    # view
-    TABLE = LeasesTable
-
-    def query (self) :
-        """
-            Database SELECT query.
-        """
-
-        return self.db.query(DHCPLease)
-
-    def filter_starts (self, value) :
-        return ((db.func.now() - DHCPLease.starts) < parse_timedelta(value))
-
-    def filter_ends (self, value) :
-        return ((DHCPLease.ends - db.func.now()) > parse_timedelta(value))
-
-    def filter_length (self, value) :
-        """
-            Filter by leases valid on given date.
-        """
-
-        dt = datetime.datetime.strptime(value, DHCPLease.DATE_FMT)
-
-        return db.between(dt, DHCPLease.starts, DHCPLease.ends)
-
-    def filter_ip (self, value) :
-        # column is IPv4 string literal format...
-        if '/' in value :
-            return (db.func.inet(DHCPLease.ip).op('<<')(db.func.cidr(value)))
-        else :
-            return (db.func.inet(DHCPLease.ip) == db.func.inet(value))
-
-
-class ListHandler (LeasesHandler) :
-    """
-        List of DHCP leases, using table.TableHandler -> LeasesTable.
-    """
-
-    #TABLE_ITEM_URL = ItemHandler
-
-    def process (self) :
-        # super
-        table.TableHandler.process(self)
- 
-    def title (self) :
-        if self.filters :
-            return "DHCP Leases: {filters}".format(filters=self.filters_title())
-        else :
-            return "DHCP Leases"
-
-    def render (self) :
-        return (
-            self.render_table(self.query, filters=self.filters, sort=self.sorts, page=self.page),
-
-            #html.a(href=self.url())(html('&laquo;'), 'Back') if self.filters else None,
-        )
-
-
-
--- a/pvl/verkko/rrd.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,262 +0,0 @@
-# encoding: utf-8
-"""
-    http://verkko.paivola.fi/rrd
-"""
-
-from pvl import web
-from pvl.web import urls
-from pvl.web.html import tags as html
-
-import pvl.web.response
-import pvl.rrd
-
-import logging; log = logging.getLogger('pvl.verkko.rrd')
-
-# errors
-import os.path
-
-class RRDNotFound (pvl.web.response.NotFound) :
-    """
-        404 Not Found for tree/rrd.
-    """
-
-    def __init__ (self, tree, rrd=None) :
-        if tree and rrd :
-            path = os.path.join(tree, rrd)
-        elif tree :
-            path = tree
-        elif rrd :
-            path = rrd
-        else :
-            path = ''
-
-        pvl.web.response.NotFound.__init__(self, path)
-
-# View/Controller
-class Handler (web.Handler) :
-    CSS = (
-        "/static/rrd/rrd.css", 
-    )
-
-    def title (self) :
-        return u"Network RRD Traffic Graphs"
-
-    def breadcrumb (self, _tree, target=None) :
-        """
-            Yield (title, url) navigation breadcrumbs
-        """
-
-        yield '/', self.url(Index)
-        
-        if _tree :
-            tree = ''
-            
-            for part in _tree.split('/') :
-                tree = urls.join(tree, part)
-
-                yield part, self.url(Index, tree=tree)
-
-        if target :
-            # Target
-            yield target, self.url(Target, tree=tree, target=self.target)
-
-    def render_breadcrumb (self, tree, target=None) :
-        """
-            Render breadcrumb -> html.div
-        """
-
-        return html.div(id='breadcrumb')(html(" &raquo; ".join(
-            str(html.a(href=url)(node)) for node, url in self.breadcrumb(tree, target)))
-        )
-
-class Index (Handler) :
-    """
-        Browse trees, show overview graphs for targets.
-    """
-
-    def _title (self) :
-        if self.tree :
-            return html(" &raquo; ".join(self.tree.split('/')))
-        else :
-            return ""
- 
-    def url_tree (self, node) :
-        """
-            Return url for given sub-node.
-        """
-        
-        if self.tree :
-            path = urls.join(self.tree, node)
-        else :
-            path = node
-        
-        return self.url(tree=path)
-
-    def process (self, tree=None) :
-        """
-            Lookup path -> self.tree.
-        """
-
-        if tree :
-            try :
-                # XXX: unicode?
-                self.tree = self.app.rrd.tree(tree)
-
-            except ValueError as ex :
-                # mask
-                raise RRDNotFound(tree)
-        else :
-            # root
-            self.tree = self.app.rrd.tree()
-
-    def render_list (self, items) :
-        return (
-            html.li(class_=('odd' if idx % 2 else 'even'))(item) for idx, item in enumerate(items)
-        )
-
-    def render_rrd (self, rrd) :
-        """
-            Render overview link/image for given rrd.
-        """
-
-        target_url = self.url(Target, tree=self.tree, target=rrd)
-        graph_url = self.url(Graph, tree=self.tree, target=rrd)
-        
-        return html.a(href=target_url)(
-                html.h3(rrd),
-                html.img(src=graph_url),
-        )
-
-    def render (self) :
-        """
-            Render list of trees/rrds.
-        """
-
-        trees, rrds = self.app.rrd.list(self.tree)
-
-        return self.render_breadcrumb(self.tree), html.div(id='overview')(
-                html.ul(id='tree-list')(
-                    self.render_list(
-                        html.a(href=self.url_tree(subtree))(subtree)
-                    for subtree in trees)
-                ) if trees else None,
-
-                html.hr() if trees and rrds else None,
-
-                html.ul(id='rrd-list')(
-                    self.render_list(
-                        self.render_rrd(rrd)
-                    for rrd in rrds)
-                ) if rrds else None,
-        )
-
-class Target (Handler) :
-    """
-        Show graphs for RRD file.
-    """
-    
-    def _title (self) :
-        return html(" &raquo; ".join(self.rrd.split('/')))
-        
-    def process (self, target, tree=None) :
-        """
-            Lookup tree/target -> self.target
-        """
-        
-        try :
-            self.tree = self.app.rrd.tree(tree)
-            self.rrd = self.app.rrd.rrd(target, self.tree)
-            self.target = target
-
-        except ValueError as ex :
-            raise RRDNotFound(tree, target)
-
-    def render_interval (self, interval, style='detail') :
-        """
-            Render detail link/image.
-        """
-
-        graph_url = self.url(Graph, tree=self.tree, target=self.target, style=style, interval=interval)
-        
-        return (
-                html.h2(interval.title()),
-                html.img(src=graph_url)
-        )
-
-    INTERVALS = ('daily', 'weekly', 'yearly')
-
-    def render (self) :
-        return self.render_breadcrumb(self.tree, self.target), html.div(id='detail')(
-                self.render_interval(interval) for interval in self.INTERVALS
-        )
-
-from pvl.invoke import merge # XXX
-import werkzeug # wrap_file
-
-class Graph (Handler) :
-    """
-        Render graph for RRD.
-    """
-
-    ARGS = { 'interval': 'daily', 'style': 'overview' }
-
-    def process (self, tree, target, style, interval) :
-        """
-            Return Graph for given options.
-        """
-
-        try :
-            self.tree = self.app.rrd.tree(tree)
-            self.rrd = self.app.rrd.rrd(target, self.tree)
-
-        except ValueError as ex :
-            raise RRDNotFound(tree, target)
-        
-        self.style = style
-        self.interval = interval
-
-    def render_png (self) :
-        """
-            Return PNG data as a file-like object for our graph.
-        """
-
-        return self.app.rrd.graph(self.rrd, self.style, self.interval)
-
-    def respond (self) :
-        """
-            Return Response for our request.
-        """
-        
-        # process params+args -> self.graph
-        process = merge(self.params, dict((arg, self.request.args.get(arg, default)) for arg, default in self.ARGS.iteritems()))
-        response = self.process(**process)
-
-        if response :
-            return response
-        
-        # PNG output
-        file = self.render_png()
-
-        # respond with file wrapper
-        return pvl.web.response.image(self.response_file(file), type='png')
-
-# WSGI
-class Application (web.Application) :
-    # dispatch
-    URLS = urls.Map((
-        urls.rule('/',                              Index),
-        urls.rule('/<target>',                      Target),
-        urls.rule('/<path:tree>/',                  Index),
-        urls.rule('/<path:tree>/<target>',          Target),
-        urls.rule('/<path:tree>/<target>.png',      Graph),
-    ))
-
-    def __init__ (self, rrd, **opts) :
-        """
-            rrd     - pvl.rrd.RRDDatabase
-        """
-
-        super(Application, self).__init__(**opts)
-
-        self.rrd = rrd
-
--- a/pvl/verkko/table.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,590 +0,0 @@
-from pvl.web import html
-from pvl.verkko import web, db
-
-import math
-
-import logging; log = logging.getLogger('pvl.verkko.table')
-
-class Column (object) :
-    """
-        web.Table column spec, representing a property of an SQLAlchemy ORM model class.
-    """
-
-    def __init__ (self, attr, title, column, rowhtml=None, sort=True, filter=True, colcss=True, rowfilter=False, rowtitle=None, rowcss=None) :
-        """
-            attr        - name of column value, used for http query arg, html css class, model getattr()
-            title       - title of column
-            column      - the model column property, used to build queries
-            rowhtml     - function returning column value as HTML for model
-            sort        - allow sorting by column
-            filter      - allow filtering by column
-            colcss      - apply css class for column; True -> attr
-            rowfilter   - allow filtering by row value
-            rowtitle    - function returning column title for model
-            rowcss      - function returning column class for model
-        """
-
-        if colcss is True :
-            colcss = attr
-
-        self.attr = attr
-        self.title = title
-        self.column = column
-
-        # column attrs
-        self.sort = sort
-        self.filter = filter
-        self.colcss = colcss
-        
-        # row attrs
-        self.rowhtml = rowhtml
-        self.rowfilter = rowfilter
-        self.rowtitle = rowtitle
-        self.rowcss = rowcss
-
-    def render_header (self, sorturl) :
-        """
-            Render <th> for <thead> in given Table.
-        """
-
-        header = self.title
-
-        if self.sort :
-            header = html.a(href=sorturl)(header)
-
-        return html.th(header)
-
-    def render_filter_input (self, filters) :
-        """
-            Render filter <input> in given Table.
-        """
-        
-        value = filters.get(self.attr)
-
-        if value :
-            # XXX: multi-valued filters?
-            value = value[0]
-        else :
-            value = None
-
-        return html.input(type='text', name=self.attr, value=value)
-
-    def render_header_filter (self, filters) :
-        """
-            Render <td><input> for <thead> in given Table.
-        """
-
-        if self.filter :
-            input = self.render_filter_input(filters)
-        else  :
-            input = None
-
-        return html.td(class_=self.colcss)(input)
-
-    def cell_value (self, item) :
-        """
-            Return value for cell.
-        """
-
-        # XXX: this is sometimes broken, figure out how to index by column
-        return getattr(item, self.attr)
-
-    def cell_html (self, item, value) :
-        """
-            Render contents for <td>.
-        """
-
-        if self.rowhtml :
-            return self.rowhtml(item)
-        else :
-            return value
-
-    def cell_css (self, item, value=None, hilight=None) :
-        """
-            Return CSS classes for <td>.
-        """
-
-        if self.colcss :
-            yield self.colcss
-
-        if self.rowcss :
-            yield self.rowcss(item)
-
-        if hilight :
-            # lookup attr/value
-            hilight = self.attr in hilight and value in hilight[self.attr]
-
-        if hilight :
-            yield 'hilight'
-
-    def cell_title (self, item, value=None) :
-        """
-            Return title= for <td>
-        """
-
-        if self.rowtitle :
-            return self.rowtitle(item)
-
-    def render_cell (self, item, table, filters=None, hilight=None) :
-        """
-            Render <td> for item in <tbody> in given Table.
-
-                hilight     - optionally higlight given { attr: value }'s using CSS.
-        """
-
-        value = self.cell_value(item)
-
-        if self.rowfilter and filters is not None :
-            # filter-link by row-value
-            filters = { self.attr: value }
-        else :
-            filters = None
-
-        yield table.render_cell(self.cell_html(item, value),
-                css         = tuple(self.cell_css(item, value, hilight=hilight)),
-                filters     = filters,
-                title       = self.cell_title(item),
-        )
-
-class Table (object) :
-    """
-        Render <table> with Columns from SQLAlchemy ORM model class.
-    """
-
-    COLUMNS = ()
-    ITEMS = None
-    
-    # attr -> column
-    ATTRS = dict((col.attr, col) for col in COLUMNS)
- 
-    # items per page
-    PAGE = 10
-    
-    def __init__ (self, url, columns=None, table_url=None, item_url=None, caption=None, page=None) :
-        """
-                url         - pvl.web.Handler.url()
-                table_url   - ListHandler, or self?
-                item_url    - ItemHandler, or self#id
-                columns     - sequence of Columns
-                caption     - optional <caption>
-                page        - items per page
-        """
-        
-        self.url = url
-
-        self.columns = columns or self.COLUMNS
-        self.table_url = table_url
-        self.item_url = item_url
-
-        self.caption = caption
-        self.page = page or self.PAGE
-
-    def tableurl (self, filters=None, **opts) :
-        """
-            URL for table with given opts, keeping our sorting/filtering unless overriden.
-        """
-
-        args = dict()
-
-        # apply
-        if filters :
-            args.update(filters)
-        
-        if opts :
-            args.update(opts)
-
-        return self.url(self.table_url, **args)
-
-    def sorturl (self, attr, sort=None, **opts) :
-        """
-            URL for table sorted by given column, reversing direction if already sorting by given column.
-        """
-
-        if not sort :
-            sort = attr
-        elif sort.lstrip('+-') != attr :
-            sort = attr
-        elif sort.startswith('-') :
-            sort = "+" + attr
-        else :
-            sort = "-" + attr
-
-        return self.tableurl(sort=sort, **opts)
-
-    def itemurl (self, item) :
-        """
-            URL for given item, by id.
-        """
-
-        if self.item_url :
-            # separate page
-            return self.url(self.item_url, id=item.id)
-        else :
-            # to our table
-            return '#{id}'.format(id=item.id)
-
-    def render_head (self, filters=None, sort=None) :
-        """
-            Yield header columns in table header.
-        """
-        
-        # id
-        yield html.th('#')
-        
-        for column in self.columns :
-            yield column.render_header(sorturl=self.sorturl(column.attr, sort=sort, filters=filters))
-
-    def render_head_filters (self, filters=None) :
-        """
-            Yield filter columns in table header.
-        """
-
-        # id
-        yield html.td(html.input(type='submit', value=u'\u00BF'))
-        
-        for column in self.columns :
-            yield column.render_header_filter(filters)
-
-    def render_cell (self, rowhtml, css=(), filters=None, title=None) :
-        """
-            Render a single cell.
-
-                htmlvalue   - rendered value
-                css         - css classes to apply
-                filters     - render filter link for filter-values?
-                title       - mouseover title for cell
-        """
-
-        if filters :
-            cell = html.a(href=self.tableurl(**filters))(rowhtml)
-        else :
-            cell = rowhtml
-
-        css = ' '.join(cls for cls in css if cls)
-        
-        return html.td(class_=css, title=title)(cell)
-   
-    def render_row (self, item, **opts) :
-        """
-            Yield columns for row.
-        """
-
-        yield html.th(
-            html.a(href=self.itemurl(item))("#")
-        ),
-
-        for column in self.columns :
-            yield column.render_cell(item, self, **opts)
-
-    def render_body (self, rows, **opts) :
-        """
-            Yield body rows.
-        """
-
-        for i, item in enumerate(rows) :
-            yield html.tr(class_=('alternate' if i % 2 else None), id=item.id)(
-                self.render_row(item, **opts)
-            )
-
-    def render_pagination (self, page, count=None, **opts) :
-        """
-            Render pagination links.
-        """
-
-        if count is not None :
-            pages = int(math.ceil(count / self.page))
-        else :
-            pages = None
-
-        if page > 0 :
-            yield html.a(href=self.tableurl(page=0, **opts))(html("&laquo;&laquo; First"))
-            yield html.a(href=self.tableurl(page=(page - 1), **opts))(html("&laquo; Prev"))
-        
-        if pages :
-            yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=pages))
-        else :
-            yield html.span("Page {page}".format(page=(page + 1)))
-
-        yield html.a(href=self.tableurl(page=(page + 1), **opts))(html("&raquo; Next"))
-
-    def render_foot (self, query, page, **opts) :
-        """
-            Render pagination/host count in footer.
-        """
-
-        # XXX: does separate SELECT count()
-        count = query.count()
-
-        if page is None :
-            return "{count} {items}".format(count=count, items=self.ITEMS)
-        else :
-            # XXX: count is just the count we got..
-            return self.render_pagination(page, count=None, **opts)
-
-    def render (self, query, filters=None, sort=None, page=None, hilight=None) :
-        """
-            Return <table> element. Wrapped in <form> if filters.
-                query   - filter()'d sort()'d SELECT query()
-                filters - None for no filtering ui, dict of filters otherwise.
-                sort    - None for no sorting ui, sort-attr otherwise.
-                page    - display pagination for given page
-                hilight - { attr: value } cells to hilight
-        """
-
-        # render table
-        table = html.table(
-            html.caption(self.caption) if self.caption else None,
-            html.thead(
-                html.tr(
-                    self.render_head(filters=filters, sort=sort)
-                ),
-                (
-                    html.tr(class_='filter')(self.render_head_filters(filters=filters))
-                ) if filters is not None else None,
-            ),
-            html.tbody(
-                self.render_body(query,
-                    filters = filters,
-                    hilight = hilight,
-                )
-            ),
-            html.tfoot(
-                html.tr(
-                    html.td(colspan=(1 + len(self.columns)))(
-                        self.render_foot(query, page, filters=filters, sort=sort)
-                    )
-                )
-            )
-        )
-        
-        # filters form?
-        if filters is None :
-            return table
-        else :
-            return html.form(method='get', action=self.tableurl())(
-                html.input(type='hidden', name='sort', value=sort),
-                table,
-            )
-
-    def json (self, item) :
-        """
-            Yield JSON params for given item.
-        """
-
-        yield 'id', item.id
-
-        for column in self.columns :
-            value = column.cell_value(item)
-
-            yield column.attr, dict(
-                    html    = unicode(html(column.cell_html(item, value))),
-                    css     = tuple(column.cell_css(item, value)),
-                    title   = column.cell_title(item, value),
-            )
-
-class TableHandler (object) :
-    """
-        Mixin for handling Table args/rendering.
-    """
-
-    CSS = (
-        "/static/dhcp/table.css", 
-    )
-    
-    TABLE = None
-    DB_TABLE = None
-
-    # target Handlers for table links
-    TABLE_URL = None
-    TABLE_ITEM_URL = None
-
-    def init (self) :
-        """
-            Bind self.table
-        """
-
-        super(TableHandler, self).init()
-
-        self.table = self.TABLE(self.url,
-                table_url   = self.TABLE_URL,
-                item_url    = self.TABLE_ITEM_URL,
-        )
-
-    def query (self) :
-        """
-            Database SELECT query.
-        """
-        
-        if self.DB_CLASS is None :
-            raise NotImplementedError()
-
-        return self.db.query(self.DB_CLASS)
-    
-    def sort (self, query) :
-        """
-            Apply ?sort= from requset args to query.
-
-            Return { attr: sort }, query
-        """
-
-        sort = self.request.args.get('sort')
-
-        if sort :
-            name = sort.lstrip('+-')
-        else :
-            name = None
-
-        if name :
-            order_by = self.TABLE.ATTRS[name].column
-        else :
-            order_by = self.TABLE.SORT # default
-        
-        # prefix -> ordering
-        if not sort :
-            pass
-        elif sort.startswith('+') :
-            order_by = order_by.asc()
-        elif sort.startswith('-') :
-            order_by = order_by.desc()
-        else :
-            pass
-        
-        # apply
-        log.debug("sort: %s", order_by)
-        
-        query = query.order_by(order_by)
-
-        return sort, query
-    
-    def filter_attr (self, attr, value) :
-        """
-            Return filter expression for given attr == value
-        """
-
-        # preprocess
-        like = False
-
-        if value.endswith('*') :
-            like = value.replace('*', '%')
-
-        # filter
-        column = self.TABLE.ATTRS[attr].column
-
-        if like :
-            return (column.like(like))
-        else :
-            return (column == value)
- 
-    def _filter (self, attr, values) :
-        """
-            Apply filters for given attr -> (value, expression)
-        """
-
-        for value in values :
-            value = value.strip()
-            
-            # ignore empty fields
-            if not value :
-                continue
-
-            # lookup attr-specific filter
-            filter = getattr(self, 'filter_{attr}'.format(attr=attr), None)
-
-            if filter :
-                filter = filter(value)
-            else :
-                # use generic
-                filter = self.filter_attr(attr, value)
-            
-            log.debug("%s: %s: %s", attr, value, filter)
-            
-            yield value, filter
-
-    def filter (self, query) :
-        """
-            Apply filters from request.args against given hosts.
-
-            Returns (filters, hosts).
-        """
-
-        # filter?
-        filters = {}
-
-        for attr in self.TABLE.ATTRS :
-            # from request args
-            values = self.request.args.getlist(attr)
-
-            # lookup attr filters as expressions
-            value_filters = list(self._filter(attr, values))
-
-            # ignore empty fields
-            if not value_filters :
-                continue
-        
-            # filtering values, and filter expressions
-            values, expressions = zip(*value_filters)
-
-            # apply
-            query = query.filter(db.or_(*expressions))
-            filters[attr] = values
-
-        return filters, query
-
-    def filters_title (self) :
-        """
-            Return a string representing the applied filters.
-        """
-
-        return ', '.join(value for values in self.filters.itervalues() for value in values)
-
-    def title (self) :
-        if self.filters :
-            return "{title}: {filters}".format(title=self.TABLE.ITEMS, filters=self.filters_title())
-        else :
-            return self.TABLE.ITEMS
-
-    def process (self) :
-        """
-            Process request args -> self.filters, self.sorts, self.page, self.query
-        """
-
-        query = self.query()
-
-        # filter
-        self.filters, query = self.filter(query)
-
-        # sort
-        # TODO: sort per filter column by default?
-        self.sorts, query = self.sort(query)
-        
-        # page?
-        self.page = self.request.args.get('page')
-
-        if self.page :
-            self.page = int(self.page)
-
-            query = query.offset(self.page * self.TABLE.PAGE).limit(self.TABLE.PAGE)
-
-        self.query = query
- 
-    def render_table (self, query, filters=None, sort=None, page=None, hilight=None) :
-        """
-            Render table
-                query       - SELECT query for rows
-                filters     - applied filters
-                sort        - applied sort
-                page        - applied page
-                hilight     - hilight given { attr: value } cells
-        """
-
-        return self.table.render(query,
-                filters = filters,
-                sort    = sort,
-                page    = page,
-                hilight = hilight,
-        )
-
-    def render (self) :
-        return (
-            self.render_table(self.query, filters=self.filters, sort=self.sorts, page=self.page),
-        )
--- a/pvl/verkko/utils.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,184 +0,0 @@
-"""
-    DHCP... stuff
-"""
-
-import re
-import functools
-from datetime import datetime, timedelta
-
-TIMEDELTA_RE = re.compile(r'(\d+)([a-z]*)', re.IGNORECASE)
-TIMEDELTA_UNITS = {
-    'd':    'days',
-    'h':    'hours',
-    'm':    'minutes',
-    's':    'seconds',
-}
-
-def parse_timedelta (expr) :
-    """
-        Parse timeout -> timedelta
-
-        >>> parse_timedelta('1d')
-        datetime.timedelta(1)
-        >>> parse_timedelta('1h')
-        datetime.timedelta(0, 3600)
-        >>> parse_timedelta('15m')
-        datetime.timedelta(0, 900)
-        >>> parse_timedelta('1d1h1s')
-        datetime.timedelta(1, 3601)
-    """
-    
-    what = {}
-
-    for (value, unit) in TIMEDELTA_RE.findall(expr) :
-        unit = unit.lower()
-        value = int(value)
-
-        if unit in TIMEDELTA_UNITS :
-            what[TIMEDELTA_UNITS[unit]] = value
-        else :
-            raise ValueError(unit)
-    
-    return timedelta(**what)
-
-def format_timedelta (td):
-    """
-        datetime.timedelta -> str
-
-        >>> print timedelta_str(timedelta(days=1))
-        1d
-        >>> print timedelta_str(timedelta(hours=6))
-        6h
-        >>> print timedelta_str(timedelta(days=2, hours=6, seconds=120))
-        2d6h2m
-    """
-
-    # divmod
-    days = td.days
-
-    seconds = td.seconds
-    minutes, seconds = divmod(seconds, 60)
-    hours, minutes = divmod(minutes, 60)
-    
-    # format
-    data = (
-        (days,      'd'),
-        (hours,     'h'),
-        (minutes,   'm'),
-        (seconds,   's'),
-    )
-
-    return ''.join('%d%s' % (count, unit) for count, unit in data if count)
-
-def parse_addr (addr, pad=False) :
-    """
-        Parse IPv4 addr -> int.
-
-            partial     - allow partial addrs; right-pad with .0
-
-        >>> print "%#010x/%d" % parse_addr('1.2.3.4')
-        0x01020304/32
-        >>> print "%#010x/%d" % parse_addr('1.2', pad=True)
-        0x01020000/16
-
-    """
-
-    # split net
-    addr = [int(part) for part in addr.split('.') if part]
-
-    addrlen = len(addr) * 8
-
-    # fixup base?
-    if len(addr) == 4 :
-        # fine
-        pass
-    
-    elif len(addr) > 4 :
-        raise ValueError("Invalid IPv4 net: {0}".format(addr))
-
-    elif len(addr) < 4 and pad :
-        # pad
-        addr += [0] * (4 - len(addr))
-    
-    else :
-        raise ValueError("Incomplete IPv4 addr: {0}".format(addr))
-
-    # pack to int
-    return functools.reduce(lambda a, b: a * 256 + b, addr), addrlen
-    
-def parse_net (expr) :
-    """
-        Parse given expr into (base, mask).
-
-        >>> print "%#010x/%#010x" % parse_net('1.2.3.4')
-        0x01020304/0xffffffff
-        >>> print "%#010x/%#010x" % parse_net('1.2.0.0/16')
-        0x01020000/0xffff0000
-        >>> print "%#010x/%#010x" % parse_net('1.2')
-        0x01020000/0xffff0000
-    """
-
-    if '/' in expr :
-        net, masklen = expr.split('/', 1)
-        
-        masklen = int(masklen)
-
-    else :
-        net = expr
-        masklen = None
-
-    base, baselen = parse_addr(net, pad=True)
-
-    if not masklen :
-        # implicit mask, by leaving off octets in the base
-        masklen = baselen
-
-    elif masklen > 32 :
-        raise ValueError("Invalid IPv4 mask: /{0:d}".format(masklen))
-    
-    # pack
-    mask = (0xffffffff << (32 - masklen)) & 0xffffffff
-
-    # verify
-    if base & ~mask :
-        raise ValueError("Invalid IPv4 net base: {base:x} & {mask:x}".format(base=base, mask=mask))
-
-    return base, mask, masklen
-
-def IPv4Address (addr) :
-    """
-        Parse IPv4 address to int.
-    """
-
-    addr, len = parse_addr(addr)
-
-    return addr
-
-class IPv4Network (object) :
-    """
-        Parse and match network masks.
-
-        XXX: is used as a dict key
-    """
-
-    def __init__ (self, expr) :
-        self.expr = expr
-        self.base, self.mask, self.masklen = parse_net(expr)
-
-    def __contains__ (self, addr) :
-        return (addr & self.mask) == self.base
-
-    def __str__ (self) :
-        return self.expr
-
-    def __repr__ (self) :
-        return "IPv4Network(%r)" % (self.expr, )
-
-if __name__ == '__main__' :
-    import logging
-
-    logging.basicConfig()
-
-    import doctest
-    doctest.testmod()
-
--- a/pvl/verkko/web.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-# encoding: utf-8
-import pvl.web
-from pvl.web import html, urls
-
-import logging; log = logging.getLogger('pvl.verkko.web')
-
-class DatabaseHandler (pvl.web.Handler) :
-    """
-        Request handler with pvl.verkko.Database session
-    """
-
-    def __init__ (self, app, request, urls, params) :
-        super(DatabaseHandler, self).__init__(app, request, urls, params)
-
-        # new ORM session per request
-        self.db = app.db.session() 
-
-    def cleanup (self) :
-        """
-            After request processing.
-        """
-        
-        # XXX: SQLAlchemy doesn't automatically close these...?
-        self.db.close()
-
-class Application (pvl.web.Application) :
-    """
-        Application with pvl.verkko.Database
-    """
-
-    def __init__ (self, db, **opts) :
-        """
-            db      - pvl.verkko.Database
-        """
-
-        super(Application, self).__init__(**opts)
-
-        self.db = db
-
--- a/pvl/verkko/wlan.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-from pvl.verkko import web, db, table
-from pvl.web import html, urls
-
-import datetime
-import logging; log = logging.getLogger('pvl.verkko.wlan')
-
-class WLANSta (object) :
-    """
-        A WLAN STA (client) with ap/ssid/sta and starts/ends.
-    """
-
-    DATE_FMT = '%Y%m%d %H:%M'
-    TIME_FMT = '%H:%M:%S'
-
-    @classmethod
-    def format_datetime (cls, dt) :
-        if dt.date() == datetime.date.today() :
-            return dt.strftime(cls.TIME_FMT)
-        else :
-            return dt.strftime(cls.DATE_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))
-        )
-
-db.mapper(WLANSta, db.wlan_sta, properties=dict(
-
-))
-
-class WLANTable (table.Table) :
-    """
-        <table> of WLAN STA
-    """
-
-    ITEMS   = "WLAN Stations"
-    COLUMNS = (
-        table.Column('ap',          "Access Point",     WLANSta.ap,
-            rowfilter   = True,
-        ),
-        table.Column('wlan',        "SSID",             WLANSta.wlan,
-            rowfilter   = True,
-        ),
-        table.Column('sta',         "MAC",              WLANSta.sta,
-            rowfilter   = True,
-        ),
-        table.Column('seen',        "Seen",             WLANSta.last_seen,  WLANSta.seen),
-    )
-    
-    # XXX: have to set again
-    ATTRS = dict((col.attr, col) for col in COLUMNS)
-
-    # default
-    SORT = WLANSta.last_seen.desc()
-    PAGE = 20
-
-class WLANHandler (table.TableHandler, web.DatabaseHandler) :
-    """
-        Combined database + <table>
-    """
-
-    CSS = web.DatabaseHandler.CSS + table.TableHandler.CSS + (
-        "/static/wlan.css", 
-    )
-    
-    # view
-    TABLE = WLANTable
-    DB_CLASS = WLANSta
-
-class Application (web.Application) :
-    URLS = urls.Map((
-        urls.rule('/',                       WLANHandler),
-    ))
--- a/pvl/web/__init__.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-"""
-    Werkzeug-based web application.
-"""
-
-# items
-from pvl.web.html import tags as html, html5
-from pvl.web.application import Application, Handler
-
-# werkzeug
-from werkzeug.wrappers import Request, Response
-
--- a/pvl/web/application.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,228 +0,0 @@
-"""
-    WSGI Application with pvl.web.urls-mapped Handlers building pvl.web.html.
-"""
-
-import werkzeug
-from werkzeug.wrappers import Request, Response
-from werkzeug.exceptions import HTTPException
-
-import pvl.web.response
-
-import logging; log = logging.getLogger('pvl.web.application')
-
-class Application (object) :
-    """
-        Main wsgi entry point state.
-    """
-
-    URLS = None
-
-    # TODO: charset
-
-    def __init__ (self, urls=None, layout=None) :
-        """
-                urls        - werkzeug.routing.Map -> Handler
-                layout      - template with {TITLE} and {HEAD} and {BODY}
-        """
-
-        if not urls :
-            urls = self.URLS
-
-        if not urls :
-            raise ValueError("No URLS/urls=... given")
-        
-        self.layout = layout
-        self.urls = urls
-
-    def respond (self, request) :
-        """
-            Lookup Request -> web.Handler, params
-        """
-        
-        # bind to request
-        urls = self.urls.bind_to_environ(request)
-        
-        # lookup
-        handler, params = urls.match()
-
-        # handler instance
-        handler = handler(self, request, urls, params)
-
-        try :
-            # apply
-            return handler.respond()
-
-        finally :
-            handler.cleanup()
-
-    @Request.application
-    def __call__ (self, request) :
-        """
-            WSGI entry point, werkzeug Request -> Response
-        """
-
-        try :
-            return self.respond(request)
-        
-        except HTTPException as ex :
-            return ex
-
-from pvl.invoke import merge # XXX
-from pvl.web.html import tags as html
-
-class Handler (object) :
-    """
-        Per-Request controller/view, containing the request context and generating the response.
-    """
-
-    DOCTYPE = 'html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"'
-    HTML_XMLNS = 'http://www.w3.org/1999/xhtml'
-    HTML_LANG = None
-
-    TITLE = None
-    STYLE = None
-    SCRIPT = None
-    CSS = (
-        #"/static/...css", 
-    )
-    JS = (
-        #"/static/....js"
-    )
-
-    STATUS = 200
-
-    def __init__ (self, app, request, urls, params) :
-        """
-            app     - wsgi.Application
-            request - werkzeug.Request
-            urls    - werkzeug.routing.Map.bind_to_environ()
-        """
-
-        self.app = app
-        self.request = request
-        self.urls = urls
-        self.params = params
-
-        self.init()
-        
-    def init (self) :
-        """
-            Initialize on request.
-        """
-
-    def url (self, handler=None, **params) :
-        """
-            Return an URL for given endpoint, with parameters,
-        """
-
-        if not handler :
-            # XXX: just generate a plain-relative '?foo=...' url instead?
-            handler = self.__class__
-            params = merge(self.params, params)
-
-        return self.urls.build(handler, params)
-
-    def status (self) :
-        """
-            Return HTTP status code for response.
-        """
-
-        return self.STATUS
-
-    def title (self) :
-        """
-            Render site/page title as text.
-        """
-
-        return self.TITLE
- 
-    def render (self) :
-        """
-            Render page content (as <body>...</body>).
-        """
-
-        raise NotImplementedError()
-
-    def render_html (self, body=None, extrahead=None) :
-        """
-            Render page layout (as <html>).
-        """
-
-        title = self.title()
-        head = html(
-                (
-                    html.link(rel='Stylesheet', type="text/css", href=src) for src in self.CSS
-                ), 
-                (
-                    html.script(src=src, type='text/javascript', _selfclosing=False) for src in self.JS
-                ),
-                html.style(type='text/css')(self.STYLE) if self.STYLE else None,
-                html.script(type='text/javascript')(self.SCRIPT) if self.SCRIPT else None,
-                extrahead,
-        )
-        
-        if body is None :
-            body = html(self.render())
-
-        if not title :
-            raise Exception("%s: no page title!" % (self, ))
-
-        if self.app.layout :
-            return self.app.layout.format(
-                TITLE   = unicode(title),
-                HEAD    = unicode(head),
-                BODY    = unicode(body),
-            )
-        else :
-            return html.document(html.html(
-                html.head(
-                    html.title(title),
-                    head,
-                ),
-                html.body(
-                    html.h1(title),
-                    body,
-                )
-            ), doctype=self.DOCTYPE, html_xmlns=self.HTML_XMLNS, html_lang=self.HTML_LANG)
-
-    def response_file (self, file) :
-        """
-            Wrap a file for returning in a response with direct_passthrough=True.
-        """
-
-        return werkzeug.wrap_file(self.request.environ, file)
-
-    def process (self, **params) :
-        """
-            Process request args to build internal request state.
-        """
-
-        pass
-
-    def respond (self) :
-        """
-            Generate a response, or raise an HTTPException
-
-            Does an HTML layout'd response per default.
-        """
-        
-        # returning e.g. redirect?
-        response = self.process(**self.params)
-
-        if response :
-            return response
-        
-        # render as html per default
-        # XXX: might also be string?
-        text = unicode(self.render_html())
-
-        return pvl.web.response.html(text,
-                status  = self.status(),
-        )
-    
-    def cleanup (self) :
-        """
-            After request processing. Do not fail :)
-        """
-        
-        pass
--- a/pvl/web/args.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,54 +0,0 @@
-import codecs
-import werkzeug.serving 
-import optparse
-
-import logging; log = logging.getLogger('pvl.web.args')
-
-def parser (parser) :
-    """
-        Command-line args for pvl.web
-    """
-
-    parser = optparse.OptionGroup(parser, 'pvl.web')
-
-    parser.add_option('--web-layout', metavar='TEMPLATE',
-            help="Use template from given file for layout")
-
-    parser.add_option('--web-static', metavar='PATH', default='static',
-            help="Path to static files")
-
-    return parser
-
-def apply (options, application, *args, **opts) :
-    """
-        Build given pvl.web.Application subclass from options.
-    """
-
-    if options.web_layout :
-        layout = codecs.open(options.web_layout, 'r', 'utf-8').read()
-    else :
-        layout = None
-    
-    return application(*args,
-        layout      = layout,
-        **opts
-    )
-
-def main (options, wsgi) :
-    """
-        Run the werkzeug development server.
-    """
-
-    static_files = { }
-
-    if options.web_static :
-        static_files['/static'] = options.web_static
-
-    werkzeug.serving.run_simple('0.0.0.0', 8080, wsgi,
-        #use_reloader    = True, 
-        use_debugger    = (options.loglevel == logging.DEBUG),
-        static_files    = static_files,
-    )
-
-    return 0
-
--- a/pvl/web/html.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,641 +0,0 @@
-"""
-    Generate XHTML output from python code.
-
-    >>> from html import tags
-    >>> unicode(tags.a(href="http://www.google.com")("Google <this>!"))
-    u'<a href="http://www.google.com">\\n\\tGoogle &lt;this&gt;!\\n</a>'
-"""
-
-# XXX: needs some refactoring for Text vs Tag now
-# XXX: not all tags work in self-closing form, e.g. empty html.title() breaks badly
-
-import itertools as itertools
-import types as types
-from xml.sax import saxutils
-
-class Renderable (object) :
-    """
-        Structured data that's flattened into indented lines of text.
-    """
-
-    # types of nested items to flatten
-    CONTAINER_TYPES = (types.TupleType, types.ListType, types.GeneratorType)
-
-    @classmethod
-    def process_contents (cls, *args) :
-        """
-            Yield the HTML tag's contents from the given sequence of positional arguments as a series of flattened
-            items, eagerly converting them to unicode.
-
-            If no arguments are given, we don't have any children:
-            
-            >>> bool(list(Tag.process_contents()))
-            False
-            
-            Items that are None will be ignored:
-
-            >>> list(Tag.process_contents(None))
-            []
-
-            Various Python container types are recursively flattened:
-
-            >>> list(Tag.process_contents([1, 2]))
-            [u'1', u'2']
-            >>> list(Tag.process_contents([1], [2]))
-            [u'1', u'2']
-            >>> list(Tag.process_contents([1, [2]]))
-            [u'1', u'2']
-            >>> list(Tag.process_contents(n + 1 for n in xrange(2)))
-            [u'1', u'2']
-            >>> list(Tag.process_contents((1, 2)))
-            [u'1', u'2']
-            >>> list(Tag.process_contents((1), (2, )))
-            [u'1', u'2']
-
-            Our own HTML-aware objects are returned as-is:
-            
-            >>> list(Tag.process_contents(Tag.build('foo')))
-            [tag('foo')]
-            >>> list(Tag.process_contents(Text(u'bar')))
-            [Text(u'bar')]
-            
-            All other objects are converted to unicode:
-            
-            >>> list(Tag.process_contents('foo', u'bar', 0.123, False))
-            [u'foo', u'bar', u'0.123', u'False']
-
-        """
-
-        for arg in args :
-            if arg is None :
-                # skip null: None
-                continue
-            
-            elif isinstance(arg, cls.CONTAINER_TYPES) :
-                # flatten nested container: tuple/list/generator
-                for node in arg :
-                    # recurse
-                    for item in cls.process_contents(node) :
-                        yield item
-
-            elif isinstance(arg, Renderable) :
-                # yield item: Renderable
-                yield arg
-
-            else :
-                # as unicode
-                yield unicode(arg)
-
-
-    def flatten (self) :
-        """
-            Flatten this object into a series of (identlevel, line) tuples.
-        """
-        
-        raise NotImplementedError()
-
-    def iter (self, indent='\t') :
-        """
-            Yield a series of lines for this render.
-        """
-        
-        for indent_level, line in self.flatten() :
-            yield (indent * indent_level) + line
-
-    def unicode (self, newline=u'\n', **opts) :
-        """
-            Render as a single unicode string.
-
-            No newline is returned at the end of the string.
-
-            >>> Tag.build('a', 'b').unicode(newline='X', indent='Y')
-            u'<a>XYbX</a>'
-        """
-
-        return newline.join(self.iter(**opts))
-    
-    # required for print
-    def str (self, newline='\n', encoding='ascii', **opts) :
-        """
-            Render as a single string.
-        """
-        
-        # XXX: try and render as non-unicode, i.e. binary data in the tree?
-        return newline.join(line.encode(encoding) for line in self.iter(**opts))
-    
-    # formal interface using defaults
-    __iter__ = iter
-    __unicode__ = unicode
-    __str__ = str
-
-class Text (Renderable) :
-    """
-        Plain un-structured/un-processed HTML text for output
-        
-        >>> Text(u'foo')
-        Text(u'foo')
-        >>> list(Text('<foo>'))
-        [u'<foo>']
-        >>> list(Text('<foo>', tag('p', 'test')))
-        [u'<foo>', u'<p>', u'\\ttest', u'</p>']
-        >>> list(tag('a', Text('<foo>')))
-        [u'<a>', u'\\t<foo>', u'</a>']
-        >>> list(Text(range(2)))
-        [u'0', u'1']
-
-    """
-
-    def __init__ (self, *contents) :
-        self.contents = self.process_contents(*contents)
-    
-    def flatten (self, indent=0) :
-        for item in self.contents :
-            if isinstance(item, Renderable) :
-                # recursively flatten items
-                for line_indent, line in item.flatten() :
-                    # indented
-                    yield indent + line_indent, line
-
-            else :
-                # render raw value
-                yield indent, unicode(item)
- 
-    def __repr__ (self) :
-        return "Text(%s)" % (', '.join(repr(item) for item in self.contents))
-
-class Tag (Renderable) :
-    """
-        An immutable HTML tag structure, with the tag's name, attributes and contents.
-    """
-    
-    @classmethod
-    def process_attrs (cls, **kwargs) :
-        """
-            Yield the HTML tag attributes from the given set of keyword arguments as a series of (name, value) tuples.
-            
-            Keyword-only options (`_key=value`) are filtered out:
-                
-            >>> dict(Tag.process_attrs(_opt=True))
-            {}
-
-            Attributes with a value of None/False are filtered out:
-
-            >>> dict(Tag.process_attrs(foo=None, bar=False))
-            {}
-            
-            A value given as True is returned as the key's value:
-
-            >>> dict(Tag.process_attrs(quux=True))
-            {'quux': u'quux'}
-
-            A (single) trailing underscore in the attribute name is removed:
-
-            >>> dict(Tag.process_attrs(class_='foo'))
-            {'class': u'foo'}
-            >>> dict(Tag.process_attrs(data__='foo'))
-            {'data_': u'foo'}
-        """
-
-        for key, value in kwargs.iteritems() :
-            # keyword arguments are always pure strings
-            assert type(key) is str
-
-            if value is None or value is False:
-                # omit
-                continue
-            
-            if key.startswith('_') :
-                # option
-                continue
-
-            if key.endswith('_') :
-                # strip underscore
-                key = key[:-1]
-
-            if '_' in key :
-                key = key.replace('_', '-')
-            
-            if value is True :
-                # flag attr
-                value = key
-            
-            yield key, unicode(value)
-        
-    @classmethod
-    def process_opts (cls, **kwargs) :
-        """
-            Return a series of of the keyword-only _options, extracted from the given dict of keyword arguments, as 
-            (k, v) tuples.
-
-            >>> Tag.process_opts(foo='bar', _bar=False)
-            (('bar', False),)
-        """
-        
-        return tuple((k.lstrip('_'), v) for k, v in kwargs.iteritems() if k.startswith('_'))
-    
-    @classmethod
-    def build (cls, _name, *args, **kwargs) :
-        """
-            Factory function for constructing Tags by directly passing in contents/attributes/options as Python function
-            arguments/keyword arguments.
-
-            The first positional argument is the tag's name:
-            
-            >>> Tag.build('foo')
-            tag('foo')
-            
-            Further positional arguments are the tag's contents:
-
-            >>> Tag.build('foo', 'quux', 'bar')
-            tag('foo', u'quux', u'bar')
-
-            All the rules used by process_contents() are available:
-            
-            >>> Tag.build('foo', [1, None], None, (n for n in xrange(2)))
-            tag('foo', u'1', u'0', u'1')
-
-            The special-case for a genexp as the only argument works:
-            
-            >>> f = lambda *args: Tag.build('foo', *args)
-            >>> f('hi' for n in xrange(2))
-            tag('foo', u'hi', u'hi')
-            
-            Attributes are passed as keyword arguments, with or without contents:
-
-            >>> Tag.build('foo', id=1)
-            tag('foo', id=u'1')
-            >>> Tag.build('foo', 'quux', bar=5)
-            tag('foo', u'quux', bar=u'5')
-            >>> Tag.build('foo', class_='ten')
-            tag('foo', class=u'ten')
-            
-            The attribute names don't conflict with positional argument names:
-
-            >>> Tag.build('bar', name='foo')
-            tag('bar', name=u'foo')
-
-            Options are handled as the 'real' keyword arguments:
-
-            >>> print Tag.build('foo', _selfclosing=False)
-            <foo></foo>
-            >>> print Tag.build('foo', _foo='bar')
-            Traceback (most recent call last):
-                ...
-            TypeError: __init__() got an unexpected keyword argument 'foo'
-        """
-
-        # pre-process incoming user values
-        contents = list(cls.process_contents(*args))
-        attrs = dict(cls.process_attrs(**kwargs))
-
-        # XXX: use Python 2.6 keyword-only arguments instead?
-        options = dict(cls.process_opts(**kwargs))
-
-        return cls(_name, contents, attrs, **options)
-
-    def __init__ (self, name, contents=None, attrs=None, selfclosing=None, whitespace_sensitive=None, escape=True) :
-        """
-            Initialize internal Tag state with the given tag identifier, flattened list of content items, dict of
-            attributes and dict of options.
-
-                selfclosing             - set to False to render empty tags as <foo></foo> instead of <foo /> 
-                                          (for XHTML -> HTML compatibility)
-
-                whitespace_sensitive    - do not indent tag content onto separate rows, render the full tag as a single
-                                          row
-
-                escape                  - html-escape non-Renderable's (text)
-
-            Use the build() factory function to build Tag objects using Python's function call argument semantics.
-            
-            The tag name is used a pure string identifier:
-
-            >>> Tag(u'foo', [], {})
-            tag('foo')
-            >>> Tag(u'\\xE4', [], {})
-            Traceback (most recent call last):
-                ...
-            UnicodeEncodeError: 'ascii' codec can't encode character u'\\xe4' in position 0: ordinal not in range(128)
-
-            Contents have their order preserved:
-
-            >>> Tag('foo', [1, 2], {})
-            tag('foo', 1, 2)
-            >>> Tag('foo', [2, 1], {})
-            tag('foo', 2, 1)
-
-            Attributes can be given:
-            
-            >>> Tag('foo', [], dict(foo='bar'))
-            tag('foo', foo='bar')
-
-            Options can be given:
-
-            >>> print Tag('foo', [], {}, selfclosing=False)
-            <foo></foo>
-        """
-        
-        self.name = str(name)
-        self.contents = contents or []
-        self.attrs = attrs or {}
-
-        # options
-        self.selfclosing = selfclosing
-        self.whitespace_sensitive = whitespace_sensitive
-        self.escape = escape
-
-    def __call__ (self, *args, **kwargs) :
-        """
-            Return a new Tag as a copy of this tag, but with the given additional attributes/contents.
-
-            The same rules for function positional/keyword arguments apply as for build()
-
-            >>> Tag.build('foo')('bar')
-            tag('foo', u'bar')
-            >>> Tag.build('a', href='index.html')("Home")
-            tag('a', u'Home', href=u'index.html')
-
-            New contents and attributes can be given freely, using the same rules as for Tag.build:
-
-            >>> Tag.build('bar', None)(5, foo=None, class_='bar')
-            tag('bar', u'5', class=u'bar')
-
-            Tag contents accumulate in order:
-
-            >>> Tag.build('a')('b', ['c'])('d')
-            tag('a', u'b', u'c', u'd')
-
-            Each Tag is immutable, so the called Tag isn't changed, but rather a copy is returned:
-
-            >>> t1 = Tag.build('a'); t2 = t1('b'); t1
-            tag('a')
-
-            Attribute values are replaced:
-
-            >>> Tag.build('foo', a=2)(a=3)
-            tag('foo', a=u'3')
-
-            Options are also supported:
-
-            >>> list(Tag.build('foo')(bar='quux', _selfclosing=False))
-            [u'<foo bar="quux"></foo>']
-        """
-
-        # accumulate contents
-        contents = self.contents + list(self.process_contents(*args))
-
-        # merge attrs
-        attrs = dict(self.attrs)
-        attrs.update(self.process_attrs(**kwargs))
-
-        # options
-        opts = dict(
-            selfclosing = self.selfclosing,
-            whitespace_sensitive = self.whitespace_sensitive,
-        )
-        opts.update(self.process_opts(**kwargs))
-
-        # build updated tag
-        return Tag(self.name, contents, attrs, **opts)
-
-    def render_attrs (self) :
-        """
-            Return the HTML attributes string
-
-            >>> Tag.build('x', foo=5, bar='<', quux=None).render_attrs()
-            u'foo="5" bar="&lt;"'
-            >>> Tag.build('x', foo='a"b').render_attrs()
-            u'foo=\\'a"b\\''
-        """
-
-        return " ".join(
-            (
-                u'%s=%s' % (name, saxutils.quoteattr(value))
-            ) for name, value in self.attrs.iteritems()
-        )
-
-    def flatten_items (self, indent=1) :
-        """
-            Flatten our content into a series of indented lines.
-
-            >>> list(Tag.build('tag', 5).flatten_items())
-            [(1, u'5')]
-            >>> list(Tag.build('tag', 'line1', 'line2').flatten_items())
-            [(1, u'line1'), (1, u'line2')]
-
-            Nested :
-            >>> list(Tag.build('tag', 'a', Tag.build('b', 'bb'), 'c').flatten_items())
-            [(1, u'a'), (1, u'<b>'), (2, u'bb'), (1, u'</b>'), (1, u'c')]
-            >>> list(Tag.build('tag', Tag.build('hr'), Tag.build('foo')('bar')).flatten_items())
-            [(1, u'<hr />'), (1, u'<foo>'), (2, u'bar'), (1, u'</foo>')]
-        """
-
-        for item in self.contents :
-            if isinstance(item, Renderable) :
-                # recursively flatten items
-                for line_indent, line in item.flatten() :
-                    # indented
-                    yield indent + line_indent, line
-
-            elif self.escape :
-                # render HTML-escaped raw value
-                # escape raw values
-                yield indent, saxutils.escape(item)
-
-            else :
-                # render raw value
-                yield indent, unicode(item)
-   
-    def flatten (self) :
-        """
-            Render the tag and all content as a flattened series of indented lines.
-            
-            Empty tags collapse per default:
-
-            >>> list(Tag.build('foo').flatten())
-            [(0, u'<foo />')]
-            >>> list(Tag.build('bar', id=5).flatten())
-            [(0, u'<bar id="5" />')]
-
-            Values are indented inside the start tag:
-
-            >>> list(Tag.build('foo', 'bar', a=5).flatten())
-            [(0, u'<foo a="5">'), (1, u'bar'), (0, u'</foo>')]
-            
-            Nested tags are further indented:
-
-            >>> list(Tag.build('1', '1.1', Tag.build('1.2', '1.2.1'), '1.3', a=5).flatten())
-            [(0, u'<1 a="5">'), (1, u'1.1'), (1, u'<1.2>'), (2, u'1.2.1'), (1, u'</1.2>'), (1, u'1.3'), (0, u'</1>')]
-
-            Empty tags are rendered with a separate closing tag on the same line, if desired:
-
-            >>> list(Tag.build('foo', _selfclosing=False).flatten())
-            [(0, u'<foo></foo>')]
-            >>> list(Tag.build('foo', src='asdf', _selfclosing=False).flatten())
-            [(0, u'<foo src="asdf"></foo>')]
-
-            Tags that are declared as whitespace-sensitive are collapsed onto the same line:
-
-            >>> list(Tag.build('foo', _whitespace_sensitive=True).flatten())
-            [(0, u'<foo />')]
-            >>> list(Tag.build('foo', _whitespace_sensitive=True, _selfclosing=False).flatten())
-            [(0, u'<foo></foo>')]
-            >>> list(Tag.build('foo', 'bar', _whitespace_sensitive=True).flatten())
-            [(0, u'<foo>bar</foo>')]
-            >>> list(Tag.build('foo', 'bar\\nasdf\\tx', _whitespace_sensitive=True).flatten())
-            [(0, u'<foo>bar\\nasdf\\tx</foo>')]
-            >>> list(Tag.build('foo', 'bar', Tag.build('quux', 'asdf'), 'asdf', _whitespace_sensitive=True).flatten())
-            [(0, u'<foo>bar<quux>asdf</quux>asdf</foo>')]
-
-            Embedded HTML given as string values is escaped:
-
-            >>> list(Tag.build('foo', '<asdf>'))
-            [u'<foo>', u'\\t&lt;asdf&gt;', u'</foo>']
-
-            Embedded quotes in attribute values are esacaped:
-
-            >>> list(Tag.build('foo', style='ok;" onload="...'))
-            [u'<foo style=\\'ok;" onload="...\\' />']
-            >>> list(Tag.build('foo', style='ok;\\'" onload=..."\\''))
-            [u'<foo style="ok;\\'&quot; onload=...&quot;\\'" />']
-        """
-
-        # optional attr spec
-        if self.attrs :
-            attrs = " " + self.render_attrs()
-
-        else :
-            attrs = ""
-
-        if not self.contents and self.selfclosing is False :
-            # empty tag, but don't use the self-closing syntax..
-            yield 0, u"<%s%s></%s>" % (self.name, attrs, self.name)
-
-        elif not self.contents  :
-            # self-closing xml tag
-            # do note that this is invalid HTML, and the space before the / is relevant for parsing it as HTML
-            yield 0, u"<%s%s />" % (self.name, attrs)
-
-        elif self.whitespace_sensitive :
-            # join together each line for each child, discarding the indent
-            content = u''.join(line for indent, line in self.flatten_items())
-
-            # render full tag on a single line
-            yield 0, u"<%s%s>%s</%s>" % (self.name, attrs, content, self.name)
-
-        else :
-            # start tag
-            yield 0, u"<%s%s>" % (self.name, attrs)
-
-            # contents, indented one level below the start tag
-            for indent, line in self.flatten_items(indent=1) :
-                yield indent, line
-
-            # close tag
-            yield 0, u"</%s>" % (self.name, )
-
-    def __repr__ (self) :
-        return 'tag(%s)' % ', '.join(
-            [
-                repr(self.name)
-            ] + [
-                repr(c) for c in self.contents
-            ] + [
-                '%s=%r' % (name, value) for name, value in self.attrs.iteritems()
-            ]
-        )
-
-# factory function for Tag
-tag = Tag.build
-
-
-class Document (Renderable) :
-    """
-        A full XHTML 1.0 document with optional XML header, doctype, html[@xmlns].
-
-        >>> list(Document(tags.html('...')))
-        [u'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">', u'<html xmlns="http://www.w3.org/1999/xhtml">', u'\\t...', u'</html>']
-    """
-
-    DOCTYPE = 'html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"'
-    HTML_XMLNS = 'http://www.w3.org/1999/xhtml'
-    HTML_LANG = None
-
-    def __init__ (self, root,
-        doctype=DOCTYPE,
-        html_xmlns=HTML_XMLNS,
-        html_lang=HTML_LANG,
-        xml_version=None, xml_encoding=None, 
-    ) :
-        # add xmlns attr to root node
-        self.root = root(xmlns=html_xmlns, lang=html_lang)
-
-        # store
-        self.doctype = doctype
-        self.xml_declaration = {}
-
-        if xml_version :
-            self.xml_declaration['version'] = xml_version
-
-        if xml_encoding :
-            self.xml_declaration['encoding'] = xml_encoding
-
-    def flatten (self) :
-        """
-            Return the header lines along with the normally formatted <html> tag
-        """
-        
-        if self.xml_declaration :
-            yield 0, u'<?xml %s ?>' % (' '.join('%s="%s"' % kv for kv in self.xml_declaration.iteritems()))
-
-        if self.doctype :
-            yield 0, u'<!DOCTYPE %s>' % (self.doctype)
-
-        # <html>
-        for indent, line in self.root.flatten() :
-            yield indent, line
-
-class TagFactory (object) :
-    """
-        Build Tags with names give as attribute names
-        
-        >>> list(TagFactory().a(href='#')('Yay'))
-        [u'<a href="#">', u'\\tYay', u'</a>']
-
-        >>> list(TagFactory()("><"))
-        [u'><']
-    """
-
-    # full XHTML document
-    document = Document
-    
-    def __getattr__ (self, name) :
-        """
-            Get a Tag object with the given name, but no contents
-
-            >>> TagFactory().a
-            tag('a')
-        """
-
-        return Tag(name)
-
-    def __call__ (self, *values) :
-        """
-            Raw HTML.
-        """
-
-        return Text(*values)
-
-class HTML5TagFactory (TagFactory) :
-    span    = Tag('span', selfclosing=False)
-
-# static instance
-tags = TagFactory()
-html5 = HTML5TagFactory()
-
-# testing
-if __name__ == '__main__' :
-    import doctest
-
-    doctest.testmod()
-
--- a/pvl/web/response.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-from werkzeug.wrappers import Response
-from werkzeug.exceptions import (
-        HTTPException, 
-        BadRequest,         # 400
-        NotFound,           # 404
-)
-from werkzeug.utils import redirect
-
-
-import json as _json
-
-def html (tag, **opts) :
-    """
-        Return text/html response for given pvl.web.html
-    """
-
-    return Response(unicode(tag), mimetype='text/html', **opts)
-
-def json (data, **opts) :
-    """
-        Return text/json response for given object.
-    """
-
-    return Response(_json.dumps(data), mimetype='text/json', **opts)
-
-def image (file, type='png') :
-    """
-        Return image/{type} response for given file-like object.
-    """
- 
-    # respond with file wrapper
-    return Response(file, mimetype='image/{type}'.format(type=type), direct_passthrough=True)        
-
--- a/pvl/web/urls.py	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,8 +0,0 @@
-from werkzeug.routing import Map, Rule
-
-# url_join
-from os.path import join 
-
-def rule (string, endpoint, **opts) :
-    return Rule(string, endpoint=endpoint, defaults=opts)
-
--- a/setup.py	Tue Feb 24 12:47:09 2015 +0200
+++ b/setup.py	Tue Feb 24 14:50:31 2015 +0200
@@ -14,16 +14,16 @@
 
 # TODO: fix to use PEP-396 once available:
 #   https://www.python.org/dev/peps/pep-0396/#classic-distutils
-for line in open('pvl/verkko/__init__.py'):
+for line in open('pvl/hosts.py'):
     if '__version__' in line:
         _, line_version = line.split('=')
         __version__ = line_version.strip().strip("''")
 
 setup(
-    name            = 'pvl-verkko',
+    name            = 'pvl-hosts',
     version         = __version__,
-    description     = "verkko.paivola.fi WSGI",
-    url             = 'http://verkko.paivola.fi/hg/pvl-verkko',
+    description     = "DNS/DHCP hosts management",
+    url             = 'http://verkko.paivola.fi/hg/pvl-hosts',
 
     author          = "Tero Marttila",
     author_email    = "terom@paivola.fi",
@@ -34,39 +34,37 @@
         # pvl.invoke
         'pvl-common',
 
-        # pvl.hosts-import
-        'pvl-ldap',
-        
-        # pvl.verkko.db ->
-        'sqlalchemy',
-
         # pvl.hosts
+        # TODO: replace with ipaddress for py3 forward-compat
         'ipaddr',
     ],
+    extras_require = {
+        # pvl.hosts-import
+        'import': [
+            'pvl-ldap',
+        ],
+
+        # pvl.dhcp-leases/syslog
+        'db': [
+            'sqlalchemy',
+        ],
+    },
     
-    # lib
+    # pvl/
     namespace_packages = [ 'pvl' ],
     py_modules = [
         'pvl.hosts',
     ],
     packages    = [
-        'pvl',
-        'pvl.web',
         'pvl.dhcp',
         'pvl.dns',
-        'pvl.rrd',
-        'pvl.verkko',
     ],
     
-    # bin
-    scripts     = globs('bin/pvl.*-*'),
-    
-    # etc, static
-    data_files  = [
-        ( 'etc/pvl/verkko', [  ] ),
-        ( 'share/pvl/verkko/static/dhcp',   globs('static/dhcp/*.css', 'static/dhcp/*.js')),
-        ( 'share/pvl/verkko/static/rrd',    globs('static/rrd/*.css', 'static/rrd/*.js')),
-        ( 'share/pvl/verkko/static',        globs('static/*.css')),
-    ],
+    # bin/
+    scripts     = globs(
+        'bin/pvl.dhcp-*',
+        'bin/pvl.dns-*',
+        'bin/pvl.hosts-*',
+   ),
 )
 
--- a/static/dhcp/forms.css	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,144 +0,0 @@
-/*
- * Form layout
- *
- * Inspiration taken from http://articles.sitepoint.com/article/fancy-form-design-css
- *      Since turned into a book? :)
- */
-
-/* General form errors, and field-specific error lists will display in red */
-form ul.errors,
-form ul.errors a
-{
-    list-style-type: disc;
-    color: #C00;
-
-    margin: 0em 1em 1em;
-}
-
-fieldset
-{
-    /* A fieldset will not be completely full-width, and will be vertically separated from adjacent fieldsets*/
-    margin: 1em 20% 1em 1em;
-
-    /* A fieldset shall not have any padding of its own, as we position the elements iniside it */
-    padding: 0em;
-
-    border: thin solid #aaa;
-}
-
-/* A fieldset's legend will look similar to a h2 element, except included as part of the frame */
-fieldset legend
-{
-    width: 100%;
-
-    font-size: large;
-    font-weight: bold;
-
-    margin: 0em 1em;
-    padding: 0.5em;
-
-    background-color: #e5e5e5;
-
-    border: 1px dashed #c5c5c5;
-}
-
-/* A fieldset will be internally structured using a list, that is not itself visible */
-fieldset ul
-{
-    list-style-type: none;
-
-    margin: 0px;
-    
-    /* Recreate padding removed from fieldset */
-    padding: 0em 1em;
-}
-
-/* Fields inside a fieldset will be vertically separated */
-fieldset ul > li
-{
-    padding: 0.5em 1em;
-
-    border-top: 1px solid #c5c5c5;
-}
-
-fieldset ul > li:first-child
-{
-    border-top: none;
-}
-
-/* The field identifier displays above the field itself, and is visually weighted, but smaller than the fieldset legend */
-fieldset label,
-fieldset h3         /* used in place of labels for non-input fields... */
-{
-    display: block;
-
-    margin: 0.2em 0em;
-    
-    font-weight: bold;
-    font-size: small;
-}
-
-/* A <strong> inside the label is an error message */
-fieldset label strong
-{
-    color: #C00;
-
-    text-transform: upppercase;
-}
-
-/* The field element are consistently aligned */
-form input,
-form textarea
-{
-    width: 40%;
-
-    border: thin solid #444;
-    padding: 4px;
-}
-
-/* A multi-line text edit widget is wide enough for lots of text */
-form textarea
-{
-    width: 80%;
-}
-
-/* A field that failed validation is highlighted */
-form .failed input,
-form .failed textarea
-{
-    border: thin solid red;
-}
-
-form select,
-form input[type=submit]
-{
-    width: 30%;
-
-    border: auto;
-}
-
-form input[type=reset]
-{
-    width: auto;
-
-    font-size: x-small;
-
-    float: right;
-}
-
-/* Field's descriptive text */
-fieldset p
-{
-    margin: 0.8em;
-
-    font-style: italic;
-    font-size: x-small;
-}
-
-/* Static fields */
-fieldset div.value
-{
-    /* Value is visually indented */
-    margin-left: 2em;
-}
-
--- a/static/dhcp/hosts.css	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,77 +0,0 @@
-/*
- * General
- */
-a
-{
-    color: inherit;
-}
-
-/*
- * Host details
- */
-div.info {
-    width: 80%;
-
-    border: 1px solid #aaa;
-
-    padding: 1em;
-    margin: 1em;
-}
-
-dl {
-    width: 80%;
-    
-    margin: 1em;
-}
-
-dl dt {
-    display: block;
-    clear: both;
-
-    font-weight: bold;
-    
-    margin-top: 0.5em;
-}
-
-dl dd {
-
-}
-
-/*
- * Hosts
- */
-.id
-{
-    font-weight: bold;
-    text-align: right;
-}
-
-.ip,
-.mac
-{
-    font-family: monospace;
-}
-
-td.name
-{
-    /* Wrap */
-    white-space: normal;
-}
-
-
-.seen
-{
-}
-
-.dhcp-search {      background-color: #444488; }
-.dhcp-ack {         background-color: #448844; }
-.dhcp-nak {         background-color: #884444; }
-.dhcp-release {     background-color: #335533; }
-.dhcp-error {       background-color: #ff4444; }
-
-/*
- * Leases 
- */
-.ends.active {
-    font-weight: bold;
-}
--- a/static/dhcp/hosts.js	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,114 +0,0 @@
-/* http://fgnass.github.com/spin.js/ */
-$.fn.spin = function(opts) {
-  this.each(function() {
-    var $this = $(this),
-        data = $this.data();
-
-    if (data.spinner) {
-      data.spinner.stop();
-      delete data.spinner;
-    }
-    if (opts !== false) {
-      data.spinner = new Spinner($.extend({color: $this.css('color')}, opts)).spin(this);
-    }
-  });
-  return this;
-};
-
-$.fn.disabled = function (disabled) {
-    if (disabled)
-        this.attr('disabled', 'disabled');
-    else
-        this.removeAttr('disabled');
-
-    return this;
-};
-
-var Timer = function (interval, cb) {
-    var handle = null;
-
-    this.enable = function () {
-        if (handle) return;
-
-        handle = window.setInterval(cb, interval);
-    };
-
-    this.disable = function () {
-        if (!handle) return;
-
-        window.clearInterval(handle);
-        handle = null;
-    };
-
-    return this;
-};
-
-/*
- * Initialize the realtime host table.
- *
- *  table       - Table to manipulate
- *  url         - base URL for ?t=...
- *  filters     - { attr: value } for ?t=...
- *  t           - ?t=... to poll for
- *
- *  $(document).ready(HostsRealtime(Table(...), ...));
- *
- */
-function HostsRealtime (table, params) {
-    var t = params.t;
-    var refreshTimer = Timer(2 * 1000, refresh);
-
-    // XXX: refresh > interval?
-    function refresh () {
-        console.log("refresh: " + t);
-        
-        var refresh = $('#refresh').disabled(true);
-        var spinner = $('#wrapper').spin();
-
-        var url = params.url;
-        var query = $.param($.extend(params.filters, {t: t }), true);   // using traditional encoding for multi-values
-
-        $.getJSON(url, query, function (data) {
-            var t1 = data.t;
-            var hosts = data.hosts;
-
-            console.log("refresh: " + t + " -> " + t1 + ": " + hosts.length);
-
-            $.each(hosts, function (i, host) {
-                table.update(host);
-            });
-            
-            // update
-            t = t1;
-
-        }).error(function () {
-            alert("Error :("); 
-
-            // disable auto-refresh
-            refreshTimer.disable();
-
-        }).complete(function () {
-            spinner.spin(false);
-            refresh.disabled(false);
-        });
-    }
-
-    return function () {
-        // init
-        $("#refresh").click(function () {
-            // in case diabled on error
-            refreshTimer.enable();
-            refresh();
-            $("#pause").disabled(false);
-        });
-
-        $("#pause").click(function () {
-            console.log("pause");
-            refreshTimer.disable();
-            $("#pause").disabled(true);
-        });
-        
-        // start auto-refresh
-        refreshTimer.enable();
-    }
-}
--- a/static/dhcp/spin.js	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,320 +0,0 @@
-//fgnass.github.com/spin.js#v1.2.7
-!function(window, document, undefined) {
-
-  /**
-   * Copyright (c) 2011 Felix Gnass [fgnass at neteye dot de]
-   * Licensed under the MIT license
-   */
-
-  var prefixes = ['webkit', 'Moz', 'ms', 'O'] /* Vendor prefixes */
-    , animations = {} /* Animation rules keyed by their name */
-    , useCssAnimations
-
-  /**
-   * Utility function to create elements. If no tag name is given,
-   * a DIV is created. Optionally properties can be passed.
-   */
-  function createEl(tag, prop) {
-    var el = document.createElement(tag || 'div')
-      , n
-
-    for(n in prop) el[n] = prop[n]
-    return el
-  }
-
-  /**
-   * Appends children and returns the parent.
-   */
-  function ins(parent /* child1, child2, ...*/) {
-    for (var i=1, n=arguments.length; i<n; i++)
-      parent.appendChild(arguments[i])
-
-    return parent
-  }
-
-  /**
-   * Insert a new stylesheet to hold the @keyframe or VML rules.
-   */
-  var sheet = function() {
-    var el = createEl('style', {type : 'text/css'})
-    ins(document.getElementsByTagName('head')[0], el)
-    return el.sheet || el.styleSheet
-  }()
-
-  /**
-   * Creates an opacity keyframe animation rule and returns its name.
-   * Since most mobile Webkits have timing issues with animation-delay,
-   * we create separate rules for each line/segment.
-   */
-  function addAnimation(alpha, trail, i, lines) {
-    var name = ['opacity', trail, ~~(alpha*100), i, lines].join('-')
-      , start = 0.01 + i/lines*100
-      , z = Math.max(1 - (1-alpha) / trail * (100-start), alpha)
-      , prefix = useCssAnimations.substring(0, useCssAnimations.indexOf('Animation')).toLowerCase()
-      , pre = prefix && '-'+prefix+'-' || ''
-
-    if (!animations[name]) {
-      sheet.insertRule(
-        '@' + pre + 'keyframes ' + name + '{' +
-        '0%{opacity:' + z + '}' +
-        start + '%{opacity:' + alpha + '}' +
-        (start+0.01) + '%{opacity:1}' +
-        (start+trail) % 100 + '%{opacity:' + alpha + '}' +
-        '100%{opacity:' + z + '}' +
-        '}', sheet.cssRules.length)
-
-      animations[name] = 1
-    }
-    return name
-  }
-
-  /**
-   * Tries various vendor prefixes and returns the first supported property.
-   **/
-  function vendor(el, prop) {
-    var s = el.style
-      , pp
-      , i
-
-    if(s[prop] !== undefined) return prop
-    prop = prop.charAt(0).toUpperCase() + prop.slice(1)
-    for(i=0; i<prefixes.length; i++) {
-      pp = prefixes[i]+prop
-      if(s[pp] !== undefined) return pp
-    }
-  }
-
-  /**
-   * Sets multiple style properties at once.
-   */
-  function css(el, prop) {
-    for (var n in prop)
-      el.style[vendor(el, n)||n] = prop[n]
-
-    return el
-  }
-
-  /**
-   * Fills in default values.
-   */
-  function merge(obj) {
-    for (var i=1; i < arguments.length; i++) {
-      var def = arguments[i]
-      for (var n in def)
-        if (obj[n] === undefined) obj[n] = def[n]
-    }
-    return obj
-  }
-
-  /**
-   * Returns the absolute page-offset of the given element.
-   */
-  function pos(el) {
-    var o = { x:el.offsetLeft, y:el.offsetTop }
-    while((el = el.offsetParent))
-      o.x+=el.offsetLeft, o.y+=el.offsetTop
-
-    return o
-  }
-
-  var defaults = {
-    lines: 12,            // The number of lines to draw
-    length: 7,            // The length of each line
-    width: 5,             // The line thickness
-    radius: 10,           // The radius of the inner circle
-    rotate: 0,            // Rotation offset
-    corners: 1,           // Roundness (0..1)
-    color: '#000',        // #rgb or #rrggbb
-    speed: 1,             // Rounds per second
-    trail: 100,           // Afterglow percentage
-    opacity: 1/4,         // Opacity of the lines
-    fps: 20,              // Frames per second when using setTimeout()
-    zIndex: 2e9,          // Use a high z-index by default
-    className: 'spinner', // CSS class to assign to the element
-    top: 'auto',          // center vertically
-    left: 'auto',         // center horizontally
-    position: 'relative'  // element position
-  }
-
-  /** The constructor */
-  var Spinner = function Spinner(o) {
-    if (!this.spin) return new Spinner(o)
-    this.opts = merge(o || {}, Spinner.defaults, defaults)
-  }
-
-  Spinner.defaults = {}
-
-  merge(Spinner.prototype, {
-    spin: function(target) {
-      this.stop()
-      var self = this
-        , o = self.opts
-        , el = self.el = css(createEl(0, {className: o.className}), {position: o.position, width: 0, zIndex: o.zIndex})
-        , mid = o.radius+o.length+o.width
-        , ep // element position
-        , tp // target position
-
-      if (target) {
-        target.insertBefore(el, target.firstChild||null)
-        tp = pos(target)
-        ep = pos(el)
-        css(el, {
-          left: (o.left == 'auto' ? tp.x-ep.x + (target.offsetWidth >> 1) : parseInt(o.left, 10) + mid) + 'px',
-          top: (o.top == 'auto' ? tp.y-ep.y + (target.offsetHeight >> 1) : parseInt(o.top, 10) + mid)  + 'px'
-        })
-      }
-
-      el.setAttribute('aria-role', 'progressbar')
-      self.lines(el, self.opts)
-
-      if (!useCssAnimations) {
-        // No CSS animation support, use setTimeout() instead
-        var i = 0
-          , fps = o.fps
-          , f = fps/o.speed
-          , ostep = (1-o.opacity) / (f*o.trail / 100)
-          , astep = f/o.lines
-
-        ;(function anim() {
-          i++;
-          for (var s=o.lines; s; s--) {
-            var alpha = Math.max(1-(i+s*astep)%f * ostep, o.opacity)
-            self.opacity(el, o.lines-s, alpha, o)
-          }
-          self.timeout = self.el && setTimeout(anim, ~~(1000/fps))
-        })()
-      }
-      return self
-    },
-
-    stop: function() {
-      var el = this.el
-      if (el) {
-        clearTimeout(this.timeout)
-        if (el.parentNode) el.parentNode.removeChild(el)
-        this.el = undefined
-      }
-      return this
-    },
-
-    lines: function(el, o) {
-      var i = 0
-        , seg
-
-      function fill(color, shadow) {
-        return css(createEl(), {
-          position: 'absolute',
-          width: (o.length+o.width) + 'px',
-          height: o.width + 'px',
-          background: color,
-          boxShadow: shadow,
-          transformOrigin: 'left',
-          transform: 'rotate(' + ~~(360/o.lines*i+o.rotate) + 'deg) translate(' + o.radius+'px' +',0)',
-          borderRadius: (o.corners * o.width>>1) + 'px'
-        })
-      }
-
-      for (; i < o.lines; i++) {
-        seg = css(createEl(), {
-          position: 'absolute',
-          top: 1+~(o.width/2) + 'px',
-          transform: o.hwaccel ? 'translate3d(0,0,0)' : '',
-          opacity: o.opacity,
-          animation: useCssAnimations && addAnimation(o.opacity, o.trail, i, o.lines) + ' ' + 1/o.speed + 's linear infinite'
-        })
-
-        if (o.shadow) ins(seg, css(fill('#000', '0 0 4px ' + '#000'), {top: 2+'px'}))
-
-        ins(el, ins(seg, fill(o.color, '0 0 1px rgba(0,0,0,.1)')))
-      }
-      return el
-    },
-
-    opacity: function(el, i, val) {
-      if (i < el.childNodes.length) el.childNodes[i].style.opacity = val
-    }
-
-  })
-
-  /////////////////////////////////////////////////////////////////////////
-  // VML rendering for IE
-  /////////////////////////////////////////////////////////////////////////
-
-  /**
-   * Check and init VML support
-   */
-  ;(function() {
-
-    function vml(tag, attr) {
-      return createEl('<' + tag + ' xmlns="urn:schemas-microsoft.com:vml" class="spin-vml">', attr)
-    }
-
-    var s = css(createEl('group'), {behavior: 'url(#default#VML)'})
-
-    if (!vendor(s, 'transform') && s.adj) {
-
-      // VML support detected. Insert CSS rule ...
-      sheet.addRule('.spin-vml', 'behavior:url(#default#VML)')
-
-      Spinner.prototype.lines = function(el, o) {
-        var r = o.length+o.width
-          , s = 2*r
-
-        function grp() {
-          return css(
-            vml('group', {
-              coordsize: s + ' ' + s,
-              coordorigin: -r + ' ' + -r
-            }),
-            { width: s, height: s }
-          )
-        }
-
-        var margin = -(o.width+o.length)*2 + 'px'
-          , g = css(grp(), {position: 'absolute', top: margin, left: margin})
-          , i
-
-        function seg(i, dx, filter) {
-          ins(g,
-            ins(css(grp(), {rotation: 360 / o.lines * i + 'deg', left: ~~dx}),
-              ins(css(vml('roundrect', {arcsize: o.corners}), {
-                  width: r,
-                  height: o.width,
-                  left: o.radius,
-                  top: -o.width>>1,
-                  filter: filter
-                }),
-                vml('fill', {color: o.color, opacity: o.opacity}),
-                vml('stroke', {opacity: 0}) // transparent stroke to fix color bleeding upon opacity change
-              )
-            )
-          )
-        }
-
-        if (o.shadow)
-          for (i = 1; i <= o.lines; i++)
-            seg(i, -2, 'progid:DXImageTransform.Microsoft.Blur(pixelradius=2,makeshadow=1,shadowopacity=.3)')
-
-        for (i = 1; i <= o.lines; i++) seg(i)
-        return ins(el, g)
-      }
-
-      Spinner.prototype.opacity = function(el, i, val, o) {
-        var c = el.firstChild
-        o = o.shadow && o.lines || 0
-        if (c && i+o < c.childNodes.length) {
-          c = c.childNodes[i+o]; c = c && c.firstChild; c = c && c.firstChild
-          if (c) c.opacity = val
-        }
-      }
-    }
-    else
-      useCssAnimations = vendor(s, 'animation')
-  })()
-
-  if (typeof define == 'function' && define.amd)
-    define(function() { return Spinner })
-  else
-    window.Spinner = Spinner
-
-}(window, document);
--- a/static/dhcp/table.css	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,130 +0,0 @@
-/*
- * Tables
- */
-table
-{
-    width: 80%;
-
-    margin: auto;
-    padding: 0px;
-
-    border-collapse: collapse;
-    border: 1px solid #aaa;
-
-    font-size: small;
-}
-
-/* A caption looks similar to a h2 */
-table caption 
-{
-    font-size: large;
-    font-weight: bold;
-
-    padding: 0.5em;
-    margin: 0em 0em 0.5em;
-
-    background-color: #e5e5e5;
-    border: 1px dashed #c5c5c5;
-}
-
-/* Table header */
-thead tr
-{
-    background-color: #d0d0d0;
-}
-
-thead
-{
-    border-bottom: 2px solid #aaa;
-}
-
-/* Filter */
-tr.filter
-{
-    height: 1em;
-}
-
-tr.filter td
-{
-    padding: 0em 1em;
-}
-
-tr.filter td input
-{
-    display: block;
-    width: 100%;
-
-    border: 0px;
-    padding: 0em; 
-
-    background-color: #d0d0d0;
-
-    font-family: inherit;
-    font-size: inherit;
-}
-
-tr.filter td input:hover
-{
-    background-color: #e0e0e0;
-}
-
-tr.filter td input:focus
-{
-    background-color: #ffffff;
-}
-
-/* Rows */
-tr
-{
-    background-color: #e0e0e0;
-}
-
-tr.alternate
-{
-    background-color: #c8c8c8;
-}
-
-/* Link to tr with URL #foo fragment */
-tr:target
-{
-    background-color: #F5F3B8;
-}
-
-/* Cells */
-td, th
-{
-    border: thin solid #ffffff;
-
-    padding: 0.25em 1em;
-    
-    /* Do not normally wrap */
-    white-space: nowrap;
-}
-
-thead a,
-tbody a
-{
-    display: block;
-}
-
-th
-{
-    text-align: center;
-}
-
-td.hilight
-{
-    font-weight: bold;
-}
-
-/* Footer */
-tfoot
-{
-    border-top: 2px solid #aaa;
-}
-
-tfoot tr
-{
-    text-align: center;
-}
-
--- a/static/dhcp/table.js	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,70 +0,0 @@
-/*
- * Dynamic pvl.verkko.table frontend.
- */
-function html (tag, params, html) {
-    return $("<" + tag + " />", $.extend({html: html}, params));
-};
-
-/*
- *  item_url    - base URL path to /items/0 by id=0
- *  columns     - [ column_name ]
- *  animate     - attempt to animate table..
- */
-function Table (table, params) {
-    var tbody = table.children('tbody');
-    var animate = params.animate || false;
-
-    if (animate)
-        table.css('display', 'block');
-
-    /*
-     * Render <tr> for item.
-     */
-    function render_tr (item) {
-        var columns = [
-            html("th", {}, 
-                html("a", { href: params.item_url.replace('0', item.id) }, "#" )
-            ),
-        ];
-
-        $.each(params.columns, function (i, column) {
-            var col = item[column];
-            var td = html("td", { title: col.title }, col.html);
-
-            $.each(col.css, function (i, css) {
-                td.addClass(css);
-            });
-
-            columns.push(td);
-        });
-
-        return html("tr", { id: item.id }, columns);
-    }
-        
-    return {
-        animate:    animate,
-
-        /*
-         * Update given item into our <table>
-         */
-        update: function (item) {
-            var tr = $('#' + item.id);
-
-            if (tr.length) {
-                if (animate) tr.slideUp();
-            } else {
-                tr = render_tr(item)
-
-                if (animate) tr.hide();
-            }
-            
-            // move to top
-            tr.prependTo(tbody);
-
-            if (animate)
-                tr.slideDown();
-            else
-                tr.effect("highlight", {}, 'slow');
-        }
-    }
-};
--- a/static/rrd/rrd.css	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,61 +0,0 @@
-/* Nagivation breadcrumb */
-div#breadcrumb
-{
-    padding: 5pt;
-
-    border-bottom: 1px dotted #aaa;
-}
-
-div#breadcrumb a
-{
-    color: #444;
-	font-weight: bold;
-	text-decoration: none;
-}
-
-div#breadcrumb a:hover
-{
-	text-decoration: underline;
-}
-
-/* Directory overview */
-#overview ul
-{
-    list-style-type: none;
-}
-
-#overview ul li
-{
-    margin: 5pt;
-    padding: 5pt;
-}
-
-#overview ul li.even
-{
-    background-color: #fff;
-}
-
-#overview ul li.odd
-{
-    background-color: #eee;
-}
-
-#overview ul li h3
-{
-    margin: 0px;
-    padding: 5pt;
-}
-
-#overview ul a
-{
-    color: #444;
-	font-weight: bold;
-    font-size: large;
-	text-decoration: none;
-}
-
-#overview ul a:hover
-{
-	text-decoration: underline;
-}
-
--- a/static/wlan.css	Tue Feb 24 12:47:09 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,26 +0,0 @@
-/*
- * General
- */
-a
-{
-    color: inherit;
-}
-
-/*
- * Hosts
- */
-.id
-{
-    font-weight: bold;
-    text-align: right;
-}
-
-.ap,
-.sta
-{
-    font-family: monospace;
-}
-
-.seen
-{
-}