Updated Readme some. Added a family of messages that can pack/unpack/compress themselves to be analogous to HTTP Req/Resp.

This commit is contained in:
Michael Woods
2025-01-03 15:27:46 -05:00
parent 9275e34c24
commit 44226376c9
3 changed files with 233 additions and 4 deletions

View File

@@ -4,6 +4,9 @@ Basically, this is supposed to be a modernized BBS for radio,
but with clients and servers exchanging binary messages but with clients and servers exchanging binary messages
(which can be compressed automatically) rather than human-typed text. (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: I'm planning several features like:
- automatic compression for all RF communication - automatic compression for all RF communication
@@ -11,7 +14,7 @@ I'm planning several features like:
- RF beacon - RF beacon
- administration over RF - administration over RF
- object storage/retrieval - 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) - 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 an e-mail or an sms gateway (though clever user uploaded scripts could do this instead)
- maybe APRS integration through APRS-IS - maybe APRS integration through APRS-IS

View File

@@ -1,6 +1,10 @@
from pe.connect import Connection from pe.connect import Connection
from threading import Lock from threading import Lock
from msgpack import Unpacker from msgpack import Unpacker
from msgpack import packb, unpackb
from enum import Enum
import bz2
from typing import Union, Self
class PacketServerConnection(Connection): class PacketServerConnection(Connection):
@@ -32,3 +36,207 @@ class PacketServerConnection(Connection):
def query_accept(cls, port, call_from, call_to): def query_accept(cls, port, call_from, call_to):
return True 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"<Request: {self.method.name} '{self.path}'>"
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"<Response: {self.status_code}>"

View File

@@ -1,9 +1,8 @@
import pe import pe
from ..common import PacketServerConnection from ..common import PacketServerConnection
import ax25 import ax25
from configparser import ConfigParser
from pathlib import Path from pathlib import Path
import ZODB, transaction import ZODB, transaction, ZODB.FileStorage
class Server: class Server:
def __init__(self, pe_server: str, port: int, server_callsign: str, data_dir: str = None): 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_server = pe_server
self.pe_port = port self.pe_port = port
if data_dir: 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.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): def server_receiver(self, conn: PacketServerConnection):