check in checkinator into hswaw/checkinator

repository: https://code.hackerspace.pl/checkinator
revision: 713c7e6c1a8fd6147522c1a5e3067898a1d8bf7a

Change-Id: I1bd2975a46ec0d9a89d6594fb4b9d49832001627
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1219
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/hswaw/checkinator/.gitignore b/hswaw/checkinator/.gitignore
new file mode 100644
index 0000000..e655d75
--- /dev/null
+++ b/hswaw/checkinator/.gitignore
@@ -0,0 +1,8 @@
+at.cfg
+at.db
+*.egg-info
+venv
+**/*_pb2*.py
+*-config.yaml
+result
+cert-*
diff --git a/hswaw/checkinator/README.rst b/hswaw/checkinator/README.rst
new file mode 100644
index 0000000..a0414d1
--- /dev/null
+++ b/hswaw/checkinator/README.rst
@@ -0,0 +1,32 @@
+`Warsaw Hackerspace`_  presence tracker hosted on https://at.hackersapce.pl. It
+uses dhcpd.leases file to track MAC adressess of devices connected to hs LAN
+network.
+
+.. _Warsaw Hackerspace: https://hackerspace.pl
+
+Setup
+-----
+.. code:: bash
+
+    cp config.yaml.dist config.yaml
+
+    # edit config file using your favourite editor
+    $EDITOR config.yaml
+
+    # create new database file (or copy existing one)
+    sqlite3 at.db < dbsetup.sql
+
+    # create python virtual environment
+    python3 -m venv vevnv
+    ./venv/bin/python3 -m pip install -r requirements
+    ./venv/bin/python3 -m pip install gunicorn
+
+Running
+-------
+.. code:: bash
+
+   ./venv/bin/gunicorn run:app
+
+When running on OpenBSD make sure to pass '--no-sendfile' argument to gunicorn
+command. This will prevent AttributeError on os.sendfile that seems to be
+missing in this marvelous OS-es python3 stdlib.
diff --git a/hswaw/checkinator/at/__init__.py b/hswaw/checkinator/at/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/hswaw/checkinator/at/__init__.py
diff --git a/hswaw/checkinator/at/cmd.py b/hswaw/checkinator/at/cmd.py
new file mode 100644
index 0000000..1cd534e
--- /dev/null
+++ b/hswaw/checkinator/at/cmd.py
@@ -0,0 +1,36 @@
+import argparse
+from pathlib import Path
+from at.dhcp import parse_isc_dhcpd_leases
+from time import time
+
+def list():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("leases", type=Path, help="leases file")
+    parser.add_argument("--timeout", type=int, default=None, help="timeout in minutes")
+    args = parser.parse_args()
+
+    with open(args.leases) as f:
+        offset, devices = parse_isc_dhcpd_leases(f)
+    if args.timeout is not None:
+        devices.purge_stale(args.timeout * 60)
+    print("Found devices:")
+    for device in devices._devices.values():
+        print(device._replace(atime = time() - device.atime))
+
+import grpc
+from .tracker_pb2 import ClientsRequest
+from .tracker_pb2_grpc import DhcpTrackerStub
+
+def format_mac(raw: bytes) -> str:
+    return ':'.join(f'{b:02x}' for b in raw)
+
+def tracker_list():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("address", default='unix:///tmp/checkinator.sock', nargs='?', help="tracker grpc address")
+    parser.add_argument("--timeout", type=int, default=None, help="timeout in minutes")
+    args = parser.parse_args()
+    with grpc.insecure_channel(args.address) as channel:
+        stub = DhcpTrackerStub(channel)
+        response = stub.GetClients(ClientsRequest())
+    for client in response.clients:
+        print(format_mac(client.hw_address), client.last_seen, client.ip_address, client.client_hostname )
diff --git a/hswaw/checkinator/at/dhcp.py b/hswaw/checkinator/at/dhcp.py
new file mode 100644
index 0000000..bee2ace
--- /dev/null
+++ b/hswaw/checkinator/at/dhcp.py
@@ -0,0 +1,234 @@
+import threading
+import os
+import io
+
+import logging
+
+from typing import Tuple, List, Optional, NamedTuple
+from time import sleep, time, mktime
+from datetime import datetime, timezone
+
+logger = logging.getLogger(__name__)
+
+def strfts(ts, format='%d/%m/%Y %H:%M'):
+    return datetime.fromtimestamp(ts).strftime(format)
+
+class DhcpLease(NamedTuple):
+    hwaddr: Optional[str]
+    atime: Optional[float]
+    ip: Optional[str]
+    name: Optional[str]
+
+
+class ActiveDevices:
+    def __init__(self):
+        self._devices = {}
+
+    def purge_stale(self, timeout):
+        now = time()
+        for device in list(self._devices.values()):
+            if now - device.atime > timeout:
+                del self._devices[device.hwaddr]
+
+    def add(self, lease: DhcpLease) -> bool:
+        if lease.atime is None:
+            lease = lease._replace(atime=time())
+        if lease.hwaddr not in self._devices or self._devices[lease.hwaddr].atime < lease.atime:
+            self._devices[lease.hwaddr] = lease
+            return True
+        return False
+
+    def update(self, devices) -> List[str]:
+        '''Add entries from another ActiveDevices instance
+
+        Args:
+            devices: list of entries to be added
+
+        Returns: list of updated enties
+        '''
+
+        updated = []
+        for device in devices._devices.values():
+            if self.add(device):
+                updated.append(device)
+        return updated
+
+class Updater(threading.Thread):
+    def __init__(self,  timeout, logger=logger, *a, **kw):
+        self.timeout = timeout
+        self.lock = threading.Lock()
+        self.logger = logger
+        self.active = ActiveDevices()
+        threading.Thread.__init__(self, *a, **kw)
+        self.daemon = True
+
+    def get_active_devices(self):
+        with self.lock:
+            self.active.purge_stale(self.timeout)
+            return dict(self.active._devices)
+
+    def get_device(self, ip):
+        with self.lock:
+            active_devices = iter(self.get_active_devices().values())
+        for device in active_devices:
+            if device.ip == ip:
+                return device.hwaddr, device.name
+        return None, None
+
+    def update(self, devices: ActiveDevices):
+        for device in devices._devices.values():
+            with self.lock:
+                changed = self.active.add(device)
+            if changed:
+                self.logger.info('updated %s with atime %s and ip %s',
+                    device.hwaddr, strfts(device.atime), device.ip)
+
+#class CapUpdater(Updater):
+#    def __init__(self, cap_file, *a, **kw):
+#        self.cap_file = cap_file
+#        Updater.__init__(self, *a, **kw)
+#
+#    def run(self):
+#        while True:
+#            try:
+#                with open(self.cap_file, 'r', buffering=0) as f:
+#                    self.logger.info('Updater ready on cap file %s',
+#                                    self.cap_file)
+#                    lines = [l.strip() for l in f.read().split('\n')]
+#                    for hwaddr in lines:
+#                        if hwaddr:
+#                            self.update(hwaddr)
+#                self.logger.warning('Cap file %s closed, reopening',
+#                                   self.cap_file)
+#            except Exception as e:
+#                self.logger.error('Updater got an exception:\n' +
+#                                 traceback.format_exc(e))
+#                sleep(10.0)
+
+
+class MtimeUpdater(Updater):
+    def __init__(self, lease_file, *a, **kw):
+        self.lease_file = lease_file
+        self.position = 0
+        self.last_modified = 0
+        Updater.__init__(self, *a, **kw)
+
+    def file_changed(self, f):
+        """Callback on changed lease file
+
+        Args:
+            f: Lease file. File offset can be used to skip already parsed lines.
+
+        Returns: New byte offset pointing after last parsed byte.
+        """
+        return f.tell()
+
+    def _trigger_update(self):
+        self.logger.info('Lease file changed, updating')
+        with open(self.lease_file, 'r') as f:
+            f.seek(self.position)
+            self.position = self.file_changed(f)
+
+    def run(self):
+        """Periodicaly check if file has changed
+
+        From ISC DHCPD manual:
+
+            New leases are appended to the end of the dhcpd.leases file.  In
+            order to prevent the file from becoming arbitrarily large, from
+            time to time dhcpd creates a new dhcpd.leases file from its in-core
+            lease database. Once this file has been written to disk, the old
+            file is renamed dhcpd.leases~, and the new file is renamed
+            dhcpd.leases.
+        """
+        while True:
+            try:
+                stat = os.stat(self.lease_file)
+                mtime = stat.st_mtime
+                size = stat.st_size
+                if size < self.position:
+                    self.logger.info('leases file changed - reseting pointer')
+                    self.position = 0
+                try:
+                    # checking if DHCPD performed cleanup
+                    # cleanup during operation seems to be currently broken
+                    # on customs so this could never execute
+                    purge_time = os.stat(self.lease_file + '~').st_mtime
+                    if purge_time > self.last_modified:
+                        self.logger.info('leases file purged - reseting pointer')
+                        self.position = 0
+                except FileNotFoundError:
+                    pass
+                if mtime > self.last_modified:
+                    self._trigger_update()
+                self.last_modified = mtime
+                sleep(5.0)
+            except Exception:
+                self.logger.exception('Exception in updater')
+                sleep(10.0)
+
+
+class DnsmasqUpdater(MtimeUpdater):
+    def file_changed(self, f):
+        raise NotImplementedError(
+            "This was not tested after adding differential update")
+        for line in f:
+            ts, hwaddr, ip, name, client_id = line.split(' ')
+            self.update(hwaddr, int(ts), ip, name)
+        return f.tell()
+
+def parse_isc_dhcpd_leases(leases_file: io.TextIOBase) -> Tuple[int, ActiveDevices]:
+    """Parse ISC dhcpd server leases file
+
+    Args:
+        leases_file: opened leases file. To skip already parsed part use seek
+            before calling.
+
+    Returns: Byte offset (as returned by tell()) of last parsed entry and
+        dictionary of parsed leases
+    """
+    leases = ActiveDevices()
+
+    ip: Optional[str] = None
+    hwaddr: Optional[str] = None
+    atime: Optional[float] = None
+    name: Optional[str] = None
+
+    lease = False
+    offset = leases_file.tell()
+    while True:
+        # using readline because iter(file) blocks file.tell usage
+        line = leases_file.readline()
+        if not line:
+            return offset, leases
+        line = line.split('#')[0]
+        cmd = line.strip().split()
+        if not cmd:
+            continue
+        if lease:
+            field = cmd[0]
+            if(field == 'starts'):
+                dt = datetime.strptime(' '.join(cmd[2:]),
+                                       '%Y/%m/%d %H:%M:%S;')
+                atime = dt.replace(tzinfo=timezone.utc).timestamp()
+            if(field == 'client-hostname'):
+                name = cmd[1][1:-2]
+            if(field == 'hardware'):
+                hwaddr = cmd[2][:-1]
+            if(field.startswith('}')):
+                offset = leases_file.tell()
+                lease = False
+                if hwaddr is not None and atime is not None:
+                    leases.add(DhcpLease(hwaddr, atime, ip, name))
+                hwaddr, atime = None, None
+        elif cmd[0] == 'lease':
+            ip = cmd[1]
+            hwaddr, atime, name = None, None, None
+            lease = True
+
+
+class DhcpdUpdater(MtimeUpdater):
+    def file_changed(self, f):
+        offset, devices = parse_isc_dhcpd_leases(f)
+        self.update(devices)
+        return offset
diff --git a/hswaw/checkinator/at/templates/account.html b/hswaw/checkinator/at/templates/account.html
new file mode 100644
index 0000000..5e8272a
--- /dev/null
+++ b/hswaw/checkinator/at/templates/account.html
@@ -0,0 +1,34 @@
+{% extends "basic.html" %}
+{% block content %}
+<a href="/">Back to homepage</a>
+<h2>Account settings</h2>
+{% for msg in get_flashed_messages(True) %}
+  <p class="{{ msg[0] }}">{{ msg[1] }}</p>
+{% endfor %}
+<h3>Claimed devices</h3>
+<table class="devices">
+  <tr>
+    <th>MAC</th>
+    <th>Device name</th>
+    <th>Visible</th>
+    <th>Toggle visibility</th>
+    <th>Delete</th>
+  </tr>
+{% for device in devices %}
+  <tr>
+    <td>{{ device.hwaddr }}</td>
+    <td>{{ device.name }}</td>
+    {% if device.ignored %}
+      <td class="invisible">invisible</td>
+      <td><a href="devices/{{ device.hwaddr }}/show">make visible</a></td>
+    {% else %}
+    <td class="visible">visible</td>
+      <td><a href="devices/{{ device.hwaddr }}/hide">make invisible</a></td>
+    {% endif %}
+    <td><a href="devices/{{ device.hwaddr }}/delete">delete device</a></td>
+  </tr>
+  </tbody>
+{% endfor%}
+</table>
+<p><a href="/claim">claim this device</a>
+{% endblock %}
diff --git a/hswaw/checkinator/at/templates/admin.html b/hswaw/checkinator/at/templates/admin.html
new file mode 100644
index 0000000..c2fcb4b
--- /dev/null
+++ b/hswaw/checkinator/at/templates/admin.html
@@ -0,0 +1,13 @@
+{% extends "basic.html" %}
+{% block content %}
+<table class="devices">
+  <tr>
+    <th>MAC</th>
+    <th>Device type</th>
+  </tr>
+  {% for key, l in data.items() %}
+      {% for item in l %}
+          <tr><td>{{ item }}</td><td>{{ key }}</td></tr>
+      {% endfor %}
+  {% endfor %}
+{% endblock %}
diff --git a/hswaw/checkinator/at/templates/basic.html b/hswaw/checkinator/at/templates/basic.html
new file mode 100644
index 0000000..4f27050
--- /dev/null
+++ b/hswaw/checkinator/at/templates/basic.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html>
+  <head>
+    {% block head %}
+    <link rel="stylesheet" type="text/css" href="/static/css/basic.css">
+    <title>{% block title %}Now at hackerspace{% endblock %}</title>
+    {% endblock %}
+  </head>
+  <body>
+    {% block body %}
+      <div class="login">
+      {% if current_user.is_authenticated %}
+        logged in as {{ current_user.id }} |
+        <a href="account">account</a> | 
+        <a href="{{ url_for('spaceauth.logout') }}">log out</a>
+      {% else %}
+        <a href="{{ url_for('spaceauth.login') }}">login</a>
+      {% endif %}
+      </div>
+      {% block content %}
+      {% endblock %}
+    {% endblock %}
+  </body>
+</html>
diff --git a/hswaw/checkinator/at/templates/claim.html b/hswaw/checkinator/at/templates/claim.html
new file mode 100644
index 0000000..05f2e0b
--- /dev/null
+++ b/hswaw/checkinator/at/templates/claim.html
@@ -0,0 +1,21 @@
+{% extends "basic.html" %}
+{% block content %}
+  <h2>Claiming a device</h2>
+  {% if not hwaddr %}
+    <p class="error">Unknown MAC. Are you sure you're in the hackerspace?</p>
+  {% else %}
+  You are about to claim <strong>{{ hwaddr }}</strong> as <strong>{{ current_user.id }}</strong>. Do you wish to continue?
+  <table>
+    <form action="" method="post">
+    <label><tr>
+      <td>Device name (optional):</td>
+      <td><input type="text" name="name" value="{{ name }}"></td>
+    </tr></label>
+    <tr>
+      <td><input type="submit" value="yes"></td>
+      </form>
+      <td><a href="/"><button>no</button></button></td>
+    </tr>
+  </table>
+  {% endif %}
+{% endblock %}
diff --git a/hswaw/checkinator/at/templates/invalid_ip.html b/hswaw/checkinator/at/templates/invalid_ip.html
new file mode 100644
index 0000000..98b33c2
--- /dev/null
+++ b/hswaw/checkinator/at/templates/invalid_ip.html
@@ -0,0 +1,16 @@
+{% extends "basic.html" %}
+{% block content %}
+  <h2>Claiming a device</h2>
+  <p class="error">Your IP address is outside of hackerspace LAN network. You might want to connect to HS WiFi and disable vpn's and mobile data.</p>
+
+  <p>
+    Make sure you:
+    <ul>
+        <li>connected to HS lan network</li>
+        <li>disabled VPN connections</li>
+        <li>use customs DNS server 10.8.1.2</li>
+    </ul>
+  </p>
+
+  <p> your IP: {{ ip_address }} </p>
+{% endblock %}
diff --git a/hswaw/checkinator/at/templates/login.html b/hswaw/checkinator/at/templates/login.html
new file mode 100644
index 0000000..b4174b8
--- /dev/null
+++ b/hswaw/checkinator/at/templates/login.html
@@ -0,0 +1,17 @@
+<html>
+<body>
+<h2>Login</h2>
+{% for error in get_flashed_messages() %}
+<p class="error">{{ error }}</p>
+{% endfor %}
+<form action="" method="POST">
+<table>
+<label><tr><td>login</td><td><input type="text" name="login" value="{{ login }}"></td></tr></label>
+<label><tr><td>password</td><td><input type="password" name="password"></td></tr></label>
+{% if goto %}
+<input type="hidden" name="goto" value="{{ goto }}">
+{% endif %}
+<tr><td></td><td><input type="submit" value="login"></input></td></tr>
+</form>
+</body>
+</html>
diff --git a/hswaw/checkinator/at/templates/main.html b/hswaw/checkinator/at/templates/main.html
new file mode 100644
index 0000000..6012b21
--- /dev/null
+++ b/hswaw/checkinator/at/templates/main.html
@@ -0,0 +1,34 @@
+{% extends "basic.html" %}
+{% block title %}
+Now at hackerspace
+{% endblock %}
+{% block content %}
+  <h2>Now at hackerspace!</h2>
+  Recently at <a href="http://www.hackerspace.pl">hackerspace</a>:
+  <ul>
+  {% for user, timestamp in users %}
+    <li>
+      <a href="{{ user | wikiurl }}">
+      {{ user }} ({{ timestamp|strfts() }})
+      </a>
+    </li>
+  {% endfor %}
+  </ul>
+  {% trans n_unk=unknown|length %}
+  <p>There is {{ n_unk }} unknown device operating. </p>
+  {% pluralize %}
+  <p>There are {{ n_unk }} unknown devices operating.</p> 
+  {% endtrans %}
+  {% trans n_kek=kektops|length %}
+  <p>There is {{ n_kek }} unknown kektop operating.</p>
+  {% pluralize %}
+  <p>There are {{ n_kek }} unknown kektops operating.</p>
+  {% endtrans %}
+  {% trans n_esp=esps|length %}
+  <p>There is {{ n_esp }} unknown ESP operating.</p>
+  {% pluralize %}
+  <p>There are {{ n_esp }} unknown ESPs operating.</p>
+  {% endtrans %}
+  <hr>
+  <a href="claim">Claim this device!</a>
+{% endblock %}
diff --git a/hswaw/checkinator/at/templates/post_claim.html b/hswaw/checkinator/at/templates/post_claim.html
new file mode 100644
index 0000000..858323b
--- /dev/null
+++ b/hswaw/checkinator/at/templates/post_claim.html
@@ -0,0 +1,11 @@
+{% extends "basic.html" %}
+{% block content %}
+{% if error %}
+<h2>Error!</h2>
+<p class="error">{{ error }}</p>
+{% else %}
+<h2>Success!</h2>
+Congratulations, you just claimed this device!
+<a href="/">go back</a>
+{% endif %}
+{% endblock %}
diff --git a/hswaw/checkinator/at/templates/register.html b/hswaw/checkinator/at/templates/register.html
new file mode 100644
index 0000000..9c6399f
--- /dev/null
+++ b/hswaw/checkinator/at/templates/register.html
@@ -0,0 +1,36 @@
+{% extends "basic.html" %}
+{% block content %}
+<h2>Register a new account</h2>
+{% for error in get_flashed_messages() %}
+<p class="error">{{ error }}</p>
+{% endfor %}
+<form action="" method="POST">
+  <table>
+    <label><tr>
+      <td>login</td>
+      <td><input type="text" name="login" value="{{ login }}"></td>
+    </tr></label>
+    <label><tr>
+      <td>password</td>
+      <td><input type="password" name="password"></td>
+    </tr></label>
+    <label><tr>
+      <td>confirm password</td>
+      <td><input type="password" name="password2"></td>
+    </tr></label>
+    <label><tr>
+      <td>homepage url</td>
+      <td><input type="text" name="url" value="{{ url }}"></td>
+    </tr></label>
+    <label><tr>
+      <td>use wiki page as url</td>
+      <td><input type="checkbox" name="wiki" 
+        value="yes" {% if wiki == 'yes' %}checked="yes"{% endif %}></td>
+    </tr</label>
+    <tr>
+      <td></td>
+      <td><input type="submit" value="register"></input></td>
+    </tr>
+  </table>
+</form>
+{% endblock %}
diff --git a/hswaw/checkinator/at/tracker.proto b/hswaw/checkinator/at/tracker.proto
new file mode 100644
index 0000000..7db4e5c
--- /dev/null
+++ b/hswaw/checkinator/at/tracker.proto
@@ -0,0 +1,31 @@
+syntax = "proto3";
+
+service DhcpTracker {
+  /* get list of clients detected in LAN network */
+  rpc GetClients (ClientsRequest) returns (DhcpClients) {};
+
+  /* get Layer 2 addess (MAC) for LAN ip address (v4 or v6) */
+  rpc GetHwAddr (HwAddrRequest) returns (HwAddrResponse) {};
+}
+
+message ClientsRequest {
+}
+
+message DhcpClient {
+  bytes hw_address = 1;
+  string last_seen = 2;
+  string client_hostname = 3;
+  string ip_address = 4;
+}
+
+message DhcpClients {
+  repeated DhcpClient clients = 1;
+}
+
+message HwAddrRequest {
+  string ip_address = 1; // IPv4 or IPv6 address
+}
+
+message HwAddrResponse {
+  bytes hw_address = 1; // MAC address
+}
diff --git a/hswaw/checkinator/at/tracker.py b/hswaw/checkinator/at/tracker.py
new file mode 100644
index 0000000..18a139e
--- /dev/null
+++ b/hswaw/checkinator/at/tracker.py
@@ -0,0 +1,110 @@
+from at.dhcp import DhcpdUpdater, DhcpLease
+from pathlib import Path
+import yaml
+import grpc
+import json
+import re
+import subprocess
+import logging
+from concurrent import futures
+from datetime import datetime
+
+from .tracker_pb2 import DhcpClient, DhcpClients, HwAddrResponse
+from .tracker_pb2_grpc import DhcpTrackerServicer, add_DhcpTrackerServicer_to_server
+
+import argparse
+parser = argparse.ArgumentParser()
+parser.add_argument("--verbose", help="output more info", action="store_true")
+parser.add_argument("config", type=Path, help="input file")
+
+logging.basicConfig(level=logging.INFO)
+
+def lease_to_client(lease: DhcpLease) -> DhcpClient:
+    return DhcpClient(
+        hw_address = bytes.fromhex(lease.hwaddr.replace(':', '')),
+        last_seen = datetime.utcfromtimestamp(lease.atime).isoformat(),
+        client_hostname = lease.name,
+        ip_address = lease.ip
+    )
+
+class DhcpTrackerServicer(DhcpTrackerServicer):
+    def __init__(self, tracker: DhcpdUpdater, *args, **kwargs):
+        self._tracker = tracker
+        super().__init__(*args, **kwargs)
+
+    def _authorize(self, context):
+        auth = context.auth_context()
+        ctype = auth.get('transport_security_type', 'local')
+        print(ctype)
+        if ctype == [b'ssl']:
+            if b'at.hackerspace.pl' not in context.peer_identities():
+                context.abort(
+                    grpc.StatusCode.PERMISSION_DENIED,
+                    (
+                        "Only at.hackespace.pl is allowed to access raw "
+                        "clients addresses"
+                    )
+                )
+        elif ctype == 'local':
+            # connection from local unix socket is trusted by default
+            pass
+        else:
+            context.abort(
+                grpc.StatusCode.PERMISSION_DENIED,
+                f"Unknown transport type: {ctype}"
+            )
+
+    def GetClients(self, request, context):
+        self._authorize(context)
+
+        clients = [
+            lease_to_client(c) for c in self._tracker.get_active_devices().values()]
+        return DhcpClients(clients = clients)
+
+    def GetHwAddr(self, request, context):
+        self._authorize(context)
+        ip_address = str(request.ip_address)
+        if not re.fullmatch('[0-9a-fA-F:.]*', ip_address):
+            raise ValueError(f'Invalid ip address: {ip_address!r}')
+        logging.info(f'running ip neigh on {ip_address}')
+        r = subprocess.run(['ip', '-json', 'neigh', 'show', ip_address], check=True, capture_output=True)
+        neighs = json.loads(r.stdout)
+        if neighs:
+            return HwAddrResponse(hw_address=bytes.fromhex(neighs[0]['lladdr'].replace(':', '')))
+        return HwAddrResponse(hw_address=None)
+
+def server():
+    args = parser.parse_args()
+    
+    config = yaml.safe_load(args.config.read_text())
+    tracker = DhcpdUpdater(config['LEASE_FILE'], config['TIMEOUT'])
+    tracker.start()
+    
+    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
+    add_DhcpTrackerServicer_to_server(DhcpTrackerServicer(tracker), server)
+
+
+    tls_address = config.get("GRPC_TLS_ADDRESS", None)
+    if tls_address:
+        cert_dir = Path(config.get('GRPC_TLS_CERT_DIR', 'cert'))
+        ca_cert = Path(config.get('GRPC_TLS_CA_CERT', 'ca.pem')).read_bytes()
+
+        server_credentials = grpc.ssl_server_credentials(
+            private_key_certificate_chain_pairs = ((
+                cert_dir.joinpath('key.pem').read_bytes(),
+                cert_dir.joinpath('cert.pem').read_bytes()
+            ),),
+            root_certificates = ca_cert,
+            require_client_auth = True
+        )
+        
+        server.add_secure_port(config.get('GRPC_TLS_ADDRESS', '[::]:2847'), server_credentials)
+
+    unix_socket = config.get('GRPC_UNIX_SOCKET', False)
+    if unix_socket:
+        server.add_insecure_port(f'unix://{unix_socket}')
+
+    if tls_address or unix_socket:
+        print('starting grpc server ...')
+        server.start()
+        server.wait_for_termination()
diff --git a/hswaw/checkinator/at/web.py b/hswaw/checkinator/at/web.py
new file mode 100644
index 0000000..34b6337
--- /dev/null
+++ b/hswaw/checkinator/at/web.py
@@ -0,0 +1,303 @@
+import json
+import sqlite3
+from pathlib import Path
+from datetime import datetime
+from typing import NamedTuple, Iterable, Iterator, List
+from functools import wraps
+from flask import Flask, render_template, abort, g, \
+    redirect, request, url_for, make_response, send_file, \
+    Response
+from base64 import b64decode
+
+
+from spaceauth import SpaceAuth, login_required, current_user, cap_required
+
+# device infomation stored in database
+class DeviceInfo(NamedTuple):
+    hwaddr: str
+    name: str
+    owner: str
+    ignored: bool
+
+def v4addr():
+    if request.headers.getlist("X-Forwarded-For"):
+        r_addr = request.headers.getlist("X-Forwarded-For")[-1]
+    else:
+        r_addr = request.remote_addr
+    if r_addr.startswith('::ffff:'):
+        r_addr = r_addr[7:]
+    return r_addr
+
+
+def req_to_ctx():
+    return dict(iter(request.form.items()))
+
+def get_device_info(conn: sqlite3.Connection, hwaddr: str) -> DeviceInfo:
+    return list(get_device_infos(conn, (hwaddr,)))[0]
+
+
+def get_device_infos(conn: sqlite3.Connection, hwaddrs: Iterable[str]) -> Iterator[DeviceInfo]:
+    hwaddrs = list(hwaddrs)
+    in_clause = '({})'.format(', '.join(['?'] * len(hwaddrs)))
+    stmt = '''select hwaddr, name, ignored, owner from
+        devices where devices.hwaddr in ''' + in_clause
+    for row in conn.execute(stmt, hwaddrs):
+        owner = row['owner'] or ''
+        ignored = row['ignored']
+        yield DeviceInfo(row['hwaddr'], row['name'], owner, ignored)
+
+def app(instance_path, devices_api, config):
+    app = Flask('at', instance_path=instance_path, instance_relative_config=True)
+    app.config.update(config)
+    app.jinja_env.add_extension('jinja2.ext.i18n')
+    app.jinja_env.install_null_translations()
+    app.updater = devices_api
+    
+    if app.config.get('PROXY_FIX'):
+        from werkzeug.contrib.fixers import ProxyFix
+        app.wsgi_app = ProxyFix(app.wsgi_app)
+    
+    app.space_auth = SpaceAuth(app)
+
+
+    def auth_get_user():
+        if config.get('DEBUG', False):
+            if "User" in request.headers:
+                return request.headers.get("User")
+            if "Authorization" in request.headers:
+                raw = b64decode(request.headers.get('Authorization').split(' ')[1])
+                app.logger.info(f'Raw authorization: {raw!s}')
+                return raw.decode().split(':')[0]
+            app.logger.info(request.headers)
+            raise Exception('username not supplied')
+        else:
+            return current_user.id
+
+    def auth_login_required(f):
+        if config.get('DEBUG', False):
+            @wraps(f)
+            def wrapper(*args, **kwargs):
+                try:
+                    auth_get_user()
+                except Exception:
+                    app.logger.exception("auth get exception")
+                    response = make_response('', 401)
+                    response.headers['WWW-Authenticate'] = 'Basic realm="at.hackerspace.pl", charset="UTF-8"'
+                    return response
+                return f(*args, **kwargs)
+            return wrapper
+        else:
+            return login_required(f)
+
+
+    def restrict_ip(prefixes : List[str] = [], exclude : List[str] = []):
+        def decorator(f):
+            @wraps(f)
+            def func(*a, **kw):
+                r_addr = v4addr()
+                if r_addr in exclude:
+                    app.logger.info('got IP %s, rejecting', r_addr)
+                    return render_template('invalid_ip.html', ip_address=r_addr), 403
+
+                for prefix in prefixes:
+                    if r_addr.startswith(prefix):
+                        break
+                else:
+                    app.logger.info('got IP %s, rejecting', r_addr)
+                    return render_template('invalid_ip.html', ip_address=r_addr), 403
+    
+                return f(*a, **kw)
+            return func
+        return decorator
+    
+    
+    @app.template_filter('strfts')
+    def strfts(ts, format='%d/%m/%Y %H:%M'):
+        return datetime.fromtimestamp(ts).strftime(format)
+    
+    
+    @app.template_filter('wikiurl')
+    def wikiurl(user):
+        return app.config['WIKI_URL'].replace("${login}", user)
+    
+    
+    @app.before_request
+    def make_connection():
+        conn = sqlite3.connect(app.config['DB'])
+        conn.row_factory = sqlite3.Row
+        conn.isolation_level = None  # for autocommit mode
+        g.db = conn
+    
+    
+    @app.teardown_request
+    def close_connection(exception):
+        g.db.close()
+    
+    
+    @app.route('/')
+    def main_view():
+        return render_template('main.html', **now_at())
+
+    @app.route('/metrics')
+    def metrics_view():
+        """Render count of different entities, per kind, in Prometheus format."""
+        now = now_at()
+        lines = [
+            "# HELP entities present at the hackerspace according to checkinator / DHCP",
+            "# TYPE people gauge",
+        ]
+        for kind, devices in now.items():
+            # The kind is being directly text-pasted into the metric below -
+            # let's make sure a new kind with special characters doesn't mess
+            # things up too much.
+            if '"' in kind:
+                continue
+            # Not using formatting, as the Prometheus format contains '{' and
+            # '}' characters which throw off Python's .format().
+            line = 'checkinator_now_present_entities{entity_kind="' + kind + '"} '
+            line += str(len(devices))
+            lines.append(line)
+        return Response('\n'.join(lines), mimetype='text/plain')
+    
+    @app.route('/api')
+    def list_all():
+        data = now_at()
+    
+        def prettify_user(xxx_todo_changeme):
+            (user, atime) = xxx_todo_changeme
+            if user == 'greenmaker':
+                user = 'dreammaker'
+            return {
+                'login': user,
+                'timestamp': atime,
+                'pretty_time': strfts(atime),
+            }
+        result = {}
+        result['users'] = list(map(prettify_user, data.pop('users')))
+        result.update((k, len(v)) for k, v in list(data.items()))
+        res = make_response(json.dumps(result), 200)
+        res.headers['Access-Control-Allow-Origin'] = '*'
+        return res
+
+    def now_at():
+        result = dict()
+        devices = app.updater.get_active_devices()
+        macs = list(devices.keys())
+
+        identified_devices = list(get_device_infos(g.db, macs))
+        unknown = set(macs) - set(d.hwaddr for d in identified_devices)
+
+        # das kektop sorting maschine
+        # identify special devices
+        for name, prefixes in app.config['SPECIAL_DEVICES'].items():
+            result[name] = set()
+            prefixes = tuple(prefixes) # startswith accepts tuple as argument
+            for hwaddr in list(unknown):
+                if hwaddr.startswith(prefixes):
+                    result[name].add(hwaddr)
+                    unknown.discard(hwaddr)
+    
+        result['unknown'] = unknown
+    
+        users = {}
+        for info in identified_devices:
+            # append device to user
+            last_seen = users.get(info.owner, 0)
+            if not info.ignored:
+                last_seen = max(last_seen, devices[info.hwaddr].atime)
+                users[info.owner] = last_seen
+
+        result['users'] = sorted(users.items(), key=lambda u_l: (u_l[1], u_l[0]), reverse=True)
+    
+        return result
+    
+    
+    restrict_to_hs = restrict_ip(prefixes=app.config['CLAIMABLE_PREFIXES'],
+                                 exclude=app.config['CLAIMABLE_EXCLUDE'])
+    
+    
+    @app.route('/claim', methods=['GET'])
+    @restrict_to_hs
+    @auth_login_required
+    def claim_form():
+        hwaddr, name = app.updater.get_device(v4addr())
+        return render_template('claim.html', hwaddr=hwaddr, name=name)
+
+    @app.route('/my_ip', methods=['GET'])
+    def get_my_ip():
+        ip = v4addr()
+        hwaddr, name = app.updater.get_device(ip)
+        return f'ip: {ip!r}\nhwaddr: {hwaddr!r}\nhostname: {name!r}\n'
+    
+    @app.route('/claim', methods=['POST'])
+    @restrict_to_hs
+    @auth_login_required
+    def claim():
+        hwaddr, lease_name = app.updater.get_device(v4addr())
+        ctx = None
+        if not hwaddr:
+            ctx = dict(error='Invalid device.')
+        else:
+            login = auth_get_user()
+            try:
+                g.db.execute('''
+    insert into devices (hwaddr, name, owner, ignored) values (?, ?, ?, ?)''',
+                             [hwaddr, request.form['name'], login, False])
+                ctx = {}
+            except sqlite3.Error:
+                error = 'Could not add device! Perhaps someone claimed it?'
+                ctx = dict(error=error)
+        return render_template('post_claim.html', **ctx)
+    
+    def get_user_devices(conn, user):
+        devs = conn.execute('select hwaddr, name, ignored from devices where\
+     owner = ?', [user])
+        device_info = []
+        for row in devs:
+            di = DeviceInfo(row['hwaddr'], row['name'], user, row['ignored'])
+            device_info.append(di)
+        return device_info
+    
+    
+    @app.route('/account', methods=['GET'])
+    @auth_login_required
+    def account():
+        devices = get_user_devices(g.db, auth_get_user())
+        return render_template('account.html', devices=devices)
+    
+    
+    def set_ignored(conn, hwaddr, user, value):
+        return conn.execute('''
+    update devices set ignored = ? where hwaddr = ? and owner = ?''',
+                            [value, hwaddr, user])
+    
+    
+    def delete_device(conn, hwaddr, user):
+        return conn.execute('''
+    delete from devices where hwaddr = ? and owner = ?''',
+                            [hwaddr, user])
+    
+    @app.route('/devices/<id>/<action>/')
+    @auth_login_required
+    def device(id, action):
+        user = auth_get_user()
+        if action == 'hide':
+            set_ignored(g.db, id, user, True)
+        if action == 'show':
+            set_ignored(g.db, id, user, False)
+        if action == 'delete':
+            delete_device(g.db, id, user)
+        return redirect(url_for('account'))
+    
+    
+    @app.route('/admin')
+    @cap_required('staff')
+    def admin():
+        data = now_at()
+        return render_template('admin.html', data=data)
+
+    @app.route('/static/css/basic.css')
+    def css():
+        return send_file(str(Path('./static/css/basic.css').absolute()))
+    
+    return app
diff --git a/hswaw/checkinator/at/webapp.py b/hswaw/checkinator/at/webapp.py
new file mode 100644
index 0000000..0c8bc0c
--- /dev/null
+++ b/hswaw/checkinator/at/webapp.py
@@ -0,0 +1,104 @@
+"""Entry point for running flask application"""
+
+import at.web 
+from at.dhcp import DhcpdUpdater
+from pathlib import Path
+import yaml
+import os
+import ipaddress
+from typing import Tuple, Optional, Dict
+
+import grpc
+from at.tracker_pb2 import ClientsRequest, HwAddrRequest
+from at.tracker_pb2_grpc import DhcpTrackerStub
+from at.dhcp import DhcpLease
+from datetime import datetime
+
+
+def format_mac(raw: bytes) -> str:
+    return ':'.join(f'{b:02x}' for b in raw)
+
+def mac_from_ipv6(address : ipaddress.IPv6Address):
+    if not isinstance(address, ipaddress.IPv6Address):
+        raise ValueError(f"not an IPv6 address: {address}")
+    raw = address.packed[8:]
+    if raw[3:5] != bytes([0xff, 0xfe]):
+        raise ValueError(f"not MAC based IPv6 Address: {address}")
+    mac = bytes([raw[0] ^ 0x02, *raw[1:3], *raw[5:]])
+    return mac
+
+class DevicesApi:
+    def __init__(self, grpc_channel):
+        self._api = DhcpTrackerStub(grpc_channel)
+
+
+    def get_active_devices(self) -> Dict[str, DhcpLease]:
+        devices = self._api.GetClients(ClientsRequest())
+        return { 
+            format_mac(d.hw_address): DhcpLease(
+                hwaddr=format_mac(d.hw_address),
+                atime=datetime.fromisoformat(d.last_seen).timestamp(),
+                ip=d.ip_address,
+                name=d.client_hostname
+            ) for d in devices.clients
+        }
+
+    def get_device(self, ip: str) -> Tuple[Optional[str], Optional[str]]:
+        hw_address = self._api.GetHwAddr(HwAddrRequest(ip_address=ip)).hw_address
+        if hw_address is not None:
+            devices = self._api.GetClients(ClientsRequest())
+            for device in devices.clients:
+                if device.hw_address == hw_address:
+                    return format_mac(hw_address), device.client_hostname
+            return format_mac(hw_address), ""
+
+        address = ipaddress.ip_address(ip)
+        if isinstance(address, ipaddress.IPv6Address):
+            try:
+                mac = mac_from_ipv6(address)
+            except ValueError:
+                pass
+            else:
+                return ( format_mac(mac), "" )
+
+        return None, None
+
+
+config_path = Path(os.environ.get("CHECKINATOR_WEB_CONFIG", 'web-config.yaml'))
+config = yaml.safe_load(config_path.read_text())
+config.update(yaml.safe_load(Path(config["SECRETS_FILE"]).read_text()))
+
+
+tls_address = config.get("GRPC_TLS_ADDRESS", False)
+unix_socket = config.get('GRPC_UNIX_SOCKET', False)
+if tls_address:
+    print("using secure channel")
+    ca_cert = Path(config.get('GRPC_TLS_CA_CERT')).read_bytes()
+    cert_dir = Path(config.get('GRPC_TLS_CERT_DIR'))
+
+    channel_credential = grpc.ssl_channel_credentials(
+        root_certificates = ca_cert,
+        private_key = cert_dir.joinpath('key.pem').read_bytes(),
+        certificate_chain = cert_dir.joinpath('cert.pem').read_bytes(),
+    )
+
+    options = [
+        ('grpc.ssl_target_name_override', 'at.customs.hackerspace.pl')
+    ]
+    channel = grpc.secure_channel(config.get('GRPC_TLS_ADDRESS'), channel_credential, options=options)
+elif unix_socket:
+    channel = grpc.insecure_channel(f'unix://{unix_socket}')
+else:
+    raise Exception("no GRPC_TLS_ADDRESS or GRPC_UNIX_SOCKET set in config file")
+
+app = at.web.app(Path(__file__).parent, DevicesApi(channel), config)
+
+def run_debug():
+    import argparse
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8080, help="http port")
+    parser.add_argument("--ip", type=str, default='127.0.0.1', help="http port")
+    
+    args = parser.parse_args()
+    
+    app.run(args.ip, args.port, debug=True)
diff --git a/hswaw/checkinator/cap.py b/hswaw/checkinator/cap.py
new file mode 100644
index 0000000..7651e6c
--- /dev/null
+++ b/hswaw/checkinator/cap.py
@@ -0,0 +1,30 @@
+import logging
+import pcapy
+import struct
+
+interface = 'wlan0'
+target = './dhcp-cap'
+logger = logging.getLogger()
+logger.setLevel(logging.DEBUG)
+logger.addHandler(logging.StreamHandler())
+
+def hwaddr_ascii(packet):
+    # picking up MAC directly from ethernet frame
+    return ':'.join('%02x' % ord(c) for c in packet[6:12])
+
+def capture_dhcp(itf):
+    f = open(target, 'w')
+    reader = pcapy.open_live(itf, 4096, False, 5000)
+    reader.setfilter('udp dst port 67')
+    def callback(header, packet):
+        hwaddr = hwaddr_ascii(packet)
+        logger.info('Captured dhcp request from %s', hwaddr)
+        f.write(hwaddr + '\n')
+        f.flush()
+    try:
+        while True:
+            reader.dispatch(1, callback)
+    except KeyboardInterrupt:
+        pass
+
+capture_dhcp('wlan0')
diff --git a/hswaw/checkinator/config.dist.yaml b/hswaw/checkinator/config.dist.yaml
new file mode 100644
index 0000000..2582168
--- /dev/null
+++ b/hswaw/checkinator/config.dist.yaml
@@ -0,0 +1,43 @@
+DB: 'at.db'
+DEBUG: false
+CAP_FILE: './dhcp-cap'
+LEASE_FILE: './dhcpd.leases'
+TIMEOUT: 1500
+
+WIKI_URL: 'https://wiki.hackerspace.pl/people:%(login)s:start'
+
+CLAIMABLE_PREFIX: '10.8.0.'
+CLAIMABLE_EXCLUDE: [ ]
+
+SECRET_KEY: 'CHANGEME'
+
+SPACEAUTH_CONSUMER_KEY: 'checkinator'
+SPACEAUTH_CONSUMER_SECRET: 'CHANGEME'
+
+SPECIAL_DEVICES:
+  'kektops':
+    - '90:e6:ba:84'
+  'esps':
+    - 'ec:fa:bc'
+    - 'dc:4f:22'
+    - 'd8:a0:1d'
+    - 'b4:e6:2d'
+    - 'ac:d0:74'
+    - 'a4:7b:9d'
+    - 'a0:20:a6'
+    - '90:97:d5'
+    - '68:c6:3a'
+    - '60:01:94'
+    - '5c:cf:7f'
+    - '54:5a:a6'
+    - '30:ae:a4'
+    - '2c:3a:e8'
+    - '24:b2:de'
+    - '24:0a:c4'
+    - '18:fe:34'
+    - '38:2b:78'
+    - 'bc:dd:c2:'
+  'vms':
+    - '52:54:00' # craptrap VMs
+
+PROXY_FIX: false
diff --git a/hswaw/checkinator/dbsetup.sql b/hswaw/checkinator/dbsetup.sql
new file mode 100644
index 0000000..6f85b51
--- /dev/null
+++ b/hswaw/checkinator/dbsetup.sql
@@ -0,0 +1,6 @@
+create table devices (
+  hwaddr character(17) primary key,
+  name varchar(50),
+  owner varchar(100) not null, 
+  ignored boolean
+);
diff --git a/hswaw/checkinator/default.nix b/hswaw/checkinator/default.nix
new file mode 100644
index 0000000..37f0936
--- /dev/null
+++ b/hswaw/checkinator/default.nix
@@ -0,0 +1,30 @@
+{ pkgs ? (import <nixpkgs> {}).c.unstable_2020-05}:
+
+let
+  spaceauth = pkgs.callPackage "${pkgs.fetchgit {
+    url = "http://code.hackerspace.pl/vuko/nix-spaceauth";
+    rev = "1c289eafe041d7730a834bb437b7173ca4b9e2c9";
+    sha256 = "0f2mhbkm92rlx3a1il3wfr4bq6xghdiajczgg349v6a01iazm4qz";
+  }}/spaceauth.nix" {};
+in pkgs.python3Packages.buildPythonPackage {
+  pname = "checkinator";
+  version = "0.2";
+
+  doCheck = false;
+  src = ./.;
+
+  propagatedBuildInputs = with pkgs; [
+    python3Packages.gunicorn
+    python3Packages.flask
+    python3Packages.pyyaml
+    python3Packages.isodate
+    python3Packages.requests
+    python3Packages.requests-unixsocket
+    python3Packages.grpcio
+    python3Packages.grpcio-tools
+    python3Packages.setuptools
+    python3Packages.protobuf
+    spaceauth
+    iproute
+  ];
+}
diff --git a/hswaw/checkinator/requirements.txt b/hswaw/checkinator/requirements.txt
new file mode 100644
index 0000000..8d2eb32
--- /dev/null
+++ b/hswaw/checkinator/requirements.txt
@@ -0,0 +1,17 @@
+blinker==1.4
+certifi==2017.7.27.1
+chardet==3.0.4
+click==6.7
+Flask==0.12.2
+Flask-Login==0.4.0
+Flask-OAuthlib==0.9.4
+-e git+https://code.hackerspace.pl/informatic/flask-spaceauth@4dd1c63912297d499dcd5631879e45dc6aa1819d#egg=Flask_SpaceAuth
+idna==2.6
+itsdangerous==0.24
+Jinja2==2.9.6
+MarkupSafe==1.0
+oauthlib==2.0.4
+requests==2.18.4
+requests-oauthlib==0.8.0
+urllib3==1.22
+Werkzeug==0.12.2
diff --git a/hswaw/checkinator/run.py b/hswaw/checkinator/run.py
new file mode 100644
index 0000000..eab78ac
--- /dev/null
+++ b/hswaw/checkinator/run.py
@@ -0,0 +1,3 @@
+import at.webapp
+
+app = at.webapp.app
diff --git a/hswaw/checkinator/setup.py b/hswaw/checkinator/setup.py
new file mode 100644
index 0000000..be35cc5
--- /dev/null
+++ b/hswaw/checkinator/setup.py
@@ -0,0 +1,42 @@
+from setuptools import setup
+import grpc_tools.protoc
+import pkg_resources
+from pathlib import Path
+
+setupdir = Path(__file__).parent
+
+proto_include = pkg_resources.resource_filename('grpc_tools', '_proto')
+
+compiled_proto = Path('at/tracker_pb2.py')
+if compiled_proto.exists():
+    compiled_proto.unlink()
+
+grpc_tools.protoc.main([
+    'grpc_tools.protoc',
+    f'-I{setupdir!s}',
+    '--python_out=./',
+    '--grpc_python_out=./',
+    'at/tracker.proto'
+])
+
+assert compiled_proto.exists()
+
+setup(
+    name='hswaw-at',
+    version='0.1',
+    description='warsaw hackerspace checkinator',
+
+    packages=['at'],
+    package_data={"at": ["templates/*"]},
+    python_requires='>=3.6,',
+    install_requires=['Flask', 'requests', 'flask-spaceauth', 'pyyaml', 'grpcio', 'protobuf'],
+    entry_points={
+        'console_scripts': [
+            'checkinator-list=at.cmd:list',
+            'checkinator-tracker=at.tracker:server',
+            'checkinator-tracker-list=at.cmd:tracker_list',
+            'checkinator-tracker-get-hwaddr=at.cmd:tracker_get_hwaddr',
+            'checkinator-web-debug=at.webapp:run_debug'
+        ],
+    },
+)
diff --git a/hswaw/checkinator/static/css/basic.css b/hswaw/checkinator/static/css/basic.css
new file mode 100644
index 0000000..a822965
--- /dev/null
+++ b/hswaw/checkinator/static/css/basic.css
@@ -0,0 +1,46 @@
+body {
+  margin: 5% auto;
+  background: #f8f8f8;
+  color: #222;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+  font-size: 16px;
+  line-height: 1.8;
+  text-shadow: 0 1px 0 #ffffff;
+  max-width: 73%;
+}
+
+code {
+  background: white;
+}
+
+a {
+  border-bottom: 1px solid #222;
+  color: #222;
+  text-decoration: none;
+}
+
+a:hover {
+  border-bottom: 0;
+}
+
+p.error {
+  color: red
+}
+
+p.message {
+  color: green
+}
+
+table.devices, .devices td, .devices th {
+  padding: .5em;
+  border: 1px solid black;
+  border-collapse: collapse;
+}
+
+td.invisible {
+  background-color: #fbb
+}
+
+td.visible {
+  background-color: #bfb
+}
diff --git a/hswaw/checkinator/static/js/passwd.js b/hswaw/checkinator/static/js/passwd.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/hswaw/checkinator/static/js/passwd.js
diff --git a/hswaw/checkinator/tracker-config.dist.yaml b/hswaw/checkinator/tracker-config.dist.yaml
new file mode 100644
index 0000000..681d3d0
--- /dev/null
+++ b/hswaw/checkinator/tracker-config.dist.yaml
@@ -0,0 +1,13 @@
+# path to dhcpd lease file 
+LEASE_FILE: './dhcpd.leases'
+
+# timeout for old leases
+TIMEOUT: 1500
+
+# optional - local trusted socket
+GRPC_UNIX_SOCKET: "unix://tmp/checkinator.sock"
+
+# optional - remote authenticated (TLS cert) socket 
+GRPC_TLS_CERT_DIR: "./cert-tracker"
+GRPC_TLS_CA_CERT: "./ca.pem"
+GRPC_TLS_ADDRESS: "[::1]:2847"
diff --git a/hswaw/checkinator/web-config.dist.yaml b/hswaw/checkinator/web-config.dist.yaml
new file mode 100644
index 0000000..39a8de7
--- /dev/null
+++ b/hswaw/checkinator/web-config.dist.yaml
@@ -0,0 +1,52 @@
+# local sqlite db for storing user and MAC 
+DB: 'at.db'
+
+# debug option interpreted by flask app
+DEBUG: true
+
+# url to member wiki page
+# "${login}" string is replaced by member login (uid)
+WIKI_URL: 'https://wiki.hackerspace.pl/people:%(login)s:start'
+
+CLAIMABLE_PREFIXES:
+  - '10.8.0.'
+  - '2a0d:eb00:4242:0:'
+
+CLAIMABLE_EXCLUDE: [ ]
+
+SECRETS_FILE: "web-secrets.yaml"
+
+SPECIAL_DEVICES:
+  'kektops':
+    - '90:e6:ba:84'
+  'esps':
+    - 'ec:fa:bc'
+    - 'dc:4f:22'
+    - 'd8:a0:1d'
+    - 'b4:e6:2d'
+    - 'ac:d0:74'
+    - 'a4:7b:9d'
+    - 'a0:20:a6'
+    - '90:97:d5'
+    - '68:c6:3a'
+    - '60:01:94'
+    - '5c:cf:7f'
+    - '54:5a:a6'
+    - '30:ae:a4'
+    - '2c:3a:e8'
+    - '24:b2:de'
+    - '24:0a:c4'
+    - '18:fe:34'
+    - '38:2b:78'
+    - 'bc:dd:c2'
+    - 'cc:50:e3'
+  'vms':
+    - '52:54:00' # craptrap VMs
+
+PROXY_FIX: true
+
+#GRPC_UNIX_SOCKET: "./checkinator.sock"
+
+GRPC_TLS_CERT_DIR: "./cert-webapp"
+GRPC_TLS_CA_CERT: "./ca.pem"
+GRPC_TLS_ADDRESS: '[::1]:2847'
diff --git a/hswaw/checkinator/web-secrets.dist.yaml b/hswaw/checkinator/web-secrets.dist.yaml
new file mode 100644
index 0000000..3e2f281
--- /dev/null
+++ b/hswaw/checkinator/web-secrets.dist.yaml
@@ -0,0 +1,3 @@
+SECRET_KEY: 'CHANGEME'
+SPACEAUTH_CONSUMER_KEY: 'checkinator'
+SPACEAUTH_CONSUMER_SECRET: 'CHANGEME'
diff --git a/hswaw/machines/customs.hackerspace.pl/checkinator-repo.json b/hswaw/machines/customs.hackerspace.pl/checkinator-repo.json
deleted file mode 100644
index d870142..0000000
--- a/hswaw/machines/customs.hackerspace.pl/checkinator-repo.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-    "url": "http://code.hackerspace.pl/checkinator",
-    "rev": "713c7e6c1a8fd6147522c1a5e3067898a1d8bf7a",
-    "sha256": "1vhz9jd0hfa0d1hihgkarf6w7z8yqvz4dzk42wzwk0rs25qlcavi"
-}
diff --git a/hswaw/machines/customs.hackerspace.pl/checkinator.nix b/hswaw/machines/customs.hackerspace.pl/checkinator.nix
index 4135f7a..cbd2090 100644
--- a/hswaw/machines/customs.hackerspace.pl/checkinator.nix
+++ b/hswaw/machines/customs.hackerspace.pl/checkinator.nix
@@ -11,7 +11,7 @@
   version = "0.2";
 
   doCheck = false;
-  src = pkgs.fetchgit (builtins.fromJSON (builtins.readFile ./checkinator-repo.json));
+  src = ../../../hswaw/checkinator;
 
   patches = [
     ./checkinator-werkzeug.patch