diff --git a/Readme.md b/Readme.md index d2dc393..e26fe09 100644 --- a/Readme.md +++ b/Readme.md @@ -4,6 +4,9 @@ Basically, this is supposed to be a modernized BBS for radio, but with clients and servers exchanging binary messages (which can be compressed automatically) rather than human-typed text. +Right now, it will use ax25 connected sessions through AGWPE, +though I make add an unconnected protocol using UI later on.. + I'm planning several features like: - automatic compression for all RF communication @@ -11,7 +14,7 @@ I'm planning several features like: - RF beacon - administration over RF - object storage/retrieval -- running user-scripts scripts or shell commands on the server in containers with podman/docker +- running user-defined scripts or shell commands on the server in containers with podman/docker - possibly a cron system (again in containers for safety) - maybe an e-mail or an sms gateway (though clever user uploaded scripts could do this instead) - maybe APRS integration through APRS-IS diff --git a/src/packetserver/common/__init__.py b/src/packetserver/common/__init__.py index 438c837..7cff2b5 100644 --- a/src/packetserver/common/__init__.py +++ b/src/packetserver/common/__init__.py @@ -1,6 +1,10 @@ from pe.connect import Connection from threading import Lock from msgpack import Unpacker +from msgpack import packb, unpackb +from enum import Enum +import bz2 +from typing import Union, Self class PacketServerConnection(Connection): @@ -32,3 +36,207 @@ class PacketServerConnection(Connection): def query_accept(cls, port, call_from, call_to): return True + +class Message: + """Base class for communication encapsulated in msgpack objects.""" + + class CompressionType(Enum): + NONE = 0 + BZIP2 = 1 + GZIP = 2 + DEFLATE = 3 + + class MessageType(Enum): + REQUEST = 0 + RESPONSE = 1 + + def __init__(self, msg_type: MessageType, compression: CompressionType, payload: dict): + self.type = Message.MessageType(msg_type) + self.compression = Message.CompressionType(compression) + self.data = payload + + @property + def vars(self) -> dict: + if 'v' in self.data: + if type(self.data['v']) is dict: + return self.data['v'] + return {} + + def get_var(self, key: str): + if 'v' not in self.data: + raise KeyError(f"Variable '{key}' not found.") + if str(key) not in self.data['v']: + raise KeyError(f"Variable '{key}' not found.") + return self.data['v'][str(key)] + + def set_var(self, key: str, value): + if 'v' not in self.data: + self.data['v'] = {} + self.data['v'][str(key)] = value + + @property + def data_bytes(self): + return packb(self.data) + + def pack(self) -> bytes: + output = {'t': self.type.value, 'c': self.compression.value} + data_bytes = self.data_bytes + + if (self.compression is self.CompressionType.NONE) or (len(data_bytes) < 30): + output['d'] = data_bytes + return packb(output) + + if self.compression is self.CompressionType.BZIP2: + compressed = bz2.compress(packb(self.data)) + else: + raise NotImplementedError(f"Compression type {self.compression.name} is not implemented yet.") + + if len(compressed) < len(data_bytes): + output['d'] = compressed + else: + output['d'] = data_bytes + output['c'] = self.CompressionType.NONE.value + return packb(output) + + @property + def payload(self): + if 'd' in self.data: + pl = self.data['d'] + if type(pl) in (dict, str, bytes): + return pl + else: + return str(pl) + else: + return "" + + @payload.setter + def payload(self, payload: Union[str, bytes, dict]): + if type(payload) in (str, bytes, dict): + self.data['d'] = payload + else: + self.data['d'] = str(payload) + + @classmethod + def unpack(cls, msg_bytes: bytes) -> Self: + try: + unpacked = unpackb(msg_bytes) + except Exception as e: + raise ValueError("ERROR: msg_bytes didn't contain a valid msgpack object.\n" + str(e)) + for i in ('t', 'c', 'd'): + if i not in unpacked: + raise ValueError("ERROR: unpacked bytes do not contain a valid Message object.") + + comp = Message.CompressionType(unpacked['c']) + msg_type = Message.MessageType(unpacked['t']) + raw_data = unpacked['d'] + + if comp is Message.CompressionType.NONE: + data = unpackb(raw_data) + elif comp is Message.CompressionType.BZIP2: + data = unpackb(bz2.decompress(raw_data)) + else: + raise NotImplementedError(f"Compression type {comp.name} is not implemented yet.") + return Message(msg_type, comp, data) + +class Request(Message): + class Method(Enum): + GET = 0 + POST = 1 + UPDATE = 2 + DELETE = 3 + + def __init__(self, msg: Message): + if msg.type is not Message.MessageType.REQUEST: + raise ValueError(f"Can't create a Request Object from a {msg.type} Message object.") + + super().__init__(msg.type, msg.compression, msg.data) + + if ('p' in msg.data) and (type(msg.data['p']) is not str): + raise ValueError("Path of Request must be a string.") + + if 'm' in msg.data: + if type(msg.data['m']) is not bytes: + raise ValueError("Method of Request must be bytes.") + self.Method(int(self.data['m'][0])) + + @property + def path(self): + if 'p' in self.data: + return str(self.data['p']) + else: + return "" + + @path.setter + def path(self, path: str): + self.data['p'] = str(path.strip()) + + @property + def method(self) -> Method: + if 'm' in self.data: + return self.Method(int(self.data['m'][0])) + else: + return self.Method.GET + + @method.setter + def method(self, meth: Method): + meth = self.Method(meth) + self.data['m'] = meth.value.to_bytes(1) + + @classmethod + def unpack(cls, msg_bytes: bytes) -> Self: + msg = super().unpack(msg_bytes) + return Request(msg) + + @classmethod + def blank(cls) -> Self: + msg = Message(Message.MessageType.REQUEST, Message.CompressionType.NONE, {}) + return Request(msg) + + def __repr__(self): + return f"" + +class Response(Message): + def __init__(self, msg: Message): + if msg.type is not Message.MessageType.RESPONSE: + raise ValueError(f"Can't create a Response Object from a {msg.type} Message object.") + + super().__init__(msg.type, msg.compression, msg.data) + if 'c' in msg.data: + status_bytes = self.data['c'] + if type(status_bytes) is not bytes: + raise ValueError("Invalid Response data") + status_code = int.from_bytes(status_bytes) + if status_code >= 600: + raise ValueError("Invalid status code.") + + @classmethod + def unpack(cls, msg_bytes: bytes) -> Self: + msg = super().unpack(msg_bytes) + return Response(msg) + + @classmethod + def blank(cls) -> Self: + msg = Message(Message.MessageType.RESPONSE, Message.CompressionType.NONE, {}) + return Response(msg) + + @property + def status_code(self) -> int: + if 'c' in self.data: + status_bytes = self.data['c'] + if type(status_bytes) is not bytes: + raise ValueError("Invalid Response data") + status_code = int.from_bytes(status_bytes) + if status_code >= 600: + raise ValueError("Invalid status code.") + return status_code + else: + return 200 + + @status_code.setter + def status_code(self, code: int): + if (code <= 0) or (code >= 600): + raise ValueError("Status must be a positive integer <= 600") + self.data['c'] = code.to_bytes(2) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/src/packetserver/server/__init__.py b/src/packetserver/server/__init__.py index d0121ce..a24c8ac 100644 --- a/src/packetserver/server/__init__.py +++ b/src/packetserver/server/__init__.py @@ -1,9 +1,8 @@ import pe from ..common import PacketServerConnection import ax25 -from configparser import ConfigParser from pathlib import Path -import ZODB, transaction +import ZODB, transaction, ZODB.FileStorage class Server: def __init__(self, pe_server: str, port: int, server_callsign: str, data_dir: str = None): @@ -13,8 +12,27 @@ class Server: self.pe_server = pe_server self.pe_port = port if data_dir: - if Path.is_dir(data_dir): + data_path = Path(data_dir) + else: + data_path = Path.home().joinpath(".packetserver") + if data_path.is_dir(): + if data_path.joinpath("data.zopedb").exists(): + if not data_path.joinpath("data.zopedb").is_file(): + raise FileExistsError("data.zopedb exists as non-file in specified path") + self.home_dir = data_dir + else: + if data_path.exists(): + raise FileExistsError(f"Non-Directory path '{data_dir}' already exists.") + else: + data_path.mkdir() self.home_dir = data_dir + self.storage = ZODB.FileStorage.FileStorage(self.data_file) + self.db = ZODB.DB(self.storage) + + + @property + def data_file(self) -> str: + return str(Path(self.home_dir).joinpath('data.zopedb')) def server_receiver(self, conn: PacketServerConnection):