From d58383822438f4139ad63d3d6fc817be2a4139bd Mon Sep 17 00:00:00 2001 From: szeka9 Date: Wed, 1 Apr 2026 20:09:38 +0200 Subject: [PATCH 1/9] Hardening improvements in HTTP parser - imrpove query parameter parsing to search for correct boundaries "?" or "&" when extracting values by key names - reject non-ASCII and control characters in headers, only allow alphanumeric characters, hyphens and underscores in header field names - reject invalid quoting in multipart header boundaries - reject requests with content-length and chunked transfer encoding set simultaneously --- src/pyrobusta/protocol/http.py | 79 ++++++++++++++++++++++++---------- tests/unit/test_http.py | 53 ++++++++++++++++++++--- 2 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index 0de2a54..db36961 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -199,17 +199,20 @@ def get_url_encoded_query_param(query: str, key: str, default: str = None): :param key: key to parse from the query :param default: default value to return when key is not present """ - idx_start = query.find(key + "=") - if idx_start != -1: - idx_end = -1 - idx_end = query.find("&", idx_start) - if idx_start > -1: - if idx_end > -1: - return query[idx_start + len(key) + 1 : idx_end] - return query[idx_start + len(key) + 1 :] - if default is None: + if query.startswith(key + "="): + idx_start = 0 + elif (idx_start := query.find("&" + key + "=")) != -1: + idx_start += 1 + elif default is None: raise KeyError() - return default + else: + return default + + idx_end = -1 + idx_end = query.find("&", idx_start) + if idx_end > -1: + return query[idx_start + len(key) + 1 : idx_end] + return query[idx_start + len(key) + 1 :] @classmethod def is_norm_path_served(cls, path: str): @@ -262,13 +265,25 @@ def _parse_headers(cls, raw_headers: memoryview) -> dict[str, str | int]: headers = {} for line in header_lines: # pylint: disable=W0511 - # TODO: support for UTF-8 in field values (e.g filenames), can be board dependent if any(c > 127 for c in line): raise HeaderParsingError("Non-ASCII character") if b":" not in line: raise HeaderParsingError() name, value = line.split(b":", 1) + if not name: + raise HeaderParsingError("Empty header name") + for c in name: + if ( + 48 <= c <= 57 # 0-9 + or 65 <= c <= 90 # A-Z + or 97 <= c <= 122 # a-z + or c in (45, 95) # -_ + ): + continue + raise HeaderParsingError("Invalid header name") name = name.strip().lower().decode(cls.ASCII) + if any((c < 32 and c != 9) or c == 127 for c in value): + raise HeaderParsingError("Invalid header value") if name == cls.CONTENT_LENGTH: value = int(value.strip()) else: @@ -277,18 +292,35 @@ def _parse_headers(cls, raw_headers: memoryview) -> dict[str, str | int]: return headers @staticmethod - def _is_multipart(headers: dict) -> str: + def _get_mp_boundary(headers: dict) -> str: """Determine from the headers if a request is multipart, and return the boundary value""" content_type = headers.get("content-type") - if content_type and content_type.lower().startswith("multipart/form-data"): - parts = content_type.split(";") - for part in parts[1:]: - if "=" in part: - key, value = part.strip().split("=", 1) - if key.strip().lower() == "boundary": - boundary = value.strip().strip('"') - return boundary if boundary else None - return None + if not content_type or not content_type.lower().startswith( + "multipart/form-data" + ): + return None + + parts = content_type.split(";") + for part in parts[1:]: + if "=" not in part: + continue + key, value = part.strip().split("=", 1) + + if key.strip().lower() != "boundary": + continue + value = value.strip() + + if value.startswith('"'): + if len(value) < 2 or not value.endswith('"'): + raise HeaderParsingError() + value = value[1:-1] + elif value.endswith('"'): + raise HeaderParsingError() + + if not value: + raise HeaderParsingError() + return value + raise HeaderParsingError() @classmethod def _parse_body_part(cls, part: memoryview) -> tuple[dict, bytes]: @@ -508,10 +540,13 @@ def _route_request_st(self, _, tx): if self.method == self.HEAD: self.on_client_error(tx, self.BAD_REQUEST_ERROR) return - if mp_boundary := self._is_multipart(self.headers): + if mp_boundary := self._get_mp_boundary(self.headers): self.mp_boundary = mp_boundary.encode(self.ASCII) self.state = self._start_multipart_parser_st elif self._is_chunked(): + if self.CONTENT_LENGTH in self.headers: + self.on_client_error(tx, self.BAD_REQUEST_ERROR) + return self.state = self._recv_chunked_size_st else: self.state = self._recv_payload_st diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index ee04f02..19a7166 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -37,10 +37,10 @@ def setUp(self): # Load your web and buffer modules self.helpers_module = load_module("pyrobusta/utils/helpers.py") buffer_module = load_module("pyrobusta/stream/buffer.py") - web_module = load_module("pyrobusta/protocol/http.py") - web_module.enable_optional_features() + self.web_module = load_module("pyrobusta/protocol/http.py") + self.web_module.enable_optional_features() - self.engine = web_module.HttpEngine() + self.engine = self.web_module.HttpEngine() self.rx = buffer_module.SlidingBuffer(bytearray(1024)) self.tx = buffer_module.SlidingBuffer(bytearray(1024)) @@ -149,6 +149,18 @@ def test_header_parsing_incomplete_header(self): self.assertEqual(self.engine.status_code, 400) self.assertEqual(self.engine.state, None) + def test_header_parsing_error(self): + for case in ( + b"", + b":", + b": value", + b" leading-space: value", + b"space in header name: value", + b"new-line-in-header:\nvalue", + ): + with self.assertRaises(self.web_module.HeaderParsingError): + self.engine._parse_headers(case) + def test_routing_unsupported_method(self): self.engine.state = self.engine._route_request_st self.engine.url = b"/api/test" @@ -312,6 +324,26 @@ def test_empty_or_missing_url_encoded_query_parameter(self): with self.assertRaises(KeyError): self.engine.get_url_encoded_query_param(self.engine.query, "param3") + def test_overlapping_url_encoded_query_parameter(self): + request = b"GET /api/test?data=value1&ta=value2&a=value3 HTTP/1.1\r\n" + + for i in range(len(request)): + self.rx.write(request[i : i + 1]) + self.engine.state(self.rx, self.tx) + + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "data"), + "value1", + ) + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "ta"), + "value2", + ) + self.assertEqual( + self.engine.get_url_encoded_query_param(self.engine.query, "a"), + "value3", + ) + def test_chunked_transfer_encoding_valid(self): self.engine.url = b"/api/test" self.engine.method = b"GET" @@ -418,10 +450,15 @@ def setUpClass(cls): def test_multipart_parser(self): for case in [ + ({}, None), ( {"content-type": 'multipart/form-data; boundary ="test-boundary"'}, "test-boundary", ), + ( + {"content-type": 'multipart/form-data; boundary =" test-boundary "'}, + " test-boundary ", + ), ( {"content-type": "multipart/form-data ;boundary= test-boundary "}, "test-boundary", @@ -432,16 +469,18 @@ def test_multipart_parser(self): ), ]: with self.subTest(headers=case[0], expected=case[1]): - self.assertEqual(self.engine._is_multipart(case[0]), case[1]) + self.assertEqual(self.engine._get_mp_boundary(case[0]), case[1]) for case in [ - {}, {"content-type": "multipart/form-data"}, {"content-type": 'multipart/form-data;boundary=""'}, {"content-type": "multipart/form-data;boundary=\r\n"}, + {"content-type": 'multipart/form-data;boundary="missing-quote'}, + {"content-type": 'multipart/form-data;boundary=missing-quote"'}, ]: - with self.subTest(headers=case, expected=None): - self.assertEqual(self.engine._is_multipart(case), None) + with self.subTest(headers=case): + with self.assertRaises(self.web_module.HeaderParsingError): + self.engine._get_mp_boundary(case) def test_multipart_receiver_valid(self): self.engine.state = self.engine._start_multipart_parser_st From 6620412a84323948db6185321c6d13b723a6d1ff Mon Sep 17 00:00:00 2001 From: szeka9 Date: Wed, 1 Apr 2026 20:15:35 +0200 Subject: [PATCH 2/9] Restructure bindings and server classes - move buffer allocation and reservation into the HTTP server to decouple buffer initialization from state machine behavior - clarify socket vs. client abstractions by introducing distinct naming: use "client" globally and "socket" at the socket server level --- src/pyrobusta/bindings/socket_http.py | 148 ++++++---------------- src/pyrobusta/server/http_server.py | 173 +++++++++++++++++++------- tests/.pylintrc | 3 +- tests/functional/test_http.py | 2 +- 4 files changed, 168 insertions(+), 158 deletions(-) diff --git a/src/pyrobusta/bindings/socket_http.py b/src/pyrobusta/bindings/socket_http.py index 799fea7..513baa8 100644 --- a/src/pyrobusta/bindings/socket_http.py +++ b/src/pyrobusta/bindings/socket_http.py @@ -4,12 +4,11 @@ import asyncio from asyncio import sleep_ms # pylint: disable=E1101 -from gc import mem_free, collect +from gc import collect -from ..stream.buffer import MemoryPool, SlidingBuffer, BufferFullError +from ..stream.buffer import BufferFullError from ..transport.socket import SocketBase -from ..protocol.http import HttpEngine, ServerBusyError -from ..utils.config import get_config +from ..protocol.http import HttpEngine, ServerBusyError, HeaderParsingError from ..utils import logging @@ -19,75 +18,19 @@ class SocketHttp(SocketBase): buffer management and state machine parser. """ - # Constants for memory footprint - MEM_CAP = float( - get_config("http_mem_cap") - ) # Default memory cap (percentage / 100) of free heap - SEND_BUF_MIN_BYTES = 512 # Minimum buffer size for responses - SEND_BUF_MAX_BYTES = 4096 # Max buffer size for responses - RECV_BUF_MIN_BYTES = 512 # Minimum buffer size for requests - RECV_BUF_MAX_BYTES = 4096 # Max buffer size for requests - CONN_OVERHEAD = 1024 # Overhead per connection - MTU_SIZE = 1460 # TCP maximum transmission unit - - # Timing settings + MTU_SIZE = 1460 STATE_MACHINE_SLEEP_MS = 2 RESP_HANDLER_SLEEP_MS = 2 RECV_TIMEOUT_SECONDS = 10 - # Static buffer pools - initialized by init_pools() - RECV_POOL = None - SEND_POOL = None - - @staticmethod - def init_pools(max_sockets): - """ - Initialize pool of buffers for sending/receiving based on different profiles - """ - mem_available = mem_free() - con_limit = max(1, max_sockets) - usable = int(SocketHttp.MEM_CAP * mem_available) - is_low_memory = (usable / con_limit) < ( - SocketHttp.RECV_BUF_MAX_BYTES - + SocketHttp.SEND_BUF_MAX_BYTES - + SocketHttp.CONN_OVERHEAD - ) - if is_low_memory: - logging.warning( - __name__ + ".init_pools: low-memory mode with reduced buffer size" - ) - recv_size = ( - SocketHttp.RECV_BUF_MIN_BYTES - if is_low_memory - else SocketHttp.RECV_BUF_MAX_BYTES - ) - send_size = ( - SocketHttp.SEND_BUF_MIN_BYTES - if is_low_memory - else SocketHttp.SEND_BUF_MAX_BYTES - ) - per_conn = recv_size + send_size + SocketHttp.CONN_OVERHEAD - if usable < per_conn: - raise MemoryError( - ( - f"Insufficient memory: {mem_available // 1024} KB " - f"at {SocketHttp.MEM_CAP*100}% cap, " - f"at least {per_conn // 1024} KB required" - ) - ) - con_limit = min(usable // per_conn, con_limit) - logging.info((__name__ + f".init_pools: {con_limit} connection(s) allowed")) - SocketHttp.RECV_POOL = MemoryPool(recv_size, con_limit, wrapper=SlidingBuffer) - SocketHttp.SEND_POOL = MemoryPool(send_size, con_limit, wrapper=SlidingBuffer) - __slots__ = ("_engine", "_prev_state", "_recv_buf", "_send_buf") - def __init__(self, reader, writer): + def __init__(self, reader, writer, recv_buf, send_buf): super().__init__(reader, writer) self._engine = HttpEngine() self._prev_state = None - self._recv_buf = None - self._send_buf = None + self._recv_buf = recv_buf + self._send_buf = send_buf async def _flush_response(self): data = self._send_buf.peek() @@ -99,38 +42,41 @@ async def _flush_response(self): async def run(self): """ Handle socket connection with HTTP state machine parser. - - 1) reserve buffer - - 2) run state machine parser - - 3) release reserved buffers and terminate socket connection """ - await self._reserve_buffers() self._prev_state = None try: while self._engine.state is not None: await self._run_state_machine() await sleep_ms(SocketHttp.STATE_MACHINE_SLEEP_MS) - except Exception as e: # pylint: disable=W0718 - logging.warning(__name__ + f": error in run_web: {e}") finally: - if self._send_buf: - self._send_buf.consume() - SocketHttp.SEND_POOL.release(self._send_buf) - if self._recv_buf: - self._recv_buf.consume() - SocketHttp.RECV_POOL.release(self._recv_buf) await self.close() collect() - async def _reserve_buffers(self): - if SocketHttp.SEND_POOL is None or SocketHttp.RECV_POOL is None: - raise RuntimeError("Pools are ninitialized") - - while not self._recv_buf or not self._send_buf: - if not self._recv_buf: - self._recv_buf = SocketHttp.RECV_POOL.reserve() - if not self._send_buf: - self._send_buf = SocketHttp.SEND_POOL.reserve() - await sleep_ms(SocketHttp.STATE_MACHINE_SLEEP_MS) + async def _read_to_buf(self): + buf_free = self._recv_buf.capacity - self._recv_buf.size() + if not buf_free: + self._engine.on_buffer_full(self._send_buf) + await self._flush_response() + return 0 + try: + request = await self.read( + read_bytes=buf_free, + decoding=None, + timeout_seconds=SocketHttp.RECV_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + self._engine.on_timeout(self._send_buf) + await self._flush_response() + return 0 + except Exception as e: # pylint: disable=W0718 + self._engine.on_failure( + self._send_buf, b"Read error: " + str(e).encode("ascii") + ) + await self._flush_response() + return 0 + self._recv_buf.write(request) + logging.debug(__name__ + f"._read_to_buf: [{request}]") + return len(request) async def _run_state_machine(self): if self._prev_state == self._engine.state or self._prev_state is None: @@ -154,6 +100,10 @@ async def _run_state_machine(self): self._engine.on_busy(self._send_buf) await self._flush_response() return + except HeaderParsingError: + self._engine.on_client_error(self._send_buf, b"Invalid headers") + await self._flush_response() + return except Exception as e: # pylint: disable=W0718 logging.warning(__name__ + f"._run_state_machine: {e}") self._engine.on_failure(self._send_buf, str(e).encode("ascii")) @@ -162,32 +112,6 @@ async def _run_state_machine(self): if self._engine.state is None and resp_handler is not None: await self._response_handler(resp_handler) - async def _read_to_buf(self): - buf_free = self._recv_buf.capacity - self._recv_buf.size() - if not buf_free: - self._engine.on_buffer_full(self._send_buf) - await self._flush_response() - return 0 - try: - request = await self.read( - read_bytes=buf_free, - decoding=None, - timeout_seconds=SocketHttp.RECV_TIMEOUT_SECONDS, - ) - except asyncio.TimeoutError: - self._engine.on_timeout(self._send_buf) - await self._flush_response() - return 0 - except Exception as e: # pylint: disable=W0718 - self._engine.on_failure( - self._send_buf, b"Read error: " + str(e).encode("ascii") - ) - await self._flush_response() - return 0 - self._recv_buf.write(request) - logging.debug(__name__ + f"._read_to_buf: [{request}]") - return len(request) - async def _response_handler(self, resp_handler): if "closure" == type(resp_handler).__name__: for is_finished in resp_handler(self._send_buf): diff --git a/src/pyrobusta/server/http_server.py b/src/pyrobusta/server/http_server.py index e0cecff..13cdf97 100644 --- a/src/pyrobusta/server/http_server.py +++ b/src/pyrobusta/server/http_server.py @@ -2,12 +2,13 @@ Socket server application """ -import gc +from gc import collect, mem_free from asyncio import sleep_ms, start_server, run # pylint: disable=E1101 from time import ticks_ms, ticks_diff from ..protocol import http from ..bindings.socket_http import SocketHttp +from ..stream.buffer import MemoryPool, SlidingBuffer from ..utils.config import get_config from ..utils import logging @@ -15,107 +16,191 @@ class HttpServer: """ Socket server class, handling global config (timeout, port, max connections etc.), - and managing active sockets. + and managing active clients. """ - __slots__ = ["_host", "_max_sockets", "_port", "_timeout", "_server"] + __slots__ = ["_host", "_port", "_server", "_max_clients"] - ACTIVE_SOCKETS = [] + ACTIVE_CLIENTS = [] + + # --------------- + # Server settings + # --------------- CON_ACCEPT_TIMEOUT_MS = 5000 # Timeout value for accepting new connection CON_ACCEPT_SLEEP_MS = ( 100 # Duration of sleep between attempts to accept new connection ) - MAX_SOCKETS = int(get_config("socket_max_con")) - SOCKET_TIMEOUT_SEC = 30 LISTEN_PORT_HTTP = 8080 LISTEN_PORT_HTTPS = 4443 TLS_CERT_PATH = "cert.der" TLS_KEY_PATH = "key.der" + CON_TIMEOUT_S = 30 + + # ----------------------------------------- + # Constants for controlled memory footprint + # ----------------------------------------- + + MEM_CAP = float(get_config("http_mem_cap")) # Default memory cap (percentage / 100) + SEND_BUF_MIN_BYTES = 512 # Minimum buffer size for responses + SEND_BUF_MAX_BYTES = 4096 # Max buffer size for responses + RECV_BUF_MIN_BYTES = 512 # Minimum buffer size for requests + RECV_BUF_MAX_BYTES = 4096 # Max buffer size for requests + CON_OVERHEAD_BYTES = 1024 # Overhead per connection + + # ------------------------------------------ + # Buffer pools - initialized by init_pools() + # ------------------------------------------ + + RECV_POOL = None + SEND_POOL = None + + @classmethod + def _init_pools(cls, max_clients): + """ + Initialize pool of buffers for sending/receiving based on different profiles + """ + mem_available = mem_free() + con_limit = max_clients + usable = int(cls.MEM_CAP * mem_available) + is_low_memory = (usable / con_limit) < ( + cls.RECV_BUF_MAX_BYTES + cls.SEND_BUF_MAX_BYTES + cls.CON_OVERHEAD_BYTES + ) + if is_low_memory: + logging.warning( + __name__ + ".init_pools: low-memory mode with reduced buffer size" + ) + recv_size = cls.RECV_BUF_MIN_BYTES if is_low_memory else cls.RECV_BUF_MAX_BYTES + send_size = cls.SEND_BUF_MIN_BYTES if is_low_memory else cls.SEND_BUF_MAX_BYTES + per_con = recv_size + send_size + cls.CON_OVERHEAD_BYTES + if usable < per_con: + raise MemoryError( + ( + f"Insufficient memory: {mem_available // 1024} KB " + f"at {cls.MEM_CAP*100}% cap, " + f"at least {per_con // 1024} KB required" + ) + ) + con_limit = min(usable // per_con, con_limit) + logging.info((__name__ + f".init_pools: {con_limit} connection(s) allowed")) + cls.RECV_POOL = MemoryPool(recv_size, con_limit, wrapper=SlidingBuffer) + cls.SEND_POOL = MemoryPool(send_size, con_limit, wrapper=SlidingBuffer) @classmethod - async def drop_client(cls, socket): - """Remove socket from active list""" - if socket not in cls.ACTIVE_SOCKETS: + async def _drop_client(cls, client): + """Remove client from active list""" + if client not in cls.ACTIVE_CLIENTS: return - logging.debug(__name__ + f": {socket.id} dropped") - await socket.close() - cls.ACTIVE_SOCKETS.remove(socket) - del socket - gc.collect() + logging.debug(__name__ + f": {client.id} dropped") + await client.close() + cls.ACTIVE_CLIENTS.remove(client) + del client + collect() + + # ---------------- + # Instance methods + # ---------------- def __init__(self): self._host = "0.0.0.0" - self._max_sockets = max(1, HttpServer.MAX_SOCKETS) self._port = ( HttpServer.LISTEN_PORT_HTTPS if get_config("tls").lower() == "true" else HttpServer.LISTEN_PORT_HTTP ) - self._timeout = HttpServer.SOCKET_TIMEOUT_SEC self._server = None + self._max_clients = 0 - async def can_handle_new_socket(self): + async def can_handle_new_client(self): """ Decide if the new socket can be handled. Evict closed/inactive sockets if needed. :return is_acceptable: true/false """ - gc.collect() + collect() con_timestamp = ticks_ms() while ticks_diff(ticks_ms(), con_timestamp) < self.CON_ACCEPT_TIMEOUT_MS: - if len(self.ACTIVE_SOCKETS) < self._max_sockets: + if len(self.ACTIVE_CLIENTS) < self._max_clients: return True # Attempt to evict inactive clients - for socket in self.ACTIVE_SOCKETS: - socket_inactive = int(ticks_diff(ticks_ms(), socket.last_event) * 0.001) - if not socket.connected or socket_inactive > self._timeout: + for client in self.ACTIVE_CLIENTS: + client_inactive = int(ticks_diff(ticks_ms(), client.last_event) * 0.001) + if not client.connected or client_inactive > self.CON_TIMEOUT_S: logging.debug( ( - __name__ + f": evicted {socket.id} " - f"timeout: {self._timeout - socket_inactive}s" + __name__ + f": evicted {client.id} " + f"timeout: {self.CON_TIMEOUT_S - client_inactive}s" ) ) - await self.drop_client(socket) - return True + await self._drop_client(client) await sleep_ms(self.CON_ACCEPT_SLEEP_MS) return False - async def accept_http(self, reader, writer): + async def _reserve_buffers(self): + if self.SEND_POOL is None or self.RECV_POOL is None: + raise RuntimeError("Pools are uninitialized") + + recv_buf = None + send_buf = None + + while not recv_buf or not send_buf: + if not recv_buf: + recv_buf = self.RECV_POOL.reserve() + if not send_buf: + send_buf = self.SEND_POOL.reserve() + await sleep_ms(self.CON_ACCEPT_SLEEP_MS) + + return recv_buf, send_buf + + async def _accept_socket_http(self, reader, writer): """ Handle incoming socket connection for HTTP. - creates SocketHttp object """ - if not await self.can_handle_new_socket(): + if not await self.can_handle_new_client(): logging.debug(__name__ + ": cannot accept new client") writer.close() await writer.wait_closed() return - new_client = SocketHttp(reader, writer) - logging.debug(__name__ + f": accept {new_client.id}") - self.ACTIVE_SOCKETS.append(new_client) - await new_client.run() - - async def run_server(self): + try: + recv_buf, send_buf = await self._reserve_buffers() + new_client = SocketHttp(reader, writer, recv_buf, send_buf) + logging.debug(__name__ + f": accept {new_client.id}") + self.ACTIVE_CLIENTS.append(new_client) + await new_client.run() + except Exception as e: # pylint: disable=W0718 + logging.warning(__name__ + f": error in run(): {e}") + finally: + if send_buf: + send_buf.consume() + self.SEND_POOL.release(send_buf) + if recv_buf: + recv_buf.consume() + self.RECV_POOL.release(recv_buf) + + async def start_socket_server(self): """ Start asyncio socket server on the specified port. """ try: - gc.collect() + collect() http.enable_optional_features() logging.debug(f"Registered endpoints: {http.HttpEngine.ENDPOINTS}") - SocketHttp.init_pools(self._max_sockets) + self._max_clients = int(get_config("socket_max_con")) + self._init_pools(self._max_clients) ssl_ctx = None + if get_config("tls").lower() == "true": import ssl ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_ctx.load_cert_chain(self.TLS_CERT_PATH, self.TLS_KEY_PATH) + self._server = await start_server( - self.accept_http, + self._accept_socket_http, self._host, self._port, - backlog=self._max_sockets, + backlog=max(1, self._max_clients), ssl=ssl_ctx, ) logging.info(__name__ + ": started") @@ -124,20 +209,20 @@ async def run_server(self): async def terminate(self): """ - Terminate HTTP server and close sockets + Terminate HTTP server and drop clients """ logging.info(__name__ + ": terminated") - while self.ACTIVE_SOCKETS: - await self.drop_client(self.ACTIVE_SOCKETS[0]) + while self.ACTIVE_CLIENTS: + await self._drop_client(self.ACTIVE_CLIENTS[0]) if self._server: self._server.close() await self._server.wait_closed() self._server = None - gc.collect() + collect() def main(): """ - Start socket server async task. + Start HTTP server async task. """ - run(HttpServer().run_server()) + run(HttpServer().start_socket_server()) diff --git a/tests/.pylintrc b/tests/.pylintrc index 1824775..5cf251e 100644 --- a/tests/.pylintrc +++ b/tests/.pylintrc @@ -3,4 +3,5 @@ disable=W0212, C0114, C0115, C0116, - R0904 + R0904, + R0902 diff --git a/tests/functional/test_http.py b/tests/functional/test_http.py index 231f0c4..ef2603e 100644 --- a/tests/functional/test_http.py +++ b/tests/functional/test_http.py @@ -108,7 +108,7 @@ async def start_server(): Start an HTTP server as a background task """ server = http_server.HttpServer() - server_task = asyncio.create_task(server.run_server()) + server_task = asyncio.create_task(server.start_socket_server()) await asyncio.sleep_ms(100) return server, server_task From f36a652f2f0084a7cc0008ae7d13d8b169f8770b Mon Sep 17 00:00:00 2001 From: szeka9 Date: Fri, 3 Apr 2026 23:45:37 +0200 Subject: [PATCH 3/9] Change default HTTP server ports Use port 80 and 443 by default for HTTP for user convenience. Use port 8080 and 4443 for testing to prevent port conflicts. --- example/mip_repo/app.py | 3 ++- src/pyrobusta/server/http_server.py | 8 ++++---- tests/functional/test_http.py | 3 +++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/example/mip_repo/app.py b/example/mip_repo/app.py index 5955b9a..55a7eea 100644 --- a/example/mip_repo/app.py +++ b/example/mip_repo/app.py @@ -38,7 +38,8 @@ def self_serve_mip_package(http_ctx, _): if tls_enabled else http_server.HttpServer.LISTEN_PORT_HTTP ) - server_addr += f":{port}" + if not server_addr in (80, 443): + server_addr += f":{port}" protocol = "https" if tls_enabled else "http" diff --git a/src/pyrobusta/server/http_server.py b/src/pyrobusta/server/http_server.py index 13cdf97..2249c0f 100644 --- a/src/pyrobusta/server/http_server.py +++ b/src/pyrobusta/server/http_server.py @@ -30,10 +30,10 @@ class HttpServer: CON_ACCEPT_SLEEP_MS = ( 100 # Duration of sleep between attempts to accept new connection ) - LISTEN_PORT_HTTP = 8080 - LISTEN_PORT_HTTPS = 4443 - TLS_CERT_PATH = "cert.der" - TLS_KEY_PATH = "key.der" + LISTEN_PORT_HTTP = 80 + LISTEN_PORT_HTTPS = 443 + TLS_CERT_PATH = "/cert.der" + TLS_KEY_PATH = "/key.der" CON_TIMEOUT_S = 30 # ----------------------------------------- diff --git a/tests/functional/test_http.py b/tests/functional/test_http.py index ef2603e..7fbc259 100644 --- a/tests/functional/test_http.py +++ b/tests/functional/test_http.py @@ -282,6 +282,9 @@ async def test_fs_access_control(): def setup_config(multipart=False, tls_enabled=False, served_paths=""): + http_server.HttpServer.LISTEN_PORT_HTTP = 8080 + http_server.HttpServer.LISTEN_PORT_HTTPS = 4443 + config_idx = config.CONFIG_CACHE.index("http_multipart") config.CONFIG_CACHE[config_idx + 1] = str(multipart) config_idx = config.CONFIG_CACHE.index("tls") From 5db2ce1ffa6f62dfc7ee1264f7630b8d308a36db Mon Sep 17 00:00:00 2001 From: szeka9 Date: Fri, 3 Apr 2026 23:51:26 +0200 Subject: [PATCH 4/9] Use prefix in version strings Use the "v" prefix in package versions to better align with versioning conventions. --- Makefile | 2 +- package.json | 2 +- src/pyrobusta/utils/config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 4486799..0fe9a85 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PYROBUSTA_VERSION := 0.3.0 +PYROBUSTA_VERSION := v0.3.0 DEVICE ?= u0 SRC_DIR := src diff --git a/package.json b/package.json index ddfce0e..23075a0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.3.0", + "version": "v0.3.0", "urls": [ [ "pyrobusta/transport/socket.mpy", diff --git a/src/pyrobusta/utils/config.py b/src/pyrobusta/utils/config.py index 35dc2ea..6a7d8e3 100644 --- a/src/pyrobusta/utils/config.py +++ b/src/pyrobusta/utils/config.py @@ -6,7 +6,7 @@ from .helpers import normalize_path -PYROBUSTA_VERSION = "0.3.0" +PYROBUSTA_VERSION = "v0.3.0" CONFIG_LOADED = False CONFIG_LOCATION = "pyrobusta.env" CONFIG_CACHE = [ From 592c8e86fec16600574181f6c7ff6f025053a87a Mon Sep 17 00:00:00 2001 From: szeka9 Date: Sat, 4 Apr 2026 00:01:14 +0200 Subject: [PATCH 5/9] Use soft-reset to unblock REPL access, imrpove Wi-Fi setup Use soft-reset to allow Makefile rules to access a device if an application is already running in blocking mode. Improve Wi-Fi connection handling to wait until the device is connected, with a specified timeout. --- Makefile | 15 ++++++++++++++- example/mem_usage/boot.py | 10 +++++++++- example/mip_repo/boot.py | 10 +++++++++- src/pyrobusta/con/wifi.py | 36 ++++++++++++++++++++++++++---------- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 0fe9a85..b8b8de8 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ $(BUILD_DIR)/%.py: $(SRC_DIR)/%.py .PHONY: deploy deploy: @echo "Uploading build/$(PKG) to device $(DEVICE)" + @mpremote $(DEVICE) soft-reset @mpremote $(DEVICE) mkdir :/lib || true @find $(BUILD_DIR)/$(PKG) | while read source; do \ rel=$${source#$(BUILD_DIR)/}; \ @@ -79,6 +80,7 @@ deploy: fi; \ sleep 1; \ done + @mpremote $(DEVICE) reset # ----------------------------- # Deploy custom configuration @@ -86,6 +88,7 @@ deploy: .PHONY: deploy-config deploy-config: @echo "Uploading pyrobusta.env" + @mpremote $(DEVICE) soft-reset @if [ -f pyrobusta.env ]; then mpremote $(DEVICE) cp pyrobusta.env :pyrobusta.env; fi # ----------------------------- @@ -152,16 +155,20 @@ run-unix: stage-example .PHONY: deploy-example deploy-example: @echo "Uploading boot.py" + @mpremote $(DEVICE) soft-reset mpremote $(DEVICE) cp $(EXAMPLE_DIR)/boot.py :boot.py + mpremote $(DEVICE) cp $(EXAMPLE_DIR)/app.py :app.py @echo "Uploading pyrobusta.env" @if [ -f pyrobusta.env ]; then mpremote $(DEVICE) cp pyrobusta.env :pyrobusta.env; fi + @mpremote $(DEVICE) reset # ----------------------------- # Run example directly # ----------------------------- .PHONY: run-device run-device: + @mpremote $(DEVICE) soft-reset mpremote $(DEVICE) run $(EXAMPLE_DIR)/app.py @@ -234,12 +241,14 @@ test-unix: stage-test tls-cert # Run functional tests on device # ----------------------------- .PHONY: test-device -test-device: #clean-device upload +test-device: stage-test #clean-device upload + @mpremote $(DEVICE) soft-reset @cd $(TEST_RUNTIME); \ for test in test_*.py; do \ echo "Running $$test"; \ mpremote $(DEVICE) run $$(basename $$test) || exit 1; \ done + @mpremote $(DEVICE) reset # ================================================ # Utilities for TLS @@ -272,8 +281,10 @@ tls-cert: # ----------------------------- .PHONY: deploy-cert deploy-cert: + @mpremote $(DEVICE) soft-reset @mpremote $(DEVICE) cp $(TLS_DIR)/key.der :key.der @mpremote $(DEVICE) cp $(TLS_DIR)/cert.der :cert.der + @mpremote $(DEVICE) reset # ================================================ # Cleanup @@ -305,4 +316,6 @@ clean: clean-build clean-runtime # ----------------------------- .PHONY: clean-device clean-device: + @mpremote $(DEVICE) soft-reset mpremote $(DEVICE) run scripts/clean_device.py + @mpremote $(DEVICE) reset diff --git a/example/mem_usage/boot.py b/example/mem_usage/boot.py index d96f6f6..a7c9c58 100644 --- a/example/mem_usage/boot.py +++ b/example/mem_usage/boot.py @@ -1,4 +1,12 @@ # This file is executed on every boot (including wake-boot from deepsleep) +import machine +from os import listdir + from pyrobusta.con import wifi -wifi.initialize() +connected = wifi.initialize() +if connected and not machine.reset_cause() == machine.SOFT_RESET: + if "app.py" in listdir(): + import app + + app.main() diff --git a/example/mip_repo/boot.py b/example/mip_repo/boot.py index d96f6f6..a7c9c58 100644 --- a/example/mip_repo/boot.py +++ b/example/mip_repo/boot.py @@ -1,4 +1,12 @@ # This file is executed on every boot (including wake-boot from deepsleep) +import machine +from os import listdir + from pyrobusta.con import wifi -wifi.initialize() +connected = wifi.initialize() +if connected and not machine.reset_cause() == machine.SOFT_RESET: + if "app.py" in listdir(): + import app + + app.main() diff --git a/src/pyrobusta/con/wifi.py b/src/pyrobusta/con/wifi.py index 8755f44..b0d85e0 100644 --- a/src/pyrobusta/con/wifi.py +++ b/src/pyrobusta/con/wifi.py @@ -2,7 +2,10 @@ Helpers for setting up Wi-Fi in station mode """ +from time import sleep + from network import WLAN, STA_IF + from ..utils.config import get_config from ..utils import logging @@ -13,19 +16,30 @@ def initialize(): """ ssid = get_config("wifi_ssid") password = get_config("wifi_password") + if not ssid or not password: - logging.warning(__name__ + ": missing SSID/password, skip initialization") - return + logging.warning(__name__ + ": missing SSID/password") + return False sta_if = WLAN(STA_IF) sta_if.active(True) - nets = sta_if.scan() - for net in nets: - if net[0].decode() == get_config("wifi_ssid"): - logging.info(__name__ + f": network {net[0]} found!") - sta_if.connect(net[0], get_config("wifi_password")) - logging.info(__name__ + f": connected, available at {sta_if.ifconfig()[0]}") - break + if sta_if.isconnected(): + logging.info(__name__ + f": already connected IP={sta_if.ifconfig()[0]}") + return True + + sta_if.connect(ssid, password) + + timeout = 30 + while timeout > 0: + if sta_if.isconnected(): + ip = sta_if.ifconfig()[0] + logging.info(__name__ + f": connected, IP={ip}") + return True + sleep(1) + timeout -= 1 + + logging.warning(__name__ + ": connection failed") + return False def get_address(): @@ -33,4 +47,6 @@ def get_address(): Get the address of the WLAN interface """ sta_if = WLAN(STA_IF) - return sta_if.ifconfig()[0] + if sta_if.isconnected(): + return sta_if.ifconfig()[0] + return None From 2b530e27fdd666abfaabb176bc551d303d694108 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Sat, 4 Apr 2026 00:34:45 +0200 Subject: [PATCH 6/9] Revamp file serving and multipart response generator - serve default page under /www, allow files to be server at the /files endpoint - allow per-part content-type setting when generating multipart responses; content-type needs to be returned by callback functions for each part - move file-server related FSM state to a standalone module, enable it optionally (controlled by http_serve_files) - rename public method on_busy() to on_unavailable() to indicate errors related to disabled features - introduce new configuration key (http_serve_files) to globally enable/disable file serving - update default configuration to server /www and /lib/pyrobusta by default - split up functional tests to reduce the footprint of the test module to allow testing with low-SRAM devices, add garbage collection between test units --- docs/configuration.md | 3 +- example/mip_repo/app.py | 2 +- src/pyrobusta/bindings/socket_http.py | 2 +- src/pyrobusta/protocol/http.py | 73 +++------ src/pyrobusta/protocol/http_file_server.py | 57 +++++++ src/pyrobusta/protocol/http_multipart.py | 48 +++--- src/pyrobusta/server/http_server.py | 14 +- src/pyrobusta/utils/config.py | 4 +- src/pyrobusta/utils/helpers.py | 15 ++ tests/functional/test_http.py | 124 +++++---------- tests/functional/test_http_multipart.py | 172 +++++++++++++++++++++ tests/unit/test_helpers.py | 2 + tests/unit/test_http.py | 22 ++- 13 files changed, 357 insertions(+), 181 deletions(-) create mode 100644 src/pyrobusta/protocol/http_file_server.py create mode 100644 tests/functional/test_http_multipart.py diff --git a/docs/configuration.md b/docs/configuration.md index 8ab2b12..660d682 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,8 @@ to upload it to the root directory of the target device. | wifi_password | Password of the Wi-Fi network. When empty, Wi-Fi is not initalized by the built-in wifi.py module. | None | | http_multipart | Enable multipart HTTP requests/responses. | "False" | | http_mem_cap | Max memory cap (% × 0.01) of usable heap for HTTP request/response stream buffers. | 0.1 | -| http_served_paths | Space delimited list of filesystem paths allowed to be served through HTTP. | "/lib/pyrobusta" | +| http_served_paths | Space delimited list of filesystem paths allowed to be served through HTTP. | "/www /lib/pyrobusta" | +| http_serve_files | Enable/disable file serving. | "True" | | socket_max_con | Max number of socket connections of any enabled application server. | 2 | | tls | Enable/disable TLS. When turned on, cert.der/key.der must be installed at the root. | "False" | | log_level | Can be one of: warning, info, debug. | "warning" | diff --git a/example/mip_repo/app.py b/example/mip_repo/app.py index 55a7eea..2564e96 100644 --- a/example/mip_repo/app.py +++ b/example/mip_repo/app.py @@ -22,7 +22,7 @@ def append_package_files(dir, package_files, host_name, protocol): package_files["urls"].append( [ target_path, - f"{protocol}://{host_name}/{current_path}", + f"{protocol}://{host_name}/files/{current_path}", ] ) diff --git a/src/pyrobusta/bindings/socket_http.py b/src/pyrobusta/bindings/socket_http.py index 513baa8..f646719 100644 --- a/src/pyrobusta/bindings/socket_http.py +++ b/src/pyrobusta/bindings/socket_http.py @@ -97,7 +97,7 @@ async def _run_state_machine(self): await self._flush_response() return except ServerBusyError: - self._engine.on_busy(self._send_buf) + self._engine.on_unavailable(self._send_buf) await self._flush_response() return except HeaderParsingError: diff --git a/src/pyrobusta/protocol/http.py b/src/pyrobusta/protocol/http.py index db36961..4171c4d 100644 --- a/src/pyrobusta/protocol/http.py +++ b/src/pyrobusta/protocol/http.py @@ -5,10 +5,8 @@ from json import dumps from io import BytesIO -from os import stat from ..utils.config import get_config -from ..utils.helpers import normalize_path class HeaderParsingError(ValueError): @@ -449,7 +447,7 @@ def on_failure(self, tx, info: bytes): self._write_response_head(tx, len(info)) tx.write(info) - def on_busy(self, tx): + def on_unavailable(self, tx): """Terminate state machine and write 503 response""" self.terminate(503) self._write_response_head(tx) @@ -563,8 +561,7 @@ def _route_request_st(self, _, tx): self.on_method_not_allowed(tx) return if self.method in (self.GET, self.HEAD): - resource = b"index.html" if not self.url else self.url - self.state = lambda _rx, _tx: self._send_file_st(_rx, _tx, resource) + self.state = lambda _rx, _tx: self._send_file_st(_rx, _tx, self.url) return self.on_missing_resource(tx) @@ -619,61 +616,28 @@ def _app_endpoint_st(self, rx, tx): dtype = dtype.encode(self.ASCII) self._set_response_header(b"content-type", dtype) - if dtype in (b"multipart/x-mixed-replace", b"multipart/form-data"): - part_content_type = data[0] - callback = data[1] - if type(callback).__name__ not in ("function", "closure"): - self.on_failure(tx, b"Invalid response handler") - return - self.terminate(200, dtype) - boundary = self.MULTIPART_BOUNDARY - self._set_response_header( - b"content-type", dtype + b"; boundary=" + boundary + if dtype.startswith(b"multipart/"): + self.state = lambda _rx, _tx: self._generate_multipart_response( + _rx, _tx, data, dtype ) - self._write_response_head(tx, None) - if self.method != self.HEAD: - return self._multipart_wrapper_factory( - callback, part_content_type.encode(self.ASCII), boundary - ) return + self.terminate(200, dtype) return self._generate_response(tx, data) - def _send_file_st(self, _, tx, web_resource: bytes): - """State for returning a static resource""" - extension = web_resource.rsplit(b".", 1)[-1] - norm_path = normalize_path(web_resource.decode(self.ASCII)) - is_path_served = self.is_norm_path_served(norm_path) - if not is_path_served: - try: - stat(norm_path) - self.on_forbidden(tx) - return - except OSError: - self.on_missing_resource(tx) - return - try: - content_type = self._lookup(self.CONTENT_TYPES, extension) - except ValueError: - content_type = self._lookup(self.CONTENT_TYPES, b"raw") - try: - self._set_response_header( - b"content-length", str(stat(norm_path)[6]).encode(HttpEngine.ASCII) - ) - self.terminate(200, content_type) - self._write_response_head(tx, None) - if self.method != self.HEAD: - return open(norm_path, "rb") - return - except OSError: - self.on_missing_resource(tx) + def _send_file_st(self, _, tx, web_resource: bytes): # pylint: disable=W0613 + """State for returning a static resource - disabled""" + self.on_unavailable(tx) def _start_multipart_parser_st(self, rx, tx): # pylint: disable=W0613 - self.on_failure(tx, b"Multipart handling is disabled") + """Initial state for processing multipart requests""" + self.on_unavailable(tx) - @staticmethod - def _multipart_wrapper_factory(callback, content_type: bytes, boundary: bytes): - pass + def _generate_multipart_response( + self, rx, tx, callback, dtype + ): # pylint: disable=W0613 + """Generate multipart response depening on the exact content type""" + self.on_unavailable(tx) def enable_optional_features(): @@ -684,3 +648,8 @@ def enable_optional_features(): from pyrobusta.protocol import http_multipart http_multipart.apply_patches() + + if get_config("http_serve_files").lower() == "true": + from pyrobusta.protocol import http_file_server + + http_file_server.apply_patches() diff --git a/src/pyrobusta/protocol/http_file_server.py b/src/pyrobusta/protocol/http_file_server.py new file mode 100644 index 0000000..1e1acc4 --- /dev/null +++ b/src/pyrobusta/protocol/http_file_server.py @@ -0,0 +1,57 @@ +""" +State machine extension for file serving. +""" + +# pylint: disable=W0212,R0401 + +from os import stat + +from pyrobusta.protocol import http +from pyrobusta.utils.helpers import normalize_path, add_method + + +def _send_file_st(self, _, tx, web_resource: bytes): + """State for returning a static resource""" + if self.url == b"/files": + web_resource = "/" + elif self.url.startswith(b"/files/"): + web_resource = web_resource[7:] + elif self.url == b"/": + web_resource = b"/www/index.html" + else: + web_resource = b"/www" + web_resource + + extension = web_resource.rsplit(b".", 1)[-1] + norm_path = normalize_path(web_resource.decode(self.ASCII)) + is_path_served = self.is_norm_path_served(norm_path) + if not is_path_served: + try: + stat(norm_path) + self.on_forbidden(tx) + return + except OSError: + self.on_missing_resource(tx) + return + try: + content_type = self._lookup(self.CONTENT_TYPES, extension) + except ValueError: + content_type = self._lookup(self.CONTENT_TYPES, b"raw") + try: + self._set_response_header( + b"content-length", str(stat(norm_path)[6]).encode(http.HttpEngine.ASCII) + ) + self.terminate(200, content_type) + self._write_response_head(tx, None) + if self.method != self.HEAD: + return open(norm_path, "rb") + return + except OSError: + self.on_missing_resource(tx) + + +def apply_patches(): + """ + Apply patches to class attributes for file serving. + """ + + add_method(http.HttpEngine, _send_file_st) diff --git a/src/pyrobusta/protocol/http_multipart.py b/src/pyrobusta/protocol/http_multipart.py index dc95393..e58074a 100644 --- a/src/pyrobusta/protocol/http_multipart.py +++ b/src/pyrobusta/protocol/http_multipart.py @@ -5,24 +5,23 @@ # pylint: disable=W0212,R0401 from pyrobusta.protocol import http +from pyrobusta.utils.helpers import add_method -def add_method(cls, func, method_type="instance"): - """ - Helper to extend web.WebEngine with - additional methods and states. - """ - if method_type == "instance": - setattr(cls, func.__name__, func) - elif method_type == "static": - setattr(cls, func.__name__, staticmethod(func)) - elif method_type == "class": - setattr(cls, func.__name__, classmethod(func)) - else: - raise ValueError("Invalid type") +def _generate_multipart_response(self, _, tx, callback, dtype): + """Generate multipart response depening on the exact content type""" + if type(callback).__name__ not in ("function", "closure"): + self.on_failure(tx, b"Invalid response handler") + return + self.terminate(200, dtype) + boundary = self.MULTIPART_BOUNDARY + self._set_response_header(b"content-type", dtype + b"; boundary=" + boundary) + self._write_response_head(tx, None) + if self.method != self.HEAD: + return self._multipart_wrapper_factory(callback, boundary) -def _multipart_wrapper_factory(callback, content_type: bytes, boundary: bytes): +def _multipart_wrapper_factory(callback, boundary: bytes): """ Factory method for creating closures that write multipart responses :param callback: function without arguments, must return bytes-like objects @@ -30,8 +29,7 @@ def _multipart_wrapper_factory(callback, content_type: bytes, boundary: bytes): :param boundary: boundary value :return closure: closure to invoke for response generation """ - boundary = b"--" + boundary - content_type_header = b"content-type: %s\r\n\r\n" % content_type + delimiter = b"--" + boundary def _multipart_wrapper(tx): """ @@ -41,13 +39,16 @@ def _multipart_wrapper(tx): :return bool: true if the stream is completed """ while True: - tx.write(boundary) - part_body = callback() - if not part_body: + tx.write(delimiter) + part = callback() + if not part: tx.write(b"--") yield True + content_type, part_body = part tx.write(b"\r\n") - tx.write(content_type_header) + tx.write(b"content-type:") + tx.write(content_type.encode("ascii")) + tx.write(b"\r\n\r\n") written = 0 while written < len(part_body): to_write = tx.capacity - tx.size() @@ -139,9 +140,7 @@ def apply_patches(): """ Apply patches to class attributes for multipart parsing. """ - cls = http.HttpEngine - - orig_init = cls.__init__ + orig_init = http.HttpEngine.__init__ def new_init(self, *args, **kwargs): orig_init(self, *args, **kwargs) @@ -150,8 +149,9 @@ def new_init(self, *args, **kwargs): self.mp_delimiter = None self.mp_last_delimiter = None - cls.__init__ = new_init + http.HttpEngine.__init__ = new_init + add_method(http.HttpEngine, _generate_multipart_response) add_method(http.HttpEngine, _multipart_wrapper_factory, "static") add_method(http.HttpEngine, _start_multipart_parser_st) add_method(http.HttpEngine, _parse_boundary_st) diff --git a/src/pyrobusta/server/http_server.py b/src/pyrobusta/server/http_server.py index 2249c0f..c8f5d1b 100644 --- a/src/pyrobusta/server/http_server.py +++ b/src/pyrobusta/server/http_server.py @@ -10,6 +10,7 @@ from ..bindings.socket_http import SocketHttp from ..stream.buffer import MemoryPool, SlidingBuffer from ..utils.config import get_config +from ..utils.helpers import normalize_path from ..utils import logging @@ -151,7 +152,7 @@ async def _reserve_buffers(self): return recv_buf, send_buf - async def _accept_socket_http(self, reader, writer): + async def _accept_socket(self, reader, writer): """ Handle incoming socket connection for HTTP. - creates SocketHttp object @@ -185,7 +186,9 @@ async def start_socket_server(self): try: collect() http.enable_optional_features() - logging.debug(f"Registered endpoints: {http.HttpEngine.ENDPOINTS}") + logging.debug( + __name__ + f"registered endpoints: {http.HttpEngine.ENDPOINTS}" + ) self._max_clients = int(get_config("socket_max_con")) self._init_pools(self._max_clients) ssl_ctx = None @@ -194,10 +197,13 @@ async def start_socket_server(self): import ssl ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ssl_ctx.load_cert_chain(self.TLS_CERT_PATH, self.TLS_KEY_PATH) + ssl_ctx.load_cert_chain( + normalize_path(self.TLS_CERT_PATH), + normalize_path(self.TLS_KEY_PATH), + ) self._server = await start_server( - self._accept_socket_http, + self._accept_socket, self._host, self._port, backlog=max(1, self._max_clients), diff --git a/src/pyrobusta/utils/config.py b/src/pyrobusta/utils/config.py index 6a7d8e3..7c9a320 100644 --- a/src/pyrobusta/utils/config.py +++ b/src/pyrobusta/utils/config.py @@ -19,7 +19,9 @@ "http_mem_cap", 0.1, "http_served_paths", - "/lib/pyrobusta", + "/www /lib/pyrobusta", + "http_serve_files", + "True", "socket_max_con", 2, "tls", diff --git a/src/pyrobusta/utils/helpers.py b/src/pyrobusta/utils/helpers.py index cc0d9d3..08f647a 100644 --- a/src/pyrobusta/utils/helpers.py +++ b/src/pyrobusta/utils/helpers.py @@ -25,3 +25,18 @@ def normalize_path(path: str): return cwd + normalized return cwd + "/" + normalized return cwd + + +def add_method(cls, func, method_type="instance"): + """ + Helper to patch/extend classes with + additional methods and states. + """ + if method_type == "instance": + setattr(cls, func.__name__, func) + elif method_type == "static": + setattr(cls, func.__name__, staticmethod(func)) + elif method_type == "class": + setattr(cls, func.__name__, classmethod(func)) + else: + raise ValueError("Invalid type") diff --git a/tests/functional/test_http.py b/tests/functional/test_http.py index 7fbc259..8800db0 100644 --- a/tests/functional/test_http.py +++ b/tests/functional/test_http.py @@ -1,11 +1,11 @@ import asyncio import ssl import json +import gc -from os import getcwd, mkdir +from os import mkdir, remove, rmdir from pyrobusta.server import http_server -from pyrobusta.protocol import http_multipart from pyrobusta.protocol.http import ( HttpEngine, enable_optional_features, @@ -19,6 +19,15 @@ ################################################# +def garbage_collect(coroutine): + async def decorated(*args, **kwargs): + gc.collect() + await coroutine(*args, **kwargs) + gc.collect() + + return decorated + + def test_assert(name, actual, expected): print(f"Test {name}: ", end="") if actual == expected: @@ -55,19 +64,6 @@ async def send_request(request, tls=False): return response -def multipart_response(num_responses): - i = 0 - - def response_generator(): - nonlocal i - i += 1 - if i > num_responses: - return None - return b"Response %s" % i - - return response_generator - - ################################################# # Test driver ################################################# @@ -82,12 +78,6 @@ def simple_callback(http_ctx, _): raise ValueError("Unhandled content-type") -@HttpEngine.route("/test/multipart", "GET") -def multipart_callback(http_ctx, _): - part_count = int(http_ctx.headers["x-part-count"]) - return "multipart/form-data", ("text/plain", multipart_response(part_count)) - - @HttpEngine.route("/test/busy", "POST") def busy_callback(*_): raise ServerBusyError() @@ -113,8 +103,9 @@ async def start_server(): return server, server_task +@garbage_collect async def test_simple_response(tls_enabled): - setup_config(multipart=False, tls_enabled=tls_enabled) + setup_config(tls_enabled=tls_enabled) server, server_task = await start_server() # Test: text/plain @@ -157,38 +148,7 @@ async def test_simple_response(tls_enabled): await server.terminate() -async def test_multipart_response(tls_enabled): - setup_config(multipart=True, tls_enabled=tls_enabled) - server, server_task = await start_server() - - # Test: 1 part - plain_response = await send_request( - b"GET /test/multipart HTTP/1.1\r\n" - b"Host: localhost\r\nX-Part-Count: 1\r\n\r\n", - tls_enabled, - ) - test_assert( - f"http{"s" if tls_enabled else ""} response contains 1 part", - b"Response 1" in plain_response, - True, - ) - - # Test: 10 parts - plain_response = await send_request( - b"GET /test/multipart HTTP/1.1\r\n" - b"Host: localhost\r\nX-Part-Count: 10\r\n\r\n", - tls_enabled, - ) - test_assert( - f"http{"s" if tls_enabled else ""} response contains 10 parts", - [b"Response %s" % i in plain_response for i in range(1, 11)], - [True] * 10, - ) - - server_task.cancel() - await server.terminate() - - +@garbage_collect async def test_server_busy(): setup_config() server, server_task = await start_server() @@ -206,6 +166,7 @@ async def test_server_busy(): await server.terminate() +@garbage_collect async def test_chunked_transfer_encoding(): setup_config() create_chunked_app_endpoint("/test/chunked") @@ -233,25 +194,33 @@ async def test_chunked_transfer_encoding(): await server.terminate() +@garbage_collect async def test_fs_access_control(): - setup_config(served_paths="/www") + setup_config(served_paths="/www/allowed") server, server_task = await start_server() + workdir_root = normalize_path("/www") + try: + mkdir(workdir_root) + except: + pass # Index page under /www -> accepted - workdir = normalize_path("/www") - index_html = normalize_path("/www/index.html") - mkdir(workdir) - with open(index_html, "w") as f: + allowed_workdir = normalize_path("/www/allowed") + allowed_index_html = normalize_path("/www/allowed/index.html") + mkdir(allowed_workdir) + with open(allowed_index_html, "w") as f: f.write("PyRobusta Home") # Index page under / -> rejected - index_html = normalize_path("/index.html") - with open(index_html, "w") as f: + rejected_workdir = normalize_path("/www/rejected") + rejected_index_html = normalize_path("/www/rejected/index.html") + mkdir(rejected_workdir) + with open(rejected_index_html, "w") as f: f.write("PyRobusta Home") # Case #1: /www/index.html response = await send_request( - (b"GET /www/index.html HTTP/1.1\r\n" b"Host: localhost\r\n\r\n") + (b"GET /allowed/index.html HTTP/1.1\r\n" b"Host: localhost\r\n\r\n") ) response_body = response.split(b"\r\n\r\n")[1] @@ -263,7 +232,7 @@ async def test_fs_access_control(): # Case #2: /index.html response = await send_request( - (b"GET /index.html HTTP/1.1\r\n" b"Host: localhost\r\n\r\n") + (b"GET /rejected/index.html HTTP/1.1\r\n" b"Host: localhost\r\n\r\n") ) test_assert( @@ -272,6 +241,10 @@ async def test_fs_access_control(): True, ) + remove(allowed_index_html) + remove(rejected_index_html) + rmdir(allowed_workdir) + rmdir(rejected_workdir) server_task.cancel() await server.terminate() @@ -281,12 +254,12 @@ async def test_fs_access_control(): ################################################# -def setup_config(multipart=False, tls_enabled=False, served_paths=""): +def setup_config(tls_enabled=False, served_paths=""): http_server.HttpServer.LISTEN_PORT_HTTP = 8080 http_server.HttpServer.LISTEN_PORT_HTTPS = 4443 - config_idx = config.CONFIG_CACHE.index("http_multipart") - config.CONFIG_CACHE[config_idx + 1] = str(multipart) + config_idx = config.CONFIG_CACHE.index("log_level") + config.CONFIG_CACHE[config_idx + 1] = str("warning") config_idx = config.CONFIG_CACHE.index("tls") config.CONFIG_CACHE[config_idx + 1] = str(tls_enabled) config_idx = config.CONFIG_CACHE.index("http_served_paths") @@ -303,12 +276,6 @@ def test_registration(): HttpEngine._get_callback(b"/test/simple", b"GET"), ) - test_assert( - "multipart endpoint registration", - multipart_callback, - HttpEngine._get_callback(b"/test/multipart", b"GET"), - ) - test_assert( "busy endpoint registration", busy_callback, @@ -316,24 +283,11 @@ def test_registration(): ) -def test_multipart_patches(): - setup_config(multipart=True) - test_assert( - "multipart state machine patches", - http_multipart._start_multipart_parser_st, - HttpEngine._start_multipart_parser_st, - ) - - def test_main(): test_registration() asyncio.run(test_simple_response(tls_enabled=False)) asyncio.run(test_simple_response(tls_enabled=True)) - test_multipart_patches() - asyncio.run(test_multipart_response(tls_enabled=False)) - asyncio.run(test_multipart_response(tls_enabled=True)) - asyncio.run(test_server_busy()) asyncio.run(test_chunked_transfer_encoding()) asyncio.run(test_fs_access_control()) diff --git a/tests/functional/test_http_multipart.py b/tests/functional/test_http_multipart.py new file mode 100644 index 0000000..5463a59 --- /dev/null +++ b/tests/functional/test_http_multipart.py @@ -0,0 +1,172 @@ +import asyncio +import ssl +import gc + +from pyrobusta.server import http_server +from pyrobusta.protocol import http_multipart +from pyrobusta.protocol.http import ( + HttpEngine, + enable_optional_features, +) +from pyrobusta.utils import config + +################################################# +# Test helpers +################################################# + + +def garbage_collect(coroutine): + async def decorated(*args, **kwargs): + gc.collect() + await coroutine(*args, **kwargs) + gc.collect() + + return decorated + + +def test_assert(name, actual, expected): + print(f"Test {name}: ", end="") + if actual == expected: + print("OK") + else: + print("Fail") + raise AssertionError(f"{actual} != {expected}") + + +async def send_request(request, tls=False): + port = ( + http_server.HttpServer.LISTEN_PORT_HTTPS + if tls + else http_server.HttpServer.LISTEN_PORT_HTTP + ) + + ctx = None + if tls: + # Disable certificate verification due to self-signed cert + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.verify_mode = ssl.CERT_NONE + + reader, writer = await asyncio.open_connection("127.0.0.1", port, ssl=ctx) + writer.write(request) + await writer.drain() + + to_read = True + response = b"" + while to_read: + response_part = await reader.read(1024) + response += response_part + to_read = len(response_part) + writer.close() + return response + + +def multipart_response(num_responses): + i = 0 + + def response_generator(): + nonlocal i + i += 1 + if i > num_responses: + return None + return "text/plain", b"Response %s" % i + + return response_generator + + +################################################# +# Test driver +################################################# + + +@HttpEngine.route("/test/multipart", "GET") +def multipart_callback(http_ctx, _): + part_count = int(http_ctx.headers["x-part-count"]) + return "multipart/form-data", multipart_response(part_count) + + +async def start_server(): + """ + Start an HTTP server as a background task + """ + server = http_server.HttpServer() + server_task = asyncio.create_task(server.start_socket_server()) + await asyncio.sleep_ms(100) + return server, server_task + + +@garbage_collect +async def test_multipart_response(tls_enabled): + setup_config(tls_enabled=tls_enabled) + server, server_task = await start_server() + + # Test: 1 part + plain_response = await send_request( + b"GET /test/multipart HTTP/1.1\r\n" + b"Host: localhost\r\nX-Part-Count: 1\r\n\r\n", + tls_enabled, + ) + test_assert( + f"http{"s" if tls_enabled else ""} response contains 1 part", + b"Response 1" in plain_response, + True, + ) + + # Test: 10 parts + plain_response = await send_request( + b"GET /test/multipart HTTP/1.1\r\n" + b"Host: localhost\r\nX-Part-Count: 10\r\n\r\n", + tls_enabled, + ) + test_assert( + f"http{"s" if tls_enabled else ""} response contains 10 parts", + [b"Response %s" % i in plain_response for i in range(1, 11)], + [True] * 10, + ) + + server_task.cancel() + await server.terminate() + + +################################################# +# Test methods +################################################# + + +def setup_config(tls_enabled=False): + http_server.HttpServer.LISTEN_PORT_HTTP = 8080 + http_server.HttpServer.LISTEN_PORT_HTTPS = 4443 + + config_idx = config.CONFIG_CACHE.index("log_level") + config.CONFIG_CACHE[config_idx + 1] = str("warning") + config_idx = config.CONFIG_CACHE.index("http_multipart") + config.CONFIG_CACHE[config_idx + 1] = "True" + config_idx = config.CONFIG_CACHE.index("tls") + config.CONFIG_CACHE[config_idx + 1] = str(tls_enabled) + enable_optional_features() + + +def test_registration(): + test_assert( + "multipart endpoint registration", + multipart_callback, + HttpEngine._get_callback(b"/test/multipart", b"GET"), + ) + + +def test_multipart_patches(): + setup_config() + test_assert( + "multipart state machine patches", + http_multipart._start_multipart_parser_st, + HttpEngine._start_multipart_parser_st, + ) + + +def test_main(): + test_registration() + test_multipart_patches() + asyncio.run(test_multipart_response(tls_enabled=False)) + asyncio.run(test_multipart_response(tls_enabled=True)) + + +test_main() diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 6886fcc..10ee8a8 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -26,6 +26,7 @@ def test_path_normalization_virtual_root(self): cwd = getcwd() for case in ( ("", ""), + ("/", f"{cwd}"), ("/path/to/resource", f"{cwd}/path/to/resource"), ("/path/to/resource/", f"{cwd}/path/to/resource"), ("///path///to///resource///", f"{cwd}/path/to/resource"), @@ -45,6 +46,7 @@ def test_path_normalization_host_root(self, _): """ for case in ( ("", ""), + ("/", "/"), ("/path/to/resource", "/path/to/resource"), ("/path/to/resource/", "/path/to/resource"), ("///path///to///resource///", "/path/to/resource"), diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 19a7166..9afec41 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -30,9 +30,7 @@ def setUp(self): }, ) self.patcher.start() - - for key, value in self.config.items(): - self.set_mock_config(key, value) + self.set_mock_config() # Load your web and buffer modules self.helpers_module = load_module("pyrobusta/utils/helpers.py") @@ -47,11 +45,11 @@ def setUp(self): def tearDown(self): self.patcher.stop() - def set_mock_config(self, key, value): + def set_mock_config(self): def side_effect(input_arg, *_, **__): - if input_arg == key: - return value - raise ValueError(f"Unexpected argument: {input_arg}") + if input_arg in self.config: + return self.config[input_arg] + raise ValueError(f"Unexpected config key: {input_arg}") self.mock_utils_config.get_config.side_effect = side_effect @@ -63,7 +61,7 @@ class TestWebStateMachine(TestWebStateMachineBase): @classmethod def setUpClass(cls): - cls.config = {} + cls.config = {"http_multipart": "False", "http_serve_files": "False"} def test_status_parsing_valid(self): request = b"GET /index.html HTTP/1.1\r\nContent-Length:10" @@ -415,7 +413,7 @@ def test_chunked_transfer_encoding_chunk_incomplete(self): self.assertEqual(self.engine.state, self.engine._recv_chunk_st) def test_path_serving_list(self): - self.set_mock_config("http_served_paths", "/path/to/dir1 /path/to/dir2") + self.config["http_served_paths"] = "/path/to/dir1 /path/to/dir2" self.assertEqual(self.engine.is_norm_path_served(""), False) self.assertEqual(self.engine.is_norm_path_served("/"), False) self.assertEqual(self.engine.is_norm_path_served("/path/to/dir1"), True) @@ -427,13 +425,13 @@ def test_path_serving_list(self): self.assertEqual(self.engine.is_norm_path_served("/path/to"), False) def test_path_serving_root(self): - self.set_mock_config("http_served_paths", "/") + self.config["http_served_paths"] = "/" self.assertEqual(self.engine.is_norm_path_served(""), True) self.assertEqual(self.engine.is_norm_path_served("/"), True) self.assertEqual(self.engine.is_norm_path_served("/path/to/served"), True) def test_path_serving_none(self): - self.set_mock_config("http_served_paths", "") + self.config["http_served_paths"] = "" self.assertEqual(self.engine.is_norm_path_served(""), False) self.assertEqual(self.engine.is_norm_path_served("/"), False) self.assertEqual(self.engine.is_norm_path_served("/path/to/served"), False) @@ -446,7 +444,7 @@ class TestMultipartStateMachine(TestWebStateMachineBase): @classmethod def setUpClass(cls): - cls.config = {"http_multipart": "True"} + cls.config = {"http_multipart": "True", "http_serve_files": "True"} def test_multipart_parser(self): for case in [ From 2386b78a7b07d4d9dbe621e9bb90890a31525a4e Mon Sep 17 00:00:00 2001 From: szeka9 Date: Sat, 4 Apr 2026 00:40:44 +0200 Subject: [PATCH 7/9] Miscellaneous improvements in printouts, update defaults - allow in-line comments in config - change default log-level to info - run garbage collection after HTTP server termination --- Makefile | 12 +++++++++--- docs/configuration.md | 2 +- example/mem_usage/app.py | 4 ++-- src/pyrobusta/server/http_server.py | 1 + src/pyrobusta/utils/config.py | 11 ++++++----- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index b8b8de8..6b13955 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,13 @@ DEVICE ?= u0 SRC_DIR := src TEST_DIR := tests -EXAMPLE_DIR := example/mem_usage +EXAMPLE_DIR := example/mip_repo BUILD_DIR := build DIST_DIR := dist -PKG := pyrobusta TLS_DIR := tls +ASSETS_DIR := assets + +PKG := pyrobusta MICROPY_ROOT := external/micropython MPY_CROSS := $(MICROPY_ROOT)/mpy-cross/build/mpy-cross @@ -131,7 +133,7 @@ stage-example: @echo "Copying built package" @cp -r build/pyrobusta $(RUNTIME_DIR)/lib - @echo "Copying example files" + @echo "Copying example app" @cp $(EXAMPLE_DIR)/app.py $(RUNTIME_DIR)/ @cp $(EXAMPLE_DIR)/boot.py $(RUNTIME_DIR)/ @@ -233,7 +235,9 @@ test-unix: TLS_DIR=$(TEST_RUNTIME) test-unix: stage-test tls-cert @cd $(TEST_RUNTIME); \ for test in test_*.py; do \ + echo "\n==================================="; \ echo "Running $$test"; \ + echo "==================================="; \ MICROPYPATH=":.frozen:lib" ../$(MICROPYTHON) $$(basename $$test) || exit 1; \ done @@ -245,7 +249,9 @@ test-device: stage-test #clean-device upload @mpremote $(DEVICE) soft-reset @cd $(TEST_RUNTIME); \ for test in test_*.py; do \ + echo "\n==================================="; \ echo "Running $$test"; \ + echo "==================================="; \ mpremote $(DEVICE) run $$(basename $$test) || exit 1; \ done @mpremote $(DEVICE) reset diff --git a/docs/configuration.md b/docs/configuration.md index 660d682..f6b63fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,4 +13,4 @@ to upload it to the root directory of the target device. | http_serve_files | Enable/disable file serving. | "True" | | socket_max_con | Max number of socket connections of any enabled application server. | 2 | | tls | Enable/disable TLS. When turned on, cert.der/key.der must be installed at the root. | "False" | -| log_level | Can be one of: warning, info, debug. | "warning" | +| log_level | Can be one of: warning, info, debug. | "info" | diff --git a/example/mem_usage/app.py b/example/mem_usage/app.py index bccf0ae..b3ac746 100644 --- a/example/mem_usage/app.py +++ b/example/mem_usage/app.py @@ -23,11 +23,11 @@ def mem_usage(http_ctx, _): selector = http_ctx.get_url_encoded_query_param(http_ctx.query, "key", "") if selector == "free": if value_format == "%": - free = 100 * free / (used + free) + free = round(100 * free / (used + free),2) return "text/plain", f"Free [{value_format}]: {free}\n" if selector == "used": if value_format == "%": - used = 100 * used / (used + free) + used = round(100 * used / (used + free),2) return "text/plain", f"Used [{value_format}]: {used}\n" if selector == "total": return "text/plain", f"Total [bytes]: {used + free}\n" diff --git a/src/pyrobusta/server/http_server.py b/src/pyrobusta/server/http_server.py index c8f5d1b..6761469 100644 --- a/src/pyrobusta/server/http_server.py +++ b/src/pyrobusta/server/http_server.py @@ -178,6 +178,7 @@ async def _accept_socket(self, reader, writer): if recv_buf: recv_buf.consume() self.RECV_POOL.release(recv_buf) + collect() async def start_socket_server(self): """ diff --git a/src/pyrobusta/utils/config.py b/src/pyrobusta/utils/config.py index 7c9a320..9a7bb8f 100644 --- a/src/pyrobusta/utils/config.py +++ b/src/pyrobusta/utils/config.py @@ -27,7 +27,7 @@ "tls", "False", "log_level", - "warning", + "info", ] @@ -48,11 +48,12 @@ def read_config(config=CONFIG_LOCATION): try: with open(config, encoding="utf-8") as conf: for line in conf: - line = line.rstrip("\r\n") - key = line.split("=")[0].strip() - if key.startswith("#") or not line.strip(): + line = line.rstrip("\r\n").split("#")[0] + if not line.strip(): continue - value = line.split("=")[1].strip().strip("'").strip('"') + parts = line.split("=") + key = parts[0].strip() + value = parts[1].strip().strip("'").strip('"') if key and value: value = normalize(key, value) if ( From 23b99ef7123d65518c35b76b1993cb5b05f8f440 Mon Sep 17 00:00:00 2001 From: szeka9 Date: Sat, 4 Apr 2026 01:01:11 +0200 Subject: [PATCH 8/9] Serve default web server page Add new page to be served at the server root. Document example steps for setting up the server; include the demo app under /examples. Create helper module (assets) to install default page under /www. --- Makefile | 20 +++++ assets/www/examples.html | 157 ++++++++++++++++++++++++++++++++++ assets/www/index.html | 55 ++++++++++++ example/demo_app/app.py | 33 +++++++ example/demo_app/boot.py | 12 +++ scripts/install_www.py | 3 + src/pyrobusta/utils/assets.py | 79 +++++++++++++++++ 7 files changed, 359 insertions(+) create mode 100644 assets/www/examples.html create mode 100644 assets/www/index.html create mode 100644 example/demo_app/app.py create mode 100644 example/demo_app/boot.py create mode 100644 scripts/install_www.py create mode 100644 src/pyrobusta/utils/assets.py diff --git a/Makefile b/Makefile index 6b13955..dd5c531 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,11 @@ toolchain: # ----------------------------- .PHONY: build build: $(MPY_TARGETS) $(INIT_TARGETS) + @mkdir -p $(BUILD_DIR) + @if [ -d assets ]; then \ + echo "Copying assets/ -> $(BUILD_DIR)"; \ + cp -r assets $(BUILD_DIR)/${PKG}/; \ + fi # Compile .py -> .mpy $(BUILD_DIR)/%.mpy: $(SRC_DIR)/%.py @@ -92,6 +97,18 @@ deploy-config: @echo "Uploading pyrobusta.env" @mpremote $(DEVICE) soft-reset @if [ -f pyrobusta.env ]; then mpremote $(DEVICE) cp pyrobusta.env :pyrobusta.env; fi + @mpremote $(DEVICE) reset + + +# ----------------------------- +# Deploy index page # TODO use install_www from assets module +# ----------------------------- +.PHONY: deploy-www +deploy-www: + @echo "Deploying /www" + @mpremote $(DEVICE) soft-reset + @mpremote $(DEVICE) run scripts/install_www.py + @mpremote $(DEVICE) reset # ----------------------------- # Full redeploy @@ -112,6 +129,9 @@ publish: @sed -E -i.bak 's/(PYROBUSTA_VERSION[[:space:]]*=[[:space:]]*)"[^"]*"/\1"$(PYROBUSTA_VERSION)"/' \ $(SRC_DIR)/pyrobusta/utils/config.py \ && rm -f $(SRC_DIR)/pyrobusta/utils/config.py.bak + @sed -E -i.bak 's/(PyRobusta[[:space:]]).+([[:space:]]Web Server)/\1$(PYROBUSTA_VERSION)\2/' \ + $(ASSETS_DIR)/www/*.html \ + && rm -f $(SRC_DIR)/www/*.bak $(MAKE) clean $(MAKE) build BUILD_DIR=$(DIST_DIR) scripts/update_package.bash $(DIST_DIR) package.json $(PYROBUSTA_VERSION) diff --git a/assets/www/examples.html b/assets/www/examples.html new file mode 100644 index 0000000..e88321c --- /dev/null +++ b/assets/www/examples.html @@ -0,0 +1,157 @@ + + + + + + PyRobusta Home + + + + +

Getting Started

+ + ← Back + +

This page presents useful examples to configure your server.

+ +
+ +

Server configuration

+ +

+ +
+ +

Simple Server Application

+

The below example demonstrates how to set up a simple application, exposed at /app.

+ + + +

Soft reset the device and upload app.py and boot.py with mpremote.

+ + +

Hard reset the device to start the application and connect over REPL.

+ + +

Use curl to test your application.

+ + + + + \ No newline at end of file diff --git a/assets/www/index.html b/assets/www/index.html new file mode 100644 index 0000000..8175a9f --- /dev/null +++ b/assets/www/index.html @@ -0,0 +1,55 @@ + + + + + + PyRobusta Home + + + + +

PyRobusta Home

+ +

The server is running correctly and is ready to serve content.

+ +
+ +

Available Resources

+ + + + + + \ No newline at end of file diff --git a/example/demo_app/app.py b/example/demo_app/app.py new file mode 100644 index 0000000..4d2491b --- /dev/null +++ b/example/demo_app/app.py @@ -0,0 +1,33 @@ +import asyncio +from gc import mem_free, mem_alloc + +from pyrobusta.server import http_server +from pyrobusta.protocol.http import HttpEngine +from pyrobusta.utils import logging + + +@HttpEngine.route("/app", "GET") +def app(http_ctx, payload): + free = mem_free() + value_format = "bytes" + + if http_ctx.query: + value_format = http_ctx.get_url_encoded_query_param( + http_ctx.query, "format", default="bytes" + ) + if value_format not in ("%", "bytes"): + raise ValueError("invalid format") + + if value_format == "%": + free = round(100 * free / (free + mem_alloc()), 2) + + return "text/plain", (f"Free memory [{value_format}]: {free}\n") + + +def main(): + http_server.main() + try: + asyncio.get_event_loop().run_forever() + except Exception as e: + logging.warning(f"loop stopped: {e}") + asyncio.get_event_loop().close() diff --git a/example/demo_app/boot.py b/example/demo_app/boot.py new file mode 100644 index 0000000..a7c9c58 --- /dev/null +++ b/example/demo_app/boot.py @@ -0,0 +1,12 @@ +# This file is executed on every boot (including wake-boot from deepsleep) +import machine +from os import listdir + +from pyrobusta.con import wifi + +connected = wifi.initialize() +if connected and not machine.reset_cause() == machine.SOFT_RESET: + if "app.py" in listdir(): + import app + + app.main() diff --git a/scripts/install_www.py b/scripts/install_www.py new file mode 100644 index 0000000..fcc8913 --- /dev/null +++ b/scripts/install_www.py @@ -0,0 +1,3 @@ +import pyrobusta.utils.assets as assets + +assets.install_www() \ No newline at end of file diff --git a/src/pyrobusta/utils/assets.py b/src/pyrobusta/utils/assets.py new file mode 100644 index 0000000..c5f5a12 --- /dev/null +++ b/src/pyrobusta/utils/assets.py @@ -0,0 +1,79 @@ +""" +Helper functions to install assets. +""" + +from os import mkdir, listdir, stat + +from .helpers import normalize_path + +FS_ITER_ABS = 0 +FS_ITER_REL = 1 + +FS_ITER_FILE = 0 +FS_ITER_DIR = 1 + + +def copy_file(src_path, dst_path): + """ + Copy a file from a to a destination path. + """ + with open(src_path, "rb") as src: + with open(dst_path, "wb") as dst: + while True: + chunk = src.read(512) + if not chunk: + break + dst.write(chunk) + + +def iterate_fs(root, iter_mode=FS_ITER_FILE, path_mode=FS_ITER_ABS): + """ + Iterate over all files or directories and yield + resulting paths either as absolute or relative paths. + :param dir: directory in which to iterate + :iter_mode int: iterate over files (FS_ITER_FILE=0) or directories (FS_ITER_DIR=1) + :path_mode int: yield absolute paths (FS_ITER_ABS=0) ro relative paths (FS_ITER_REL=1) + """ + dirs = [root] + while dirs: + current_directory = dirs.pop(0) + for name in listdir(current_directory): + if current_directory == "/": + current_path = "/" + name + else: + current_path = current_directory + "/" + name + st = stat(current_path) + fs_mode = st[0] + if fs_mode & 0x4000: # directory bit set + dirs.append(current_path) + if iter_mode == FS_ITER_DIR: + if path_mode == FS_ITER_REL: + yield current_path[len(root) + 1 :] + else: + yield current_path + else: + continue + if iter_mode == FS_ITER_FILE: + if path_mode == FS_ITER_REL: + yield current_path[len(root) + 1 :] + else: + yield current_path + + +def install_www(): + """ + Install default web server assets under /www. + """ + source_dir = normalize_path("/lib/pyrobusta/assets/www") + target_dir = normalize_path("/www") + if "www" not in listdir(): + mkdir(target_dir) + + for asset_dir in iterate_fs(source_dir, FS_ITER_DIR, FS_ITER_ABS): + mkdir(asset_dir) + + for asset in iterate_fs(source_dir, FS_ITER_FILE, FS_ITER_REL): + copy_file( + source_dir + "/" + asset, + target_dir + "/" + asset, + ) From a70f861430a5d5ff77933f1f20cc7ca3c098eb6d Mon Sep 17 00:00:00 2001 From: szeka9 Date: Sat, 4 Apr 2026 01:25:05 +0200 Subject: [PATCH 9/9] Bump version to v0.4.0 --- Makefile | 4 +- assets/www/examples.html | 2 +- assets/www/index.html | 2 +- dist/pyrobusta/assets/www/examples.html | 157 +++++++++++++++++++ dist/pyrobusta/assets/www/index.html | 55 +++++++ dist/pyrobusta/bindings/socket_http.mpy | Bin 3001 -> 2088 bytes dist/pyrobusta/con/wifi.mpy | Bin 564 -> 615 bytes dist/pyrobusta/protocol/http.mpy | Bin 7369 -> 7248 bytes dist/pyrobusta/protocol/http_file_server.mpy | Bin 0 -> 839 bytes dist/pyrobusta/protocol/http_multipart.mpy | Bin 1832 -> 2035 bytes dist/pyrobusta/server/http_server.mpy | Bin 1979 -> 2987 bytes dist/pyrobusta/utils/assets.mpy | Bin 0 -> 719 bytes dist/pyrobusta/utils/config.mpy | Bin 856 -> 883 bytes dist/pyrobusta/utils/helpers.mpy | Bin 241 -> 426 bytes package.json | 18 ++- src/pyrobusta/utils/config.py | 2 +- 16 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 dist/pyrobusta/assets/www/examples.html create mode 100644 dist/pyrobusta/assets/www/index.html create mode 100644 dist/pyrobusta/protocol/http_file_server.mpy create mode 100644 dist/pyrobusta/utils/assets.mpy diff --git a/Makefile b/Makefile index dd5c531..f99940a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PYROBUSTA_VERSION := v0.3.0 +PYROBUSTA_VERSION := v0.4.0 DEVICE ?= u0 SRC_DIR := src @@ -131,7 +131,7 @@ publish: && rm -f $(SRC_DIR)/pyrobusta/utils/config.py.bak @sed -E -i.bak 's/(PyRobusta[[:space:]]).+([[:space:]]Web Server)/\1$(PYROBUSTA_VERSION)\2/' \ $(ASSETS_DIR)/www/*.html \ - && rm -f $(SRC_DIR)/www/*.bak + && rm -f $(ASSETS_DIR)/www/*.html.bak $(MAKE) clean $(MAKE) build BUILD_DIR=$(DIST_DIR) scripts/update_package.bash $(DIST_DIR) package.json $(PYROBUSTA_VERSION) diff --git a/assets/www/examples.html b/assets/www/examples.html index e88321c..095c675 100644 --- a/assets/www/examples.html +++ b/assets/www/examples.html @@ -151,7 +151,7 @@

Simple Server Application

\ No newline at end of file diff --git a/assets/www/index.html b/assets/www/index.html index 8175a9f..2f49452 100644 --- a/assets/www/index.html +++ b/assets/www/index.html @@ -48,7 +48,7 @@

Available Resources

diff --git a/dist/pyrobusta/assets/www/examples.html b/dist/pyrobusta/assets/www/examples.html new file mode 100644 index 0000000..095c675 --- /dev/null +++ b/dist/pyrobusta/assets/www/examples.html @@ -0,0 +1,157 @@ + + + + + + PyRobusta Home + + + + +

Getting Started

+ + ← Back + +

This page presents useful examples to configure your server.

+ +
+ +

Server configuration

+ +

+ +
+ +

Simple Server Application

+

The below example demonstrates how to set up a simple application, exposed at /app.

+ + + +

Soft reset the device and upload app.py and boot.py with mpremote.

+ + +

Hard reset the device to start the application and connect over REPL.

+ + +

Use curl to test your application.

+ + + + + \ No newline at end of file diff --git a/dist/pyrobusta/assets/www/index.html b/dist/pyrobusta/assets/www/index.html new file mode 100644 index 0000000..2f49452 --- /dev/null +++ b/dist/pyrobusta/assets/www/index.html @@ -0,0 +1,55 @@ + + + + + + PyRobusta Home + + + + +

PyRobusta Home

+ +

The server is running correctly and is ready to serve content.

+ +
+ +

Available Resources

+ + + + + + \ No newline at end of file diff --git a/dist/pyrobusta/bindings/socket_http.mpy b/dist/pyrobusta/bindings/socket_http.mpy index b56ceb730aadb38c6d71e2fe4c72dd2db89ead53..6ee7b49a327f43412615dbf66ec72436c79f4db4 100644 GIT binary patch literal 2088 zcmZva-%}b_6vr<=B}i~x)(ufq0+_IfSP7!FO^XYtXf&>nIJC9vx?FW9%fjp~q&9U1 zt&?`zKK8l)L1#ddKOnJv>1&_z&}8z`nZ9+>f1vj+Voch7*g1Rmp6~gd&pFF>Fudeg z)Kqc2x~EpQYML&LZ%Il?Qg*a)ts>rsI=`#y)mU{8^tc6WPZ6aGAT$}mDqq%sTdc@3 z6m?+T5y22!+unxiTuqjmOI#k)3w%1k)EkL0J)m1^&khL@Qs#?)2$UqD`1a7&qgOYcEv!+Y3)~SY7 zLD0>^wld9ERaoaypE?92uR^iTBYP;+pi(lzA%0t~X}jnj@~0rLjw*!r0kK@T%ja{S zWP$5}D(O%KhdF0S6(j{yP_$YZ0=rsMK!`{0b_KpHh`YF4n@%me1eL zW>@*;JaD#+9Q1fmIibYs6~h7{3ROXr^gZCvq%R@xRTO-V;PTsuE;x8d8P~V=bf^KM z1jPy-85q)~GOW~e)C{sz(m;4Eo4&^va?9B}cME(zo4&J>$pdepeXY%rLgHt60LR;c zB-d03Tu?!>5I6-*lqBFR!L8a3IAE}unj+NEHDOCe!$RMpEI~!*p)q#%13^`=oxnLm zepgURGGgWy<%)*Y$0KXm{3^dNyOOz`U3){MAH&7Ed|iSMz`?#ttdz@=4#p-)p4a4x zuJJtRKJ>US0i;Nr&yYAD;o;=L-obkAaNLWBcwYSQmeXqM>a2pT`{FLxF1=X>6rW4k zU2CY0-{@?{4{V;;#YxThKYh5r0qouF)%P3IvG?SZx*$s>|E^(C1GXzD>)oZ8@ju)L zPP5HEC&@5(2iUq;JWd=9bObs(=s)9!Q@-(eDbuF^dh~WQIxRQ20W)oG5SEB(!0fZo zmSTgjqS)rM($?Z3VMlS7&raJ>>_G7)pM!Rw*hzE~u83vevd>97ecg1o&qce?xf|W- z@wsVtu@DCY(d$T9Xo3?>N3w$9TofL34c@k!ELB%KM+@{(N3>v`PrQcqgmK2ALP;#G>EQ;lFaesiqp>7%YB!BaBZ zAm>^M6G?cy2@Ah;?OD@vmFjODALEnPG4tJ|sfdpTlHhZ0x5y6SOkWZdk>K>tDS9x~ zh-`)e0cM@ynqE&Xz}{_AL+#6?1&ItNOgORWAw7QX#qa5BT$8$X_TQQS#SKpUq52ma z4Wt@VvEV2Z2;5%@k1|Um1t!8wG(D6jh+b4E3I2K7O5Z~|)Clr`ZBcYvry(wQo78pU zMvDk@1cQo3l8><6XN!e(((D<1nOVmMsOWzZ*U9;p8Raw=!oM+;ZDPrjYZQ2_S1ii9FT76J#s2&rY6wWYkV+woG^($hF`I%p;Uv5Gr<6Q!Z9Ym z49|`+xltxJ#w<-FnaFt4<1v^#PtT)}dy!dbQURkM)F#V`3-jDFwr*TOT(Lbrs*4*i zqZ##KAZeE6&+Yv$Y_Rx~X`Qsc%oL6OjRq|jo8rWEvb%#P{th1e)78nniQ!n^%|-OS zI?crqhy(&*qb5$;AUZfNzs+(FhZ~ zJ`oN1!#9EfhRKfv>wY3hE~3TtOg+z(TgY3&WNdF|Cyq07Mf9*M2AApf60(Er-;J}h zp#6``errB?iebK=1ize6Gmn#|U!NeVoA~U5Cl`0@Eg2X0*#^7Q=`q+vO`M4)&lczH GUjGFS(N~)Q literal 3001 zcmaJ>-Ba7x6~8tZ^A$(PisFDF!mQs}U)k7DOw?EZpQ>nD z+B6Llbz8QnNTuj{-B0D5s=Q<=h_Ovcx5|cTN9$M+l>snmeN)vH)R$AtZN=m()@~zH z*)(hea6~a=L~R0i+0ZxCO%$lus%AwSE1za;LW)h)?rft*O`IE1s;=5%8IW7ZH=CXn zlamW5yqHej68WVmadvV=7U>jTn4iCiNT;4-vOZC%b3lt_Q`r__Y_@_((NyGZ5r_oS zQuLx@_K6!>#o7WNFsTEptz|{Ii-_6$l9!=cKbcPhJ;_XVE-mJ6rqc`JY!0>8q4`WjQ}9Ul z&VuQrLjg!E6-C={V76N7&lE%%y0{^!TEzrKfenh>MV*Q+8$|`ROO~vvDD=7&aZA#R z8bBvxsVvE=y^DfQ5XLEPfW*+=eafk_zH2KW*P;TM@I?A-wWJso8+d|lixwJkn9gTs z)ALJtF_(r(rgA8duOF)m45Mqrq4uJ(UfBe$G{eGZ$A%ZvxdkyZIhVSbUVKA9H)dB_ z*A#JERd&$cgHG8fl~fyDy3)R(8Iq0mIZ_lY&9E&|M082IC)UMop%z%6z4r?(C74Rb zzP}Tj@Kj&Zu>ZcN6H8yjw-P8p?adzViBvotr?ui~0~Q-%0sFOD zJXfM`F2Dq%89SFC8Qf*21S4Q}RC|jtm10F!icDP(j59dT2U*a}#)c}Zif%LYmf2DQ2<_A_8PiZE2KKNRpj~Us!06)D z(b%X~6?)vXyGk?-yL#NgCc3FmC7KD(u)C)v*i1JEJ+!B=Pk4z|qHVaTr#wM@TqA!F#i__dbJ-$z8e?=z+ni1&!u7wzc(dSqfEz}s7iw?!y z^kA%sj>eklv6zRx9BZKyF)w|sCb#}N^YO&F#p=jfWN?HHN2b|uhRLSk!$tVwGB-X( zHpkIZGPhTYyPuJHlJe7s-^E?ekB|Nh_IdaQUct|WS6_emD0P!0sctgEx?d$+g-59= zaP;sgeD16ID(I{Ne|r2yJ?PKJPrgpAl1pq8)q4mHeGg~pP__8o6TC-fp{ zN4Pji^+E1ts`n`2!Uah0<0IlC*&5$>lT?5qyPFdQa7 zjG)7Fp`+u3yFi|aqr(6z90tPhjlg$6kPq&Cdz~0usooiiy?3eRAE1U3(E9BvdFL?h z0%TmgNxaejtmYr4Mh=gT@Xo8KdkLW6t|iyMqRFNkxh_!Jnq8TYJS!q6JGo!*+0WBldCUN zcb;a+2Fo7ctj>fWJRNea&bU?usGZ860Dv_8)O)aU5Js>g;vfSDgB=m}e;fFgpSZkI{Y8vpL+}hmIiP0p5|@@yOavDr1 zI#;6-4$7y8NnVgM)50@;+c^UC!9NuPFt|}SOlN@lQ354-UaV1r|A$dD^B=TKw!KUh zFw4|c2xPq=jn`3vyiEFDrj{M|k8yU)RwE^L!Z<#8o{cre(Mt6m*T->^juemME^31K zNy7DZUg7yr6CRh-0kpSp1S3w()TnD9Z9H~C{=EDTel)RC{e6@j;X8xJ?yT~gCo^L^soqA?#Q&V# Fe*sNkVDJC{ diff --git a/dist/pyrobusta/con/wifi.mpy b/dist/pyrobusta/con/wifi.mpy index 8f5f2bc53a58c4621983660f2f547f6f1307b285..5e823148b5e6ffee28d85005cee32f548dd81600 100644 GIT binary patch literal 615 zcmYL@TW-@(5QfKY++3QAdwMXSM#4&^I#i*7C<;;}9-2hZNTq5M5Lh53#y+u*aP7#) zB?<`1srtqqSVNk!fI?XWD_{YvfRkJMHuKLn^Ucduc%Von=9|U-(Dy!^vcM>so?9Hy z9^LH^A(LPZA$^Di^n^g{&B5LwB-^j{I?d;hc1bYs{EvWb5_I@hkJ@nWG@uUKU36*3 zvu*0ykeJ1QsY?TDIP^0CG<9?sqZXtGhVM?Xq+tg169I)Xe!(SXKrDz+x97p=p-g+r zvx^}pQ?p@NK4A=sQfhXxZ!pIHvOv09RZl24xVGAEHyg#<$^EKoI6g6~p?V`yn@7*S zd<6+rZ?nQO+_(v;=c+w}I{dTq_c9W?>OrgEoU5TM?g*PJSy2)7P>#sbj<~s+jVO_< zq)7Ui9OctkHmXE<9p`l-8&~3b36!;xpx|Ru{FZ(r(3;N~g@AYN)r=D4eBXjXt YOittQQCHLQ^B=CpGx`2_t~8I$|MfMeAOHXW delta 423 zcmXYqOHaZ;6oqG6%1gzu)M{cAM~$MSv06z@T!K5J2}T!PNf}x*fwY7c5sjffR_^#4 zt`ynh>rc3I=iXo7n0gmC=j49p-0#>hwzwY$^V2ciZxOB8ZF$wUA6OdkT&+VLy58)9 z$-YKclRmLrFkZ8XSF=Hqx{e2~*D{QAZ4v-P9VQ)m+8<%4_P=I&D)#HNiK$BiYBlJE z6;RIwLUsqccF^%!b$oe+9k1=y7J#7a;QuDOref>Nq7AFsu(1^|3fu+=!3x&Z#@%#? zIK=0tK76p_pz!y3?m+ diff --git a/dist/pyrobusta/protocol/http.mpy b/dist/pyrobusta/protocol/http.mpy index d2a04ba38134a1960dbaa6e6720f95426b57657c..e645c38ee95bfe1d5ead7fd69cc0f64f30a9e07b 100644 GIT binary patch delta 4532 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 T0Ra0AfZYVxAHeAldo1-o&2s|G delta 4646 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 literal 0 HcmV?d00001 diff --git a/dist/pyrobusta/protocol/http_multipart.mpy b/dist/pyrobusta/protocol/http_multipart.mpy index 03fdbedc412b020480978add600f8e9b3e3b43ae..e6dbbcee80d1302ccd0af362163c06eb7a44037a 100644 GIT binary patch literal 2035 zcmZuwOLN;)6uwd%$90m%wS-aHG!?Rw*okEu*L6$NK$F8~b77R0N_!qMC0#+URg6@Dtr!(!a>WZD&vfxO5BurV@_q^^o-}jxXt4?(4 zog?$QCPhmdTJd^WH^gX3D;h`C6+_`da0=D7)9x#Dy)dIQr0l?8dzJG zRVYP)tBY3_X67ez3&Pag;%sU%mq$TC$42|Au!h9~)*yOQlc9O5r5#5-O<1l>dNPF) zheqGj#1a65p^{P5HXsKAXvU!mV6H=fZXh}X)YIy!tYS1UJvY0Mo?Q^Gq-QTJWYE#Z zd>st}14)swY6w`~~ZG0n6(c4T51<(-jOrR09PBn?ZY)#l#IHLDon!B^B383I^4zEn9iE zeW$Qt2T>qn>pKr($m(j^X2w`*rgKdM$6>REAc=}{U6gJhXIWE_#ZlIU6{wkAv< zC&}~h*Xu@3N3YwmdkYR=?iBi=UwJE2j@(jNuE7d>1O#5HyVfpL*BQx z69m=eM=$)pEpsKf85)<-(bZ5EL~*CgUA_OFY{=)^>_!Ywbe z!6ARpKNJk|L5>^s4@Cmu8XX%Vl0<$8CD8rt>C52z9pL9y^V>1mHZBqdcE%NteXtev>jmgSLx&1oAo4NQq1$zL5~y79s_nVZ!S?M!Fu^2 zbJC;+P3BZq`XYGRH<%bdQ^~PxbdIyfK@(^N1G7!Hzv04slVa^_-@Cr?yw6%pK3ZnO zBcZWujOVymkoV68LUEuL4Gd55Q8pI3%5#Bm!fNESpeKp@7S<&3ph~}r3R2z+Ce@z+ z)!}&d5Hx9svuIK$pB`e?P$$!8W$8C|l0=Mr^!UC0CGfdFQ}ghYnlk!r2|l*#0wy(N zQoTv?m)+^#D?gZf`=exn%r~bpjj8`Hpash=utjWomXPx%Gu#prD7E3Y>wru{@Iy?V zr!erTTJNZSK=JVA8vJr56KuhZk-t7^d^|tWAzPGDc%0TpGgkWvzL7gdus>Y+dO35a z>(UrMHb&Sr{0LEH5pmD7Jcjo=tkdfR)=jg|6G?Qm9M3^~@W5oj%u2-O2Tv;ZE_zQ- kBr4yI1pBm7t@YifdBvi literal 1832 zcmZuwOLN**7`>7)Cd7`3q!QV26T-uQ&8vjayxiJ68oNzwAa-5aW(2uhV@-s_NTP|m z2`0{@>83MXbSD2o;74DZbn(gb ze@>BMw7w=wHyestj@D&Km7pXvn)f$4z*UPdRsjq=yn_dIn9yqcO1`b}tk41<8 zQ?69F8d6uK3WyHkqRK;HheBCVK;mE?cUvyk>qzES%21W$HQd8kSv`cnYZVMoRKU#Q zy;-r!iwKOR7v>kU^NZZo?EIC*S#WF>l`BYAz$r=Opui(h<&Z2(G6?(|$z=e>XkI|q$}8LoFXQ1hh5sBMs9Vxy z0t$<+@uE&?5Q$K#AYh-lo}Qb-XO|hLuGJA}ooofyk4pH7NuJ#|(P%q*XKK+F z`3|tCcfj%W<)hA2V`T-&T{HRz=fuy-0$*_%Z>(fE46KYfC#=^lx_ruzeaH^1r2b_Y zvSY}-Pi`2)n+|ed3CmXjo zN8|m@KptA}Pya}a^+%$yroH4E4~@sha~+3eH*=MGOKa2CcA~GCQaX---At}qq2lcX zp;vEo9JYh%S+jbDN_aqmKzZ28TzW(tIhTC9Ino^PzxQASkQNW&BlrBIhiw1tw`iR$ z7wCJk35hSJ|IEZErlzJYHoc{iE8z14Jb{4QJ>l_1yrE5I(nlnTq7Nj%)2;LeIQQsz zp<2|#J{;;068|=zrK+1We&J4nzn<@;v(!5YfN*zfdwXXuLM~y<_Ri~PxoUBny1bPj zUZx%UGg9B&&BKS_(zyNng^SH;S19ZbyF;N!*d3gtNct!}sL?~erx!HlSjWhRlVmXu z+l)h}u>9oiZYGt79zvs!?|J(6G?p@B0>^ZlVK!TPN!+DgWe#S%Va{+G&7!tUpTN=U zkJ%F%&1&pO9^MO_b+YF#Tx#ZBuIPf>Sciw;QFvkAOzCcl_iOa1@$8Z9E60bUx{^;y zu8==CnU96t?pPq~S?~tqSX$IOo(e}@vEa3^+Z#&ggDg7rB=JP&nk1gKng6oFE$XsH z(+PY4{ImNEd(t&$Nuy7^Jfy7a&g`(>WlnA-i5U6(=l4gJaX$7(CRgm)_@-gO4Fh*d zqfcwJD@p#co&K%)owoDs1eqX<=2oh^_5TgjX&C^+L^IQk%xUbIeKWD79{nB;i|HDE zNXe@V!g|{J-_hQI#KV{C>MLlhXP;(_{Pl&#c-`x>-!neLl<_2i#c7Pc^Fxw^_nFv8 zp!sDfo4r4f2~SRvI_9xP2mNHn1&E*88XKhg^&)k|F@XQY%*2izFhlkr9K~TC60MJ3 pG#^h>;i>b@uP41;uMf04*Ed_|T4b@%X3yl>tS8@QDg2@H@E@he8u$PJ diff --git a/dist/pyrobusta/server/http_server.mpy b/dist/pyrobusta/server/http_server.mpy index 467a75e11e9a01b845443be387cc014c2eb736a5..d798948148dee9714a0dce566975882cda601c07 100644 GIT binary patch literal 2987 zcmZWp&2!tv6<<;!CCdyA0WeHSYz*^5CS{6}X_#?jCz1GJNj4=CB5jAUWe5U62{i?< z0BBjsWI{R0j8ixNKs(cEPQ8MZ{L$WQC2sGXshdO7>7moRn@Ojq_AN*`c1m!7w{PG6 z-rM*4?Jl^{@pa;&p-U6>EnQn}7^XO3$og$rpIA4|y3n!X^(}P7D`~ZwESZSdkT-;? zF3YHQO+tiGljXXwVIUtY*3Iq`2GPUCnt~%zjcQfa(Xe6a zvbYgn?F{KP*%TyAttxBC*D#fu5$}%ts-|yY8pM@4=4A2}#z?cm=8;CT_`FwzVydcH7daztd2}2#Tth z0HD<%*FttdNY4~dXepb%Dx|Jl6c%RQ7gFU?wunN->|*A}zI1+3$Y0Ga&1Gj`&W`hL zin@vmeg$NH@&7?y`nIF}idxl>cT*SZuxCWt*$er64v}_J+w=()U8@UHO_5a-`DfCl z`KwtWotw`tmWpVgBCj^q(Dqx~NA5a;-pRQAAh!wI+7|jvy&eR8%yKC(~KzWnrc? zhkST=Ia}_GysM&g3#5z4Dc05<)JmGNzM-h1DWi$;v!oyxHO({x0S#0E+eF)k-4f8n zyh_0KJ7qVlL7pz}cd6}bvIkBUR<~zUR{#^uyI~1(d0wN=<-tc(z;}cs){*OM{0F5^ zu{CXT9H!v#vKv}OW;Ydcoz>+^Ly{|OhXU4sGl^X9&O?8zRYk(OV>@f6*pEJDFQ-`1 z1S8A>ehk~V|JZ}4boplu1ptvN{90m|?Ft6Si5O8G63c3(t|_W%bf&!BE70aiQy~@rVHc8! z1;vBDpPPh}=oxd4^!lBQ)8E7Nl$(T$ za1(uFJtO^o7vu7~8MnWW=>z{EVgMo!`wuaP`~%ED`7YrZbBw&<_b?u?yAd@4H(WB&*pz}r2QGSrgTGYu@8e*Q=b_Cm@KRkS7$NzEf@$<|L@@BAyj<%uN z?*SIJ``u$~Cwn-DObUCCzu)DY`+zzIsJ(P_H|fC8B=K;UI7JR}cb%3mMiP&H7>)DN z&bi-D`{LJEnjf%{z?on{L^t{MG6d0QT7VvL zkWLPjdFj)0gES{L*TXD(b~Z$uwS2=QMI<=~{ZsMkUii=S=moKD zAG*&!;h(0&R(cNHbUKNiw6Qsk&8M#rwZe|YMx&`v zEP5tdx*i;j!r!7V*o{svcQfgk1j8poqp|U5Y9bpAp8RPb7z~@& z=6=~x3+bWf0kB)7hmMjS>JoLitJh_c3ci64E6Bs5PXOnTfq(M4npT_qNbYHyUT9N` zNyn2GohOf4RDpBg56?N0&NAt?sEkDoThs`#M6PWE5a%RvEpDd=xCi|~emE*b5w{li z;wJQB|79<{FHaE9??-yVL6LV~hX* delta 1391 zcmY*XOKclO7@l2w9XE;G$?Rro$0jx2IDW)=*&FvoTgTosFDK0=DZxOxHtVIe>g-1L zmb9Xk*Of~f!KLyY2`(HE(%Ly70TOC+B~>$kZ#J{J`1+i@aXqs_!a#>Xj zz`LdUbo$(xHSbJuTd${TYT5LF7Ae7m3( z%c`uYdtH3<3)<8CfsT1`K~dCQL%wYoy8!PMN(Sjpuc}3GznQg7_i-hSxOR2DTx~wE z-K;rF+PgYH7yVVEP%#M1wO6QUCG8eKhFaMvX$3^U#|B#lyVjH zcUuLISO;kw)VKO8YM~=m|6Zj;qCt-7|1TxLxVjNXPDR%M)3h`3<{C5Raj&blN>vi1 z7X7ML+|^5(QN8GY|6%hRW^DdR+3lwC{?YU6u~@9U|G<>IG^R~xgLcS7ZC;1ZhHXAO zw)+^&RL_7x(z6MzyKn@N5Pz zz`}rwrv$Qa@M9bcd`m!qPv62(cm`jFZhU26F%D|v)k`Y!aT<@uZ8#pceRG0V-7BWAe=CCiN^W&hsasqlE#ypT_Yg8Xib=a*d=`U-@X0cPk(Y>3mMM$vIFEjhKHRXfUcPW27dwtE^$or$Q(Fo}= zVsYa~>SVT$OE8_ZZLUT{BS)eu9TB#_)fVW(V~~RzJ~+|0Bdc&gf(|(51pN{CH60zw zno6Dzg%Z)ggQIL?B{H&;S1c zZ<3L|@S@FKY_pe=)bkein#EqW*egjohwK&`w^-IxGSI=d&+;+G5VDa?YG4Ug9(*x&-R1%&3?$O1DO)Mh(9zkJ%xNO6 zq7V45_^`1#eMIrsyH7LmZ!p=L@x_+K;ET!4$+_p=+RcfYD+OzCp zYR}emD^;{C)wbp;H84C_-r3YlFr;gi?UR7FWdquA?h8rF8bTe>b+t5pz4`N;Tyv#WCc zc4kGsA*`M?E14w_{yWL$mojG~F~0(!7o%qbZd!Jax#fUq7&buuW4UZ>ssQ;;d(^$t zHGWH=BWbKnNYP0hq(C{l+5Jef;; zK#>L<^w`+R5qmCn02YXkt+XKIo9y}40wD=Ag?sn?QVt*eJ|-4|+uj+lePZ@Qm_oY!zjL_X zO#gaXP+m^0M?%i^jbxI`&fc7jB@hz_JaLQ|h)74@C literal 0 HcmV?d00001 diff --git a/dist/pyrobusta/utils/config.mpy b/dist/pyrobusta/utils/config.mpy index 5e9b55a70d436304648b2fa770b0be735657ead4..671b920e949a5418cd555dc1380938016848620d 100644 GIT binary patch delta 499 zcmW-Z&2Q3R9LJxh3$?(6XAcFpxooxNEi?g!wb4WqEsfhUjaw#8Pc5rO8|z9aFEcTr zG{$H=7{XuSt!&^Obsl%|;tu`?p612l%6^wme&4s>wtRPXz74#nl`|u^+GZAwr&E-*sodr`YOo*ca^x)|8lYuD|9*? zreGR-h5DO@wb!znRS-Zgo6Wjn>y5Uq)C^O%K_FsV+AF=OysjQ7nzaw!-~NDdP*oo! zxujN>S^wnE!FR!rO9Kz`U37_~Ttp!f1yP8{38n-R;klI@+_?@D(Grokx~`f=2|ARR|n{CFJAi|}|PAB^P|4v)qHaXiAzANeU}=!KC8cYnJ7 zol|*ZjG3>!vnSFP_Kii~csAwOr{s1Ae~J0UHT5pTr#N<-%cZiOzwxUqe& zd`@avY7PTmd16ssW?uTl5E~o7=@(QMJG&?WK|f~;XQ%Y!GmP5xO#Dm!HDfSg zG-FKOn9SH{;m9GQ-k_q*up!=?>!1+RMiWMT5hs)69*&>PI(*6vnh6TZdTJVa21-_H z>I!Q9o*LmEGZ}?c>Ng1R3kaArF}im!9^4r3-N<-olZc>zkQs>P6%aOKYzP$*F=IHq zSwvJo%#6|5eG`c5?BpZ@m2U*gi--t_n=t}O2{Xncn_VilxReTUZQf$SbY!!T!d;gL zAq5f1&1Q_@N1J{$+;s8WBBZdj@l%*nfDTYX3Mdf?qPGBPr$2RpF|l4;;X=`BHeRiQibj#KDUE2<1fr7;3^bP-NU*3>6P)mf#<9QQ zh!$p!eEb9hKSI7h5EDmdXYIXmzR`<^JK5{tc2bh?A2 z@eF02f_jt{>by({SyL7jau8WvCXuC;Rx*h>x(078aRl=TlszKvE`cviRd-!H+SQI6 zZQl`n3DDJ)Bl_J0($x^=#ekDQ)enJ*!)IGN>xg3mThlu29_?VhH#qch?-hl|r^M;{ zd`?!@l`SRhVsBSF7NnLlHQ~fPx>|8D?rP~F5oVk?8qjkWPyW;3A596f1jUh=p+E5T BYF7XN delta 158 zcmZ3*{E?B(myJQ5k8vWK?nGO81CD~kk_-keCRRp0237`!Mi&ka#sr0&1_355Mrmdl zCNZW+BL*>+z+}dDiAXIL-iZ&zBUqEQ84hj`VH03CV~qD!KD0@h;jW95lj$Y_j+-vt z8(rKsig21SL^`=|6ye%r#u(`&Ex<0qz0r*Eun?08!;#G{AzMUvK$=}#wru{CyhVWh I6NpL%0LJqvC;$Ke diff --git a/package.json b/package.json index 23075a0..ad11182 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "v0.3.0", + "version": "v0.4.0", "urls": [ [ "pyrobusta/transport/socket.mpy", @@ -9,6 +9,14 @@ "pyrobusta/transport/__init__.py", "github:szeka9/PyRobusta/dist/pyrobusta/transport/__init__.py" ], + [ + "pyrobusta/assets/www/index.html", + "github:szeka9/PyRobusta/dist/pyrobusta/assets/www/index.html" + ], + [ + "pyrobusta/assets/www/examples.html", + "github:szeka9/PyRobusta/dist/pyrobusta/assets/www/examples.html" + ], [ "pyrobusta/utils/helpers.mpy", "github:szeka9/PyRobusta/dist/pyrobusta/utils/helpers.mpy" @@ -25,6 +33,10 @@ "pyrobusta/utils/logging.mpy", "github:szeka9/PyRobusta/dist/pyrobusta/utils/logging.mpy" ], + [ + "pyrobusta/utils/assets.mpy", + "github:szeka9/PyRobusta/dist/pyrobusta/utils/assets.mpy" + ], [ "pyrobusta/protocol/http_multipart.mpy", "github:szeka9/PyRobusta/dist/pyrobusta/protocol/http_multipart.mpy" @@ -37,6 +49,10 @@ "pyrobusta/protocol/__init__.py", "github:szeka9/PyRobusta/dist/pyrobusta/protocol/__init__.py" ], + [ + "pyrobusta/protocol/http_file_server.mpy", + "github:szeka9/PyRobusta/dist/pyrobusta/protocol/http_file_server.mpy" + ], [ "pyrobusta/stream/__init__.py", "github:szeka9/PyRobusta/dist/pyrobusta/stream/__init__.py" diff --git a/src/pyrobusta/utils/config.py b/src/pyrobusta/utils/config.py index 9a7bb8f..86dbf44 100644 --- a/src/pyrobusta/utils/config.py +++ b/src/pyrobusta/utils/config.py @@ -6,7 +6,7 @@ from .helpers import normalize_path -PYROBUSTA_VERSION = "v0.3.0" +PYROBUSTA_VERSION = "v0.4.0" CONFIG_LOADED = False CONFIG_LOCATION = "pyrobusta.env" CONFIG_CACHE = [