add basic session & credential dealing

This commit is contained in:
ducklet 2021-01-30 13:41:25 +01:00
parent f6bf544f54
commit f4cf26a33e
7 changed files with 128 additions and 52 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.pyc

View file

@ -1,5 +1,10 @@
"use strict" "use strict"
/* global document, window */
const crypto = window.crypto
const location = document.location
const storage = window.sessionStorage
// TODOs // TODOs
// - measure/report latency // - measure/report latency
// - use server reported time to find winner // - use server reported time to find winner
@ -44,7 +49,8 @@ let socket,
servertime, servertime,
toffset_ms, toffset_ms,
clients = [], clients = [],
me me,
session_key
function hide(e) { function hide(e) {
q(`#${e}`).style.display = "none" q(`#${e}`).style.display = "none"
@ -60,7 +66,7 @@ function session_id() {
} }
function new_session_id() { function new_session_id() {
if (!window.crypto) { if (!crypto) {
return Math.random().toString(36).substr(2) return Math.random().toString(36).substr(2)
} }
const data = new Uint8Array(10) const data = new Uint8Array(10)
@ -96,7 +102,7 @@ function redraw_clients(me, clients) {
text: c.name, text: c.name,
data: { cid: c.id }, data: { cid: c.id },
appendTo: ul, appendTo: ul,
cls: c.id === me ? "me" : "", cls: c.id === me.id ? "me" : "",
}) })
} }
} }
@ -193,8 +199,14 @@ function setup_ws() {
servertime = msg.value servertime = msg.value
toffset_ms = performance.now() toffset_ms = performance.now()
} else if (msg.type === "id") { } else if (msg.type === "id") {
me = msg.value me = { id: msg.id, key: msg.key }
storage["my_id"] = me.id
storage["my_key"] = me.key
redraw_clients(me, clients) redraw_clients(me, clients)
} else if (msg.type === "session_key") {
session_key = { path: msg.path, key: msg.key }
storage["session_path"] = session_key.path
storage["session_key"] = session_key.key
} else if (msg.type === "buzz") { } else if (msg.type === "buzz") {
const buzztime_ns = msg.time const buzztime_ns = msg.time
const client_id = msg.client const client_id = msg.client

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
from . import config
from .quiz import server from .quiz import server
@ -12,7 +13,7 @@ def main():
level=logging.INFO, level=logging.INFO,
) )
asyncio.get_event_loop().run_until_complete(server()) asyncio.get_event_loop().run_until_complete(server(config.ws_host, config.ws_port))
asyncio.get_event_loop().run_forever() asyncio.get_event_loop().run_forever()

3
quiz/config.py Normal file
View file

@ -0,0 +1,3 @@
path_prefix = "/quiz/"
ws_host = "0.0.0.0"
ws_port = 8765

View file

@ -1,7 +1,6 @@
import asyncio import asyncio
import logging import logging
import unicodedata import unicodedata
from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from json import dumps, loads from json import dumps, loads
from secrets import token_hex from secrets import token_hex
@ -10,39 +9,70 @@ from typing import *
import websockets import websockets
from . import config
log = logging.getLogger(__name__)
Path = str
Token = NewType("Token", str)
Websocket = websockets.WebSocketServerProtocol Websocket = websockets.WebSocketServerProtocol
def token() -> Token:
return Token(token_hex(8))
@dataclass @dataclass
class Client: class Client:
ws: Websocket ws: Websocket
path: str path: Path
id: str = field(default_factory=lambda: token_hex(8)) id: Token = field(default_factory=token)
name: str = "" name: str = ""
points: int = 0
secret: Token = field(default_factory=token)
session: Optional["Session"] = None
def __str__(self): def __str__(self):
return f"{ascii(self.id)[1:-1]}:{ascii(self.name)[1:-1]}" return f"{ascii(self.id)[1:-1]}:{ascii(self.name)[1:-1]}"
@property
def is_admin(self):
return self.session is not None and self.session.admin == self.id
sessions: dict[str, dict[str, Client]] = defaultdict(dict) def others(self, include_self=True) -> Iterable["Client"]:
if self.session is None:
log = logging.getLogger("buzzer") return []
clients = self.session.clients.values()
if include_self:
return clients
return [c for c in clients if c.id != self.id]
async def send_time(client): @dataclass
await client.ws.send(dumps({"type": "time", "value": perf_counter_ns()})) class Session:
path: Path
admin: Token
secret: Token = field(default_factory=token)
clients: dict[Token, Client] = field(default_factory=dict)
async def send_buzz(target, client, time): def msg(type_: str, **args):
await target.ws.send(dumps({"type": "buzz", "client": client.id, "time": time})) return dumps({"type": type_, **args})
async def send_clients(client): async def send_time(client: Client):
clients = [ await client.ws.send(msg("time", value=perf_counter_ns()))
{"name": c.name or "<noname>", "id": c.id}
for c in sessions[client.path].values()
] async def send_buzz(target, client: Client, time):
await client.ws.send(dumps({"type": "clients", "value": clients})) await target.ws.send(msg("buzz", client=client.id, time=time))
async def send_clients(client: Client):
if not client.session:
return
clients = [{"name": c.name or "<noname>", "id": c.id} for c in client.others()]
await client.ws.send(msg("clients", value=clients))
async def wait(coros, **kwds): async def wait(coros, **kwds):
@ -53,33 +83,45 @@ async def wait(coros, **kwds):
return await asyncio.wait(tasks, **kwds) return await asyncio.wait(tasks, **kwds)
async def broadcast_client(client): async def broadcast_client(client: Client):
msg = dumps( if not client.session:
{ return
"type": "client", m = msg("client", value={"name": client.name or "<noname>", "id": client.id})
"value": {"name": client.name or "<noname>", "id": client.id}, await wait(c.ws.send(m) for c in client.others())
}
async def broadcast_clients(client: Client):
if not client.session:
return
await wait(send_clients(c) for c in client.others())
async def broadcast_buzz(client: Client, time):
if not client.session:
return
await wait(send_buzz(c, client, time) for c in client.others())
async def send_credentials(client: Client):
await client.ws.send(msg("id", id=client.id, key=client.secret))
async def send_keys_to_the_city(client: Client):
if not client.session:
return
await client.ws.send(
msg("session_key", path=client.path, key=client.session.secret)
) )
await wait(c.ws.send(msg) for c in sessions[client.path].values())
async def broadcast_clients(client): async def send_hello(client: Client):
await wait(send_clients(c) for c in sessions[client.path].values()) msgs = [send_time(client), send_credentials(client), send_clients(client)]
if client.is_admin:
msgs.append(send_keys_to_the_city(client))
await wait(msgs)
async def broadcast_buzz(client, time): async def send_heartbeat(client: Client):
await wait(send_buzz(c, client, time) for c in sessions[client.path].values())
async def send_id(client):
await client.ws.send(dumps({"type": "id", "value": client.id}))
async def send_hello(client):
await wait([send_time(client), send_id(client), send_clients(client)])
async def send_heartbeat(client):
await asyncio.sleep(5.0) await asyncio.sleep(5.0)
await send_time(client) await send_time(client)
@ -89,7 +131,7 @@ def printable(s: str) -> str:
return "".join(c for c in s if not unicodedata.category(c).startswith("C")) return "".join(c for c in s if not unicodedata.category(c).startswith("C"))
async def handle_messages(client): async def handle_messages(client: Client):
async for message in client.ws: async for message in client.ws:
log.debug("[%s] got a message: %a", client, message) log.debug("[%s] got a message: %a", client, message)
mdata = loads(message) mdata = loads(message)
@ -107,7 +149,7 @@ async def handle_messages(client):
log.error("[%s] received borked message", client) log.error("[%s] received borked message", client)
async def juggle(client): async def juggle(client: Client):
while client.ws.open: while client.ws.open:
done, pending = await wait( done, pending = await wait(
[send_heartbeat(client), handle_messages(client)], [send_heartbeat(client), handle_messages(client)],
@ -117,24 +159,41 @@ async def juggle(client):
task.cancel() task.cancel()
sessions: dict[Path, Session] = {}
async def connected(ws: Websocket, path: str): async def connected(ws: Websocket, path: str):
if not path.startswith("/quiz/"): # We'll throw out anything not starting with a certain path prefix just to
# get rid of internet spam - mass scans for security problems, etc.
# No need to waste resources on this kinda crap.
# Ideally the same rule should already be enforced by an upstream proxy.
if not path.startswith(config.path_prefix):
await ws.close() await ws.close()
return return
client = Client(ws, path) client = Client(ws, path)
log.info("[%s] new client on %a", client, path) log.info("[%s] new client on %a", client, path)
sessions[path][client.id] = client
if path not in sessions:
sessions[path] = Session(path, admin=client.id)
log.info("[%s] new session on %a", client, path)
sessions[path].clients[client.id] = client
client.session = sessions[path] # Note: This creates a ref cycle.
try: try:
await send_hello(client) await send_hello(client)
await juggle(client) await juggle(client)
finally: finally:
log.info("[%s] client disconnected", client) log.info("[%s] client disconnected", client)
del sessions[path][client.id] client.session = (
None # Not sure if this is necessary, but it breaks the ref cycle.
)
del sessions[path].clients[client.id]
await broadcast_clients(client) await broadcast_clients(client)
# Clean up sessions map # Clean up sessions map
if not sessions[path]: if not sessions[path].clients:
del sessions[path] del sessions[path]
def server(host="0.0.0.0", port=8765): def server(host: str, port: int):
return websockets.serve(connected, host, port) return websockets.serve(connected, host, port)

View file

@ -11,4 +11,4 @@ exec docker run --init --name dumpr-quiz-ws \
--label org.dumpr.quiz.service=ws \ --label org.dumpr.quiz.service=ws \
-p 8765:8765 \ -p 8765:8765 \
-v "$RUN_DIR":/var/quiz:ro \ -v "$RUN_DIR":/var/quiz:ro \
"$image":"$tag" "$@" "$image":"$tag"