Reorganized repo a bit

This commit is contained in:
Michael Woods
2025-03-18 22:50:24 -04:00
parent 856e6f429b
commit 338afc7a63
78 changed files with 5647 additions and 15 deletions

View File

@@ -0,0 +1,343 @@
from pe.connect import Connection, ConnectionState
from threading import Lock
from msgpack import Unpacker
from msgpack import packb, unpackb
from enum import Enum
import bz2
from typing import Union, Self
import datetime
import logging
import ax25
class PacketServerConnection(Connection):
connection_subscribers = []
receive_subscribers = []
max_send_size = 2000
def __init__(self, port, call_from, call_to, incoming=False):
super().__init__(port, call_from, call_to, incoming=incoming)
# Now perform any initialization of your own that you might need
self.data = Unpacker()
self.data_lock = Lock()
self.connection_created = datetime.datetime.now(datetime.UTC)
self.connection_last_activity = datetime.datetime.now(datetime.UTC)
self.closing = False
@property
def local_callsign(self):
if self.incoming:
return self.call_to
else:
return self.call_from
@property
def remote_callsign(self):
if self.incoming:
return self.call_from
else:
return self.call_to
def connected(self):
logging.debug("connected")
logging.debug(f"new connection from {self.call_from} to {self.call_to}")
for fn in PacketServerConnection.connection_subscribers:
fn(self)
def disconnected(self):
logging.debug(f"connection disconnected: {self.call_from} -> {self.call_to}")
def data_received(self, pid, data):
self.connection_last_activity = datetime.datetime.now(datetime.UTC)
logging.debug(f"received data: {data}")
with self.data_lock:
logging.debug(f"fed received data to unpacker {data}")
self.data.feed(data)
for fn in PacketServerConnection.receive_subscribers:
logging.debug("found function to notify about received data")
fn(self)
logging.debug("notified function about received data")
def send_data(self, data: Union[bytes, bytearray]):
logging.debug(f"sending data: {data}")
self.connection_last_activity = datetime.datetime.now(datetime.UTC)
if len(data) > self.max_send_size:
logging.debug(f"Large frame detected {len(data)} breaking it up into chunks")
index = 0
counter = 0
while index <= len(data):
logging.debug(f"Sending chunk {counter}")
if (len(data) - index) < self.max_send_size:
super().send_data(data[index:])
break
super().send_data(data[index:index + self.max_send_size])
index = index + self.max_send_size
counter = counter + 1
else:
super().send_data(data)
@classmethod
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
logging.debug("Packing Message")
if (self.compression is self.CompressionType.NONE) or (len(data_bytes) < 30):
output['d'] = data_bytes
output['c'] = self.CompressionType.NONE.value
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, list):
return pl
else:
return str(pl)
else:
return ""
@payload.setter
def payload(self, payload: Union[str, bytes, dict, list]):
logging.debug(f"Setting a message payload: {type(payload)}: {payload}")
if type(payload) in (str, bytes, dict, list):
logging.debug(f"Payload type is {type(payload)}, conversion to string unnecessary")
self.data['d'] = payload
else:
logging.debug("payload type is not in (str, bytes, dict, list); converting to string")
self.data['d'] = str(payload)
logging.debug(f"Final payload is: {type(payload)}: {payload}")
@classmethod
def partial_unpack(cls, msg: dict) -> Self:
unpacked = msg
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)
@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))
if type(unpacked) is not dict:
raise ValueError("ERROR: unpacked message was not a packetserver message.")
for i in ('t', 'c', 'd'):
if i not in unpacked:
raise ValueError("ERROR: unpacked message was not a packetserver message.")
return Message.partial_unpack(unpacked)
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 'p' in self.data:
self.data['p'] = str(self.data['p']).strip().lower()
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']).lower().strip()
else:
return ""
@path.setter
def path(self, path: str):
self.data['p'] = str(path).strip().lower()
@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}>"
def send_response(conn: PacketServerConnection, response: Response, original_request: Request,
compression: Message.CompressionType = Message.CompressionType.BZIP2):
if conn.state.name == "CONNECTED" and not conn.closing:
# figure out compression setting based on request
logging.debug("Determining compression of response")
comp = compression
logging.debug(f"Default comp: {comp}")
logging.debug(f"Original vars: {original_request.vars}")
if 'C' in original_request.vars:
logging.debug(f"Detected compression header in original request: {original_request.vars['C']}")
val = original_request.vars['C']
for i in Message.CompressionType:
logging.debug(f"Checking type: {i}")
if str(val).strip().upper() == i.name:
comp = i
logging.debug(f"matched compression with var to {comp}")
break
try:
if int(val) == i.value:
comp = i
logging.debug(f"matched compression with var to {comp}")
except ValueError:
pass
response.compression = comp
logging.debug(f"Final compression: {response.compression}")
logging.debug(f"sending response: {response}, {response.compression}, {response.payload}")
conn.send_data(response.pack())
logging.debug("response sent successfully")
else:
logging.warning(f"Attempted to send data, but connection state is {conn.state.name}")
def send_blank_response(conn: PacketServerConnection, original_request: Request, status_code: int = 200,
payload: Union[bytes, bytearray, str, dict, list] = ""):
response = Response.blank()
response.status_code = status_code
response.payload = payload
send_response(conn, response, original_request)

View File

@@ -0,0 +1,2 @@
no_values = [0, '0', 'n', 'N', 'f', 'F', 'no', 'NO', False]
yes_values = [1, '1', 'y', 'Y', 't', 'T', 'yes', 'YES', True]

View File

@@ -0,0 +1,268 @@
import msgpack
from . import PacketServerConnection
from pe.connect import ConnectionState
from msgpack import Unpacker
from typing import Union, Self, Optional
import os.path
import logging
import ax25
class DummyPacketServerConnection(PacketServerConnection):
def __init__(self, call_from: str, call_to: str, incoming=False):
super().__init__(0, call_from, call_to, incoming=incoming)
self.sent_data = Unpacker()
self._state = ConnectionState.CONNECTED
@property
def state(self):
return self._state
def send_data(self, data: Union[bytes, bytearray]):
self.sent_data.feed(data)
logging.debug(f"Sender added {data} to self.sent_data.feed")
class DirectoryTestServerConnection(PacketServerConnection):
"""Monitors a directory for messages in msgpack format."""
def __init__(self, call_from: str, call_to: str, directory: str, incoming=False):
super().__init__(0, call_from, call_to, incoming=incoming)
self._state = ConnectionState.CONNECTED
if not os.path.isdir(directory):
raise FileNotFoundError(f"No such directory as {directory}")
self._directory = directory
self._sent_data = Unpacker()
self._pid = 1
self.closing = False
@classmethod
def create_directory_connection(cls, self_callsign: str, directory: str) -> Self:
if not ax25.Address.valid_call(self_callsign):
raise ValueError("self_callsign must be a valid callsign.")
if not os.path.isdir(directory):
raise NotADirectoryError(f"{directory} is not a directory or doesn't exist.")
spl = os.path.basename(directory).split('--')
if len(spl) != 2:
raise ValueError(f"Directory {directory} has the wrong name to be a connection dir.")
src = spl[0]
dst = spl[1]
if not ax25.Address.valid_call(src):
raise ValueError(f"Directory {directory} has the wrong name to be a connection dir.")
if not ax25.Address.valid_call(dst):
raise ValueError(f"Directory {directory} has the wrong name to be a connection dir.")
if dst.upper() == self_callsign.upper():
incoming = True
else:
incoming = False
return DirectoryTestServerConnection(src, dst, directory, incoming=incoming)
@property
def pid(self) -> int:
old = self._pid
self._pid = self._pid + 1
return old
@property
def directory(self) -> str:
return self._directory
@property
def state(self):
return self._state
@property
def file_path(self) -> str:
file_name = f"{self.local_callsign}.msg"
file_path = os.path.join(self._directory, file_name)
return file_path
@property
def remote_file_path(self) -> str:
file_name = f"{self.remote_callsign}.msg"
file_path = os.path.join(self._directory, file_name)
return file_path
def check_closed(self):
if self.closing:
self._state = ConnectionState.DISCONNECTED
if self._state is not ConnectionState.CONNECTED:
return True
if not os.path.isdir(self._directory):
self._state = ConnectionState.DISCONNECTED
self.disconnected()
return True
return False
def write_out(self, data: bytes):
if self.check_closed():
raise RuntimeError("Connection is closed. Cannot send.")
if os.path.exists(self.file_path):
raise RuntimeError("The outgoing message file already exists. State is wrong for sending.")
if os.path.exists(self.file_path+".tmp"):
os.remove(self.file_path+".tmp")
open(self.file_path+".tmp", 'wb').write(data)
os.rename(self.file_path+".tmp", self.file_path)
def send_data(self, data: Union[bytes, bytearray]):
if self.check_closed():
raise RuntimeError("Connection is closed. Cannot send.")
self._sent_data.feed(data)
logging.debug(f"Sender added {data} to self.sent_data.feed")
try:
obj = self._sent_data.unpack()
self.write_out(msgpack.packb(obj))
logging.debug(f"Wrote complete binary message to {self.file_path}")
except msgpack.OutOfData as e:
pass
def check_for_data(self):
"""Monitors connection directory for data."""
if self.closing:
self._state = ConnectionState.DISCONNECTED
if self.check_closed():
return
if os.path.isfile(self.remote_file_path):
logging.debug(f"{self.local_callsign} Found that the remote file path '{self.remote_file_path}' exists now.")
data = open(self.remote_file_path, 'rb').read()
self.data_received(self.pid, bytearray(data))
os.remove(self.remote_file_path)
logging.debug(f"{self.local_callsign} detected data from {self.remote_callsign}: {msgpack.unpackb(data)}")
class SimpleDirectoryConnection:
def __init__(self, call_from: str, call_to: str, directory: str, incoming=False):
self._state = ConnectionState.CONNECTED
if not os.path.isdir(directory):
raise FileNotFoundError(f"No such directory as {directory}")
self._directory = directory
self._sent_data = Unpacker()
self.data = Unpacker()
self._pid = 1
self.call_to = call_to
self.call_from = call_from
self.incoming = incoming
self._incoming = incoming
self.closing = False
if incoming:
self.local_callsign = call_to
self.remote_callsign = call_from
else:
self.local_callsign = call_from
self.remote_callsign = call_to
@classmethod
def create_directory_connection(cls, self_callsign: str, directory: str) -> Self:
if not ax25.Address.valid_call(self_callsign):
raise ValueError("self_callsign must be a valid callsign.")
if not os.path.isdir(directory):
raise NotADirectoryError(f"{directory} is not a directory or doesn't exist.")
spl = os.path.basename(directory).split('--')
if len(spl) != 2:
raise ValueError(f"Directory {directory} has the wrong name to be a connection dir.")
src = spl[0]
dst = spl[1]
if not ax25.Address.valid_call(src):
raise ValueError(f"Directory {directory} has the wrong name to be a connection dir.")
if not ax25.Address.valid_call(dst):
raise ValueError(f"Directory {directory} has the wrong name to be a connection dir.")
if dst.upper() == self_callsign.upper():
incoming = True
else:
incoming = False
return SimpleDirectoryConnection(src, dst, directory, incoming=incoming)
@property
def pid(self) -> int:
old = self._pid
self._pid = self._pid + 1
return old
@property
def directory(self) -> str:
return self._directory
@property
def state(self):
return self._state
@property
def file_path(self) -> str:
file_name = f"{self.local_callsign}.msg"
file_path = os.path.join(self._directory, file_name)
return file_path
@property
def remote_file_path(self) -> str:
file_name = f"{self.remote_callsign}.msg"
file_path = os.path.join(self._directory, file_name)
return file_path
def check_closed(self):
if self.closing:
self._state = ConnectionState.DISCONNECTED
if self._state is not ConnectionState.CONNECTED:
return True
if not os.path.isdir(self._directory):
self._state = ConnectionState.DISCONNECTED
return True
return False
def write_out(self, data: bytes):
if self.check_closed():
raise RuntimeError("[SIMPLE] Connection is closed. Cannot send.")
if os.path.exists(self.file_path):
raise RuntimeError("[SIMPLE] The outgoing message file already exists. State is wrong for sending.")
if os.path.exists(self.file_path+".tmp"):
os.remove(self.file_path+".tmp")
open(self.file_path+".tmp", 'wb').write(data)
os.rename(self.file_path+".tmp", self.file_path)
def send_data(self, data: Union[bytes, bytearray]):
if self.check_closed():
raise RuntimeError("[SIMPLE] Connection is closed. Cannot send.")
self._sent_data.feed(data)
logging.debug(f"[SIMPLE] Sender added {data} to self.sent_data.feed")
try:
obj = self._sent_data.unpack()
self.write_out(msgpack.packb(obj))
logging.debug(f"[SIMPLE] Wrote complete binary message to {self.file_path}")
except msgpack.OutOfData as e:
pass
def check_for_data(self) -> bool:
"""Monitors connection directory for data."""
if self.closing:
self._state = ConnectionState.DISCONNECTED
if self.check_closed():
return False
if os.path.isfile(self.remote_file_path):
data = open(self.remote_file_path, 'rb').read()
os.remove(self.remote_file_path)
logging.debug(f"[SIMPLE] {self.local_callsign} detected data from {self.remote_callsign}: {data}")
self.data.feed(data)
return True
else:
return False

151
packetserver/common/util.py Normal file
View File

@@ -0,0 +1,151 @@
import re
import datetime
import tempfile
import tarfile
from typing import Union, Iterable, Tuple, Optional, IO
import os.path
from io import BytesIO, BufferedReader
import random
import string
def email_valid(email: str) -> bool:
"""Taken from https://www.geeksforgeeks.org/check-if-email-address-valid-or-not-in-python/"""
regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b'
if re.fullmatch(regex, email):
return True
else:
return False
def to_date_digits(index: datetime.datetime) -> str:
return f"{str(index.year).zfill(4)}{str(index.month).zfill(2)}{str(index.day).zfill(2)}{str(index.hour).zfill(2)}{str(index.minute).zfill(2)}{str(index.second).zfill(2)}"
def from_date_digits(index: str, tz: datetime.timezone = datetime.UTC) -> datetime:
ind = str(index)
if not ind.isdigit():
raise ValueError("Received invalid date digit string, containing non-digit chars.")
if len(ind) < 4:
raise ValueError("Received invalid date digit string, needs to at least by four digits for a year")
year = int(ind[:4])
month = 1
day = 1
hour = 0
minute = 0
second = 0
if len(ind) >= 6:
month = int(ind[4:6])
if len(ind) >= 8:
day = int(ind[6:8])
if len(ind) >= 10:
hour = int(ind[8:10])
if len(ind) >= 12:
minute = int(ind[10:12])
if len(ind) >= 14:
second = int(ind[12:14])
return datetime.datetime(year, month, day ,hour, minute, second, tzinfo=tz)
def tar_bytes(file: Union[str, Iterable]) -> bytes:
"""Creates a tar archive in a temporary file with the specified files at root level.
Returns the bytes of the archive."""
files = []
if type(file) is str:
files.append(file)
else:
for i in file:
files.append(str(i))
with tempfile.TemporaryFile() as temp:
tar_obj = tarfile.TarFile(fileobj=temp, mode="w")
for i in files:
tar_obj.add(i, arcname=os.path.basename(i))
tar_obj.close()
temp.seek(0)
return temp.read()
def bytes_to_tar_bytes(name: str, data: bytes) -> bytes:
"""Creates a tar archive with a single file of name <name> with <data> bytes as the contents"""
with tempfile.TemporaryFile() as temp:
tar_obj = tarfile.TarFile(fileobj=temp, mode="w")
bio = BytesIO(data)
tar_info = tarfile.TarInfo(name=name)
tar_info.size = len(data)
tar_obj.addfile(tar_info, bio)
tar_obj.close()
temp.seek(0)
return temp.read()
def bytes_tar_has_files(data: Union[bytes, IO]):
if type(data) is bytes:
bio = BytesIO(data)
else:
bio = data
tar_obj = tarfile.TarFile(fileobj=bio, mode="r")
files = [m for m in tar_obj.getmembers() if m.isfile()]
if len(files) > 0:
return True
else:
return False
def multi_bytes_to_tar_bytes(objects: dict) -> bytes:
"""Creates a tar archive with a single file of name <name> with <data> bytes as the contents"""
with tempfile.TemporaryFile() as temp:
tar_obj = tarfile.TarFile(fileobj=temp, mode="w")
for name in objects:
data = bytes(objects[name])
bio = BytesIO(data)
tar_info = tarfile.TarInfo(name=name)
tar_info.size = len(data)
tar_obj.addfile(tar_info, bio)
tar_obj.close()
temp.seek(0)
return temp.read()
def extract_tar_bytes(tarfile_bytes: bytes) -> Tuple[str, bytes]:
"""Takes the bytes of a tarfile, and returns the name and bytes of the first file in the archive."""
out_bytes = b''
bio = BytesIO(tarfile_bytes)
tar_obj = tarfile.TarFile(fileobj=bio, mode="r")
members = tar_obj.getmembers()
for i in range(0, len(members)):
if members[i].isfile():
return members[i].name, tar_obj.extractfile(members[i]).read()
raise FileNotFoundError("No files found to extract from archive")
def random_string(length=8) -> str:
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=length))
return rand_str
class TarFileExtractor(object):
"""Generator created from file like object pointing to tar data"""
def __init__(self, fileobj: IO):
self.fileobj = fileobj
try:
self.tar_file = tarfile.TarFile(fileobj=self.fileobj)
self._raw_members = [m for m in self.tar_file.getmembers() if m.isfile()]
except:
self._raw_members = []
self._count = 0
def __iter__(self):
return self
# Python 3 compatibility
def __next__(self):
return self.next()
def next(self) -> Tuple[str, IO]:
if (self._count + 1) > len(self._raw_members):
raise StopIteration()
else:
member = self._raw_members[self._count]
name = member.name
if type(name) is bytes:
name = name.decode()
name = str(name)
self._count = self._count + 1
return os.path.basename(name), self.tar_file.extractfile(member)