139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Kokkoja relay.
|
||
|
|
|
||
|
|
Passes messages between the participants of a session. It stores nothing:
|
||
|
|
no accounts, no pages, no notebooks. Message bodies are encrypted by the
|
||
|
|
clients with a key the relay never sees, so the relay cannot read them.
|
||
|
|
|
||
|
|
The protocol is described in notes/protocol.md. In short:
|
||
|
|
|
||
|
|
* A client connects to ``/room/<room id>``. The relay gives the connection
|
||
|
|
a peer number, unique within the room, and sends ``welcome``.
|
||
|
|
|
||
|
|
* A binary message from a client begins with a 4-byte destination peer
|
||
|
|
number (0 means everyone else in the room). The relay replaces those four
|
||
|
|
bytes with the sender's peer number and forwards the message.
|
||
|
|
|
||
|
|
* The relay sends ``join`` and ``leave`` as peers come and go.
|
||
|
|
|
||
|
|
Requires the ``websockets`` library, version 13 or later.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import asyncio
|
||
|
|
import http
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
import signal
|
||
|
|
|
||
|
|
from websockets.asyncio.server import broadcast, serve
|
||
|
|
from websockets.exceptions import ConnectionClosed
|
||
|
|
|
||
|
|
ROOM_PATH = re.compile(r"^/room/([A-Za-z0-9_-]{22})$")
|
||
|
|
|
||
|
|
MAX_MESSAGE_BYTES = 1 << 20 # Clients split anything larger.
|
||
|
|
MAX_PEERS_PER_ROOM = 32
|
||
|
|
MAX_ROOMS = 500
|
||
|
|
|
||
|
|
# Close codes in the range reserved for applications.
|
||
|
|
CLOSE_ROOM_FULL = 4001
|
||
|
|
CLOSE_RELAY_FULL = 4002
|
||
|
|
CLOSE_BAD_MESSAGE = 4003
|
||
|
|
|
||
|
|
log = logging.getLogger("kokkoja_relay")
|
||
|
|
|
||
|
|
|
||
|
|
class Room:
|
||
|
|
def __init__(self):
|
||
|
|
self.peers = {} # peer number -> connection
|
||
|
|
self.next_peer = 1 # Peer numbers are never reused in a room.
|
||
|
|
|
||
|
|
|
||
|
|
rooms = {} # room id -> Room
|
||
|
|
|
||
|
|
|
||
|
|
def check_path(connection, request):
|
||
|
|
"""Refuse the WebSocket handshake unless the path names a room."""
|
||
|
|
if not ROOM_PATH.match(request.path):
|
||
|
|
return connection.respond(http.HTTPStatus.NOT_FOUND, "Not a Kokkoja room\n")
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def control(message_type, **fields):
|
||
|
|
return json.dumps({"type": message_type, **fields})
|
||
|
|
|
||
|
|
|
||
|
|
async def handle(connection):
|
||
|
|
room_id = ROOM_PATH.match(connection.request.path).group(1)
|
||
|
|
room = rooms.get(room_id)
|
||
|
|
if room is None:
|
||
|
|
if len(rooms) >= MAX_ROOMS:
|
||
|
|
await connection.close(CLOSE_RELAY_FULL, "relay full")
|
||
|
|
return
|
||
|
|
room = rooms[room_id] = Room()
|
||
|
|
if len(room.peers) >= MAX_PEERS_PER_ROOM:
|
||
|
|
await connection.close(CLOSE_ROOM_FULL, "room full")
|
||
|
|
return
|
||
|
|
|
||
|
|
peer = room.next_peer
|
||
|
|
room.next_peer += 1
|
||
|
|
others = sorted(room.peers)
|
||
|
|
room.peers[peer] = connection
|
||
|
|
sender = peer.to_bytes(4, "big")
|
||
|
|
log.info("room %s..: peer %d joined (%d present)", room_id[:4], peer, len(room.peers))
|
||
|
|
|
||
|
|
try:
|
||
|
|
await connection.send(control("welcome", peer=peer, peers=others))
|
||
|
|
broadcast([room.peers[p] for p in others], control("join", peer=peer))
|
||
|
|
async for message in connection:
|
||
|
|
if not isinstance(message, bytes) or len(message) < 4:
|
||
|
|
await connection.close(CLOSE_BAD_MESSAGE, "bad message")
|
||
|
|
break
|
||
|
|
destination = int.from_bytes(message[:4], "big")
|
||
|
|
if destination == 0:
|
||
|
|
targets = [c for p, c in room.peers.items() if p != peer]
|
||
|
|
else:
|
||
|
|
target = room.peers.get(destination)
|
||
|
|
targets = [target] if target is not None and destination != peer else []
|
||
|
|
broadcast(targets, sender + message[4:])
|
||
|
|
except ConnectionClosed:
|
||
|
|
pass
|
||
|
|
finally:
|
||
|
|
del room.peers[peer]
|
||
|
|
if room.peers:
|
||
|
|
broadcast(list(room.peers.values()), control("leave", peer=peer))
|
||
|
|
else:
|
||
|
|
del rooms[room_id]
|
||
|
|
log.info("room %s..: peer %d left (%d present)", room_id[:4], peer, len(room.peers))
|
||
|
|
|
||
|
|
|
||
|
|
async def run(host, port):
|
||
|
|
stop = asyncio.get_running_loop().create_future()
|
||
|
|
|
||
|
|
def stop_once():
|
||
|
|
if not stop.done():
|
||
|
|
stop.set_result(None)
|
||
|
|
|
||
|
|
for signal_number in (signal.SIGINT, signal.SIGTERM):
|
||
|
|
asyncio.get_running_loop().add_signal_handler(signal_number, stop_once)
|
||
|
|
async with serve(handle, host, port, process_request=check_path,
|
||
|
|
max_size=MAX_MESSAGE_BYTES, ping_interval=20, ping_timeout=20):
|
||
|
|
log.info("relay listening on %s port %d", host, port)
|
||
|
|
await stop
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description="Kokkoja relay")
|
||
|
|
parser.add_argument("--host", default="127.0.0.1",
|
||
|
|
help="address to listen on (default 127.0.0.1; 0.0.0.0 for all)")
|
||
|
|
parser.add_argument("--port", type=int, default=8770)
|
||
|
|
arguments = parser.parse_args()
|
||
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||
|
|
# The library reports every connection as well; our own lines suffice.
|
||
|
|
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||
|
|
asyncio.run(run(arguments.host, arguments.port))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|