Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
485e0d0
HTTP API: COMMIT ALL THE THINGS!
AnotherKamila Jan 31, 2015
ce90890
fix mutable default args ~_~
AnotherKamila Jan 31, 2015
6a4556a
update README
AnotherKamila Jan 31, 2015
f8dc052
db.py: better dict_intersect()
AnotherKamila Feb 1, 2015
a2eb8c9
update README, add "to do next"
AnotherKamila Feb 1, 2015
950e82d
cosmetic changes in db.py
AnotherKamila Feb 1, 2015
0b4e66e
http_api: add stop() function
AnotherKamila Feb 1, 2015
8abc830
screw my mini ORM, this stuff is simple
AnotherKamila Feb 6, 2015
cc2ee8a
add logs DB table + resource
AnotherKamila Feb 6, 2015
d8a8095
add tests (yay!)
AnotherKamila Feb 6, 2015
f0d0fca
mention bootstrap in README
AnotherKamila Feb 6, 2015
02a32cc
Merge branch 'origin/http-api' into http-api
AnotherKamila Feb 6, 2015
f7967a4
fix .gitignore; remove unnecessary import; update README
AnotherKamila Feb 6, 2015
eff9f7b
new config format; 2 executables; UDP echo server; README update
AnotherKamila Feb 7, 2015
250e722
small changes in db and http_api
AnotherKamila Feb 7, 2015
721fc62
mv tables.{sql,txt}
AnotherKamila Feb 7, 2015
eb91e0a
add packet parsing
AnotherKamila Feb 12, 2015
9e2b792
add {http,udp}_host to config
AnotherKamila Feb 15, 2015
792bd9a
add NaCl (yay!) + rewrite udpclient
AnotherKamila Feb 15, 2015
ffe9d69
startup check: all message types have handlers
AnotherKamila Feb 15, 2015
33e9bee
split controller_api into controller_{api,protocol}
AnotherKamila Feb 15, 2015
26e84c6
cleanup: add docstrings & stuff
AnotherKamila Feb 15, 2015
11fcf2e
rm packet_example.bin
AnotherKamila Feb 15, 2015
eee39b2
fix formatting in controller_api
AnotherKamila Feb 15, 2015
aefd60d
DB init: .sql instead of bootstrap.py + weird .txt
AnotherKamila Feb 16, 2015
a8984d1
remove http_api & co.
AnotherKamila Feb 16, 2015
b622e51
fix formatting: Align! Align! Align!
AnotherKamila Feb 16, 2015
7d8811f
remove CherryPy from requirements.txt
AnotherKamila Feb 17, 2015
cf47406
review-induced fixes
AnotherKamila Feb 17, 2015
823f789
change struct declaration format to "vertical"
AnotherKamila Feb 18, 2015
9a7e598
remove requests from requirements.txt
AnotherKamila Feb 18, 2015
23502ef
rename parse_r -> parse_payload
AnotherKamila Feb 18, 2015
ad78ac0
update config.py.example
AnotherKamila Dec 7, 2015
2b164e9
rename reply to response and such
AnotherKamila May 23, 2015
817e5de
change controller_protocol based on review
AnotherKamila Dec 8, 2015
d02e545
more packet-parsing refactoring; structparse now separate
AnotherKamila Apr 3, 2016
292ea11
add {pytest,coverage}-related stuff
AnotherKamila Apr 3, 2016
f426b3a
add unfrozen requirements.txt
AnotherKamila Apr 3, 2016
38e5a24
renames: s/controller_//
AnotherKamila Apr 3, 2016
993366d
clean up structparse and a few imports
AnotherKamila Apr 3, 2016
69e4a69
update requirements
AnotherKamila Apr 3, 2016
af6b24c
kill deadserver.db in favor of records
AnotherKamila Apr 3, 2016
b4420f5
kill that tweetnacl wrapper in favor of pynacl
AnotherKamila Apr 3, 2016
764d6cd
add notes about fun problems
AnotherKamila Apr 3, 2016
0db210a
structparse fixes
AnotherKamila Apr 3, 2016
42346dd
add infrastructure for pluggable packet handlers
AnotherKamila Apr 4, 2016
8166d30
review-induced fixes
AnotherKamila Apr 5, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[report]

source =
structparse
# deadserver

exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if 0:

show_missing = True
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.py[co]
__pycache__/
venv/
config.py
.coverage
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
**Note: This README is out of date. TODO.**

server: the DB + "manager"
==========================

Expand All @@ -15,5 +17,30 @@ Setup
- fish: `. venv/bin/activate.fish`
- csh, tcsh: `source venv/bin/activate.csh`
3. Install dependencies if necessary: `pip install -r requirements.txt`
4. configure: `cp config.py{.example,}; $EDITOR config.py`
5. create DB tables: `psql -U <dbuser> <dbname> -f dbinit.sql`
6. run with `./runserver.py`;
run the HTTP API server with `./runhttp.py`

Running tests
-------------

1. Edit configuration: `cp tests/config.py{.example,}; $EDITOR tests/config.py`
A real Postgres DB is used, you need to specify the connection string.
2. create DB tables: `psql -U <testdbuser> <testdbname> -f dbinit.sql`
3. run with `py.test tests/`
or `py.test --cov gateserver/ --cov-report term-missing tests/` for coverage report

Next to do:
-----------

- split DB table to controller and door
- on request arrival check if client IP matches the one in DB for this ID
- HTTP: rewrite to use Werkzeug instead of CherryPy
- fix DB singleton (who wants a singleton?!)
- CI

Style Guide & such
------------------

The rest doesn't exist yet.
[PEP-8](https://www.python.org/dev/peps/pep-0008/), `import this`. Also: code and design reviews.
9 changes: 9 additions & 0 deletions config.py.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""The server configuration."""

http_host = '0.0.0.0'
http_port = 5047

udp_host = '0.0.0.0' # Use the actual IP address for UDP!
udp_port = 5042

db_url = 'postgresql://user:password@localhost/deadlock'
42 changes: 42 additions & 0 deletions controller_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Quick & dirty client (i.e. the controller end), used for manual testing of the server."""

import socket
import os
import sys

import records

import config
from deadserver.api import *
from deadserver.protocol import *

api = API(config=config, db=records.Database(config.db_url))

def msg(buf):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(buf, (config.udp_host, config.udp_port))
return sock.recv(1024)

def send(id, msgtype, data):
nonce = os.urandom(18)
req = Request(msgtype.value, data)
req_packet = make_packet(id, nonce, req, get_key=api.get_key)
res_packet = msg(req_packet.pack())
return parse_packet(Response, res_packet, get_key=api.get_key)

if __name__ == '__main__':
mac, msgtype = sys.argv[1:]
try:
t = MsgType[msgtype.upper()]
except KeyError:
sys.exit('No such message type: '+msgtype)

indata = sys.stdin.buffer.read()

hdr, res = send(str2id(mac), t, indata)

print(' * * * as {} sent request: {}'.format(mac, str(t)))
print(indata)
print(' * * * received response: {}'.format(str(t)))
print(str(res.status))
print(res.data)
11 changes: 11 additions & 0 deletions dbinit.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS controller (
id macaddr PRIMARY KEY,
ip inet UNIQUE NOT NULL,
key bytea NOT NULL,
name text
);
CREATE TABLE IF NOT EXISTS log (
time timestamp NOT NULL,
ctrl_id macaddr REFERENCES controller,
message text
);
1 change: 1 addition & 0 deletions deadserver/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The Deadlock server -- communicates with controllers."""
43 changes: 43 additions & 0 deletions deadserver/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""The controller ↔ server API -- the business logic.

This knows what should happen for a given request. See `controller_protocol` for
the message format details.
"""

from . import handlers
from . import protocol

class API:
def __init__(self, config, db):
self.config = config
self.db = db

def handle_packet(self, in_buf):
try:
request_header, request = protocol.parse_packet(protocol.Request, in_buf, self.get_key)
handler = handlers.get_handler_for(request.msg_type)
status, response = handler(request_header.controller_id, request.data.val, api=self)
self.log_message(request_header.controller_id, request, status)
response_packet = protocol.make_response_packet_for(request_header, request.msg_type,
status, response, get_key=self.get_key)
return response_packet.pack()
except protocol.BadMessageError as e:
self.log_bad_message(in_buf, e)

# TODO if protocol crypto and insides were better separated, this could just create a
# {de,en}cryption black box and thereby avoid telling the key to anyone else.
def get_key(self, id):
"""Loads the key for this controller from the DB."""
rows = self.db.query('SELECT key FROM controller WHERE id = :id',
id=protocol.id2str(id)).all()
protocol.check(len(rows) == 1, 'unknown controller ID')
return bytes(rows[0]['key'])

def log_message(self, controller_id, request, status):
"""TODO"""
# print(utils.bytes2mac(controller_id), mtype.name, indata, '->', status.name)
print(protocol.id2str(controller_id), request, '->', status.name)

def log_bad_message(self, buf, e):
"""TODO"""
raise e
35 changes: 35 additions & 0 deletions deadserver/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Collects request handlers for the various message types.

How to write a request handler:

Your module must define `function(controller_id, request_data) -> status, response_data`. This must
be registered as a handler for a request type using the `@handles(deadserver.protocol.MsgType)`
decorator. See below for note on importing.

Hello world example:

```python
from deadserver.protocol import MsgType, ResponseStatus

@handles(deadserver.protocol.MsgType.HELLO) # actually, this doesn't exist, but if it did...
def handle_hello(controller_id, data):
return ResponseStatus.OK, b'Hello ' + controller_id + b'! You sent: ' + data
```

See `./open.py` for a real-world example.

----------------------------------------------------------------------------------------------------

In order to be executed (and therefore registered), your handler module must be imported somewhere.
This file is a good place for that, as it is imported by `deadserver.api`. Unless you have a reason
to do this differently, add your handlers below.
"""

### LIST OF ALL STANDARD HANDLER IMPORTS ###########################################################

from . import echotest
from . import open

####################################################################################################

from .defs import get_handler_for # for more convenient access
13 changes: 13 additions & 0 deletions deadserver/handlers/defs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Provides functions for defining request handlers, such as the `handles(msg_type)` decorator."""

_all_handlers = {}

def handles(msg_type):
def decorator(fn):
_all_handlers[msg_type] = fn
fn.handles = msg_type
return fn
return decorator

def get_handler_for(msg_type):
return _all_handlers[msg_type]
9 changes: 9 additions & 0 deletions deadserver/handlers/echotest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Handler for ECHOTEST requests."""

from ..protocol import MsgType, ResponseStatus

from .defs import handles

@handles(MsgType.ECHOTEST)
def handle_hello(controller_id, data, api):
return ResponseStatus.OK, data
17 changes: 17 additions & 0 deletions deadserver/handlers/open.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Handler for OPEN requests."""

from .defs import handles
from . import utils

from structparse import struct, types
from deadserver.protocol import MsgType, ResponseStatus

CardId = types.PascalStr(12)

OpenRequest = struct('OpenRequest', (CardId, 'card_id'))

@handles(MsgType.OPEN)
@utils.unpack_indata_as(OpenRequest)
def handle(controller_id, data, api):
status = ResponseStatus.OK if data.card_id == CardId('hello') else ResponseStatus.ERR
return status, None
26 changes: 26 additions & 0 deletions deadserver/handlers/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Provides helpers for conveniently defining request handlers."""

import functools

from .. import protocol

def unpack_indata_as(struct):
"""Decorator to first unpack the request data buffer into the given struct."""
def decorator(fn):
@functools.wraps(fn)
def decorated(controller_id, indata, api):
try:
unpacked = struct.unpack(indata)
except ValueError as e:
raise protocol.BadMessageError('parsing data failed') from e
return fn(controller_id, unpacked, api)
return decorated
return decorator

def pack_outdata(fn):
"""Decorator to pack the structured response data into bytes."""
@functools.wraps(fn)
def decorated(controller_id, indata, api):
status, outdata = fn(controller_id, indata, api)
return status, outdata.pack()
return decorated
104 changes: 104 additions & 0 deletions deadserver/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""The controller ↔ server protocol message structure.

This knows the data format for the various structures in the protocol. See
`controller_api` for the behavior / business logic.
"""

# TODO: consider [CBOR](http://cbor.io/).
# TODO: With the faster processor, we probably can afford assymetric crypto. Switch if possible.
# TODO: separate the 2 layers of the protocol
# TODO: ... and then blackboxes instead of secret keys

from structparse import struct, types
import nacl.secret
import enum


class BadMessageError(Exception): pass

def check(expression, errmsg):
if not expression: raise BadMessageError(errmsg)


PROTOCOL_VERSION = types.Bytes(2)([0,1])

class MsgType(types.Uint8, enum.Enum):
OPEN = 1
ECHOTEST = 254

class ResponseStatus(types.Uint8, enum.Enum):
OK = 0x01
ERR = 0x10
TRY_AGAIN = 0x11

Request = struct('Request',
(MsgType, 'msg_type'),
(types.Tail, 'data' ))

Response = struct('Response',
(MsgType, 'msg_type'),
(ResponseStatus, 'status' ),
(types.Tail, 'data' ))

PacketHeader = struct('PacketHeader',
(types.Bytes(2), 'protocol_version'),
(types.Bytes(6), 'controller_id' ),
(types.Bytes(18), 'nonce' ))

Packet = struct('Packet',
(PacketHeader, 'header'),
(types.Tail, 'payload'))


def id2str(id):
return ':'.join('{:02x}'.format(x) for x in id.val)

def str2id(s):
return types.Bytes(6)(bytes.fromhex(s.replace(':', '')))


def crypto_unwrap_payload(nonce, payload, key):
return nacl.secret.SecretBox(key).decrypt(payload, nonce)

def crypto_wrap_payload(nonce, payload, key):
# Note: encrypt returns the ciphertext prepended by nonce. We don't want this, so strip it.
return nacl.secret.SecretBox(key).encrypt(payload, nonce)[nacl.secret.SecretBox.NONCE_SIZE:]

def parse_packet(struct, buf, get_key):
"""Parses the buffer into PacketHeader and `struct`, which must be Request or Response.

Decrypts the payload with the key returned by the `get_key` function.
"""
assert struct in [Request, Response]

try:
hdr, tail = PacketHeader.unpack_from(buf)
check(hdr.protocol_version == PROTOCOL_VERSION, 'Invalid protocol version')
payload_buf = crypto_unwrap_payload(hdr.controller_id.val + hdr.nonce.val,
tail, get_key(hdr.controller_id))
payload = struct.unpack(payload_buf)
except ValueError as e:
raise BadMessageError('parsing packet failed') from e

return hdr, payload

def make_packet(controller_id, nonce, payload, get_key):
"""Packs and encrypts the packet headers, request/response headers and data."""
encrypted = crypto_wrap_payload(controller_id.val + nonce, payload.pack(), get_key(controller_id))
return Packet(PacketHeader(protocol_version=PROTOCOL_VERSION,
controller_id=controller_id,
nonce=nonce),
encrypted)

def make_response_packet_for(request_header, msg_type, status, response_data, get_key):
"""Creates a response packet from the status and data for the given request.

Packs status and data into a response, encrypting according to the key returned by `get_key`.
Requires `request_packet` to be valid.
"""
if not response_data: response_data = b''
response = Response(msg_type=msg_type,
status=status,
data=response_data)
response_nonce = bytearray(request_header.nonce.val); response_nonce[-1] ^= 0x1
return make_packet(request_header.controller_id, response_nonce, response, get_key)
Loading