diff --git a/Makefile b/Makefile index 31bcfe6..5ff15b4 100644 --- a/Makefile +++ b/Makefile @@ -160,8 +160,8 @@ publish: # Bump version in Python source @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 + $(SRC_DIR)/pyrobusta/__init__.py \ + && rm -f $(SRC_DIR)/pyrobusta/__init__.py.bak $(MAKE) clean $(MAKE) build docs BUILD_DIR=$(DIST_DIR) @@ -198,6 +198,8 @@ stage-app: @if [ -f pyrobusta.passwd ]; then cp pyrobusta.passwd $(RUNTIME_DIR)/; fi @echo "http_port=8080" >> $(RUNTIME_DIR)/pyrobusta.env @echo "https_port=4443" >> $(RUNTIME_DIR)/pyrobusta.env + @sed -i '/^wifi_ssid/d' $(RUNTIME_DIR)/pyrobusta.env + @sed -i '/^wifi_password/d' $(RUNTIME_DIR)/pyrobusta.env # ----------------------------- # Run app locally with UNIX MicroPython @@ -349,8 +351,10 @@ perf-test-device: perf-test-http-dimensioning perf-test-http-soak perf-test-regenerate-plots: @for type in dimensioning soak; do \ [ -d docs/$$type/esp32_c3 ] && \ + echo "\n======================\nESP32-C3: $$type\n======================\n"; \ python3 tests/system/summary.py ESP32-C3 docs/$$type; \ [ -d docs/$$type/esp32_s3 ] && \ + echo "\n======================\nESP32-S3: $$type\n======================\n"; \ python3 tests/system/summary.py ESP32-S3 docs/$$type; \ done @@ -359,26 +363,116 @@ perf-test-regenerate-plots: # ================================================ # ----------------------------- -# Generate certificate +# TLS test certificate defaults +# TLS_FORMAT: DER, PEM +# TLS_KEY_TYPE: EC, RSA +# TLS_KEY_BITS (RSA only): 2048, 3072, 4096 +# TLS_KEY_CURVE (EC only): prime256v1, secp384r1 +# ----------------------------- +TLS_FORMAT ?= DER +TLS_KEY_TYPE ?= EC +TLS_KEY_CURVE ?= prime256v1 +TLS_KEY_BITS ?= 2048 +TLS_CN ?= localhost +TLS_SAN ?= DNS:localhost + +TLS_CA_CN ?= Test CA + +TLS_EXT := $(shell printf '%s' "$(TLS_FORMAT)" | tr '[:upper:]' '[:lower:]') + +TLS_CA_CERT = $(TLS_DIR)/ca-cert.$(TLS_EXT) +TLS_CA_KEY = $(TLS_DIR)/ca-key.$(TLS_EXT) +TLS_CERT = $(TLS_DIR)/cert.$(TLS_EXT) +TLS_KEY = $(TLS_DIR)/key.$(TLS_EXT) + +TLS_CSR = $(TLS_DIR)/csr.pem +TLS_EXTFILE = $(TLS_DIR)/openssl-ext.conf +TLS_SERIAL = $(TLS_DIR)/ca-cert.srl + +# ----------------------------- +# Generate CA +# ----------------------------- +.PHONY: tls-ca +tls-ca: + @mkdir -p "$(TLS_DIR)" + + @if [ ! -f "$(TLS_CA_KEY)" ]; then \ + echo "Generating CA private key ($(TLS_FORMAT))..."; \ + openssl genpkey \ + -algorithm EC \ + -out "$(TLS_CA_KEY)" \ + -outform "$(TLS_FORMAT)" \ + -pkeyopt ec_paramgen_curve:prime256v1; \ + fi + + @if [ ! -f "$(TLS_CA_CERT)" ]; then \ + echo "Generating CA certificate ($(TLS_FORMAT))..."; \ + openssl req -new -x509 \ + -key "$(TLS_CA_KEY)" \ + -keyform "$(TLS_FORMAT)" \ + -out "$(TLS_CA_CERT)" \ + -outform "$(TLS_FORMAT)" \ + -days 3650 \ + -subj "/CN=$(TLS_CA_CN)" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign"; \ + fi + +# ----------------------------- +# Generate server certificate # ----------------------------- .PHONY: tls-cert -tls-cert: - @rm -f $(TLS_DIR)/cert.der $(TLS_DIR)/key.der; \ - mkdir -p $(TLS_DIR); - - @openssl genpkey \ - -algorithm RSA \ - -out $(TLS_DIR)/key.der \ - -outform DER \ - -pkeyopt rsa_keygen_bits:2048 2>/dev/null - - @openssl req -new -x509 \ - -key $(TLS_DIR)/key.der \ - -keyform DER \ - -out $(TLS_DIR)/cert.der \ - -outform DER \ - -days 365 \ - -subj "/CN=localhost" +tls-cert: tls-ca + @mkdir -p "$(TLS_DIR)" + + @if [ "$(TLS_KEY_TYPE)" = "RSA" ]; then \ + echo "Generating RSA server key ($(TLS_KEY_BITS) bits)..."; \ + openssl genpkey \ + -algorithm RSA \ + -out "$(TLS_KEY)" \ + -outform "$(TLS_FORMAT)" \ + -pkeyopt "rsa_keygen_bits:$(TLS_KEY_BITS)"; \ + elif [ "$(TLS_KEY_TYPE)" = "EC" ]; then \ + echo "Generating EC server key ($(TLS_KEY_CURVE))..."; \ + openssl genpkey \ + -algorithm EC \ + -out "$(TLS_KEY)" \ + -outform "$(TLS_FORMAT)" \ + -pkeyopt "ec_paramgen_curve:$(TLS_KEY_CURVE)"; \ + else \ + echo "Unsupported TLS_KEY_TYPE: $(TLS_KEY_TYPE)" >&2; \ + exit 1; \ + fi + + @openssl req -new \ + -key "$(TLS_KEY)" \ + -keyform "$(TLS_FORMAT)" \ + -out "$(TLS_CSR)" \ + -subj "/CN=$(TLS_CN)" + + @printf '%s\n' \ + "basicConstraints=critical,CA:FALSE" \ + "keyUsage=critical,digitalSignature,keyEncipherment" \ + "subjectAltName=$(TLS_SAN)" \ + > "$(TLS_EXTFILE)" + + @openssl x509 -req \ + -in "$(TLS_CSR)" \ + -CA "$(TLS_CA_CERT)" \ + -CAkey "$(TLS_CA_KEY)" \ + -CAform "$(TLS_FORMAT)" \ + -CAkeyform "$(TLS_FORMAT)" \ + -CAcreateserial \ + -out "$(TLS_CERT)" \ + -outform "$(TLS_FORMAT)" \ + -days 365 \ + -extfile "$(TLS_EXTFILE)" + + @rm -f \ + "$(TLS_CSR)" \ + "$(TLS_EXTFILE)" \ + "$(TLS_SERIAL)" + # ----------------------------- # Deploy certificate @@ -386,10 +480,11 @@ 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) cp "$(TLS_DIR)/key.$(TLS_EXT)" ":key.$(TLS_EXT)" + @mpremote $(DEVICE) cp "$(TLS_DIR)/cert.$(TLS_EXT)" ":cert.$(TLS_EXT)" @mpremote $(DEVICE) reset + # ================================================ # Cleanup # ================================================ diff --git a/README.md b/README.md index 6788d61..5c6432e 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,10 @@ install_www() # Start the HTTP server import asyncio -from pyrobusta.server.http_server import HttpServer +from pyrobusta import application async def main(): - server = HttpServer() - await server.start_socket_server() + await application.run() while True: await asyncio.sleep(1) @@ -79,9 +78,10 @@ payloads, and advanced HTTP features. ```python import asyncio +import machine from gc import mem_free, mem_alloc, collect -import pyrobusta.server.http_server as http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine @HttpEngine.route("/mem-usage", "GET") @@ -98,12 +98,12 @@ def mem_usage(http_ctx, _): ) async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() while True: await asyncio.sleep(1) -asyncio.run(main()) +if machine.reset_cause() != machine.SOFT_RESET: + asyncio.run(main()) ``` Check the [Application Development](./docs/application_development/index.md) guide for diff --git a/docs/application_development/configuration.md b/docs/application_development/configuration.md index 9717deb..e6737be 100644 --- a/docs/application_development/configuration.md +++ b/docs/application_development/configuration.md @@ -14,7 +14,7 @@ configuration API. * [Configuration](#configuration) + [Configuration Format & Deployment](#configuration-format-deployment) + [Parameter Description](#parameter-description) - + [Configuration API](#configuration-api) + + [Configuration Loading](#configuration-loading) --- @@ -35,8 +35,8 @@ tls=False # turn off TLS Perform a soft reset and upload `pyrobusta.env` using mpremote. ``` -$ mpremote a0 soft-reset -$ mpremote a0 cp pyrobusta.env :/pyrobusta.env +$ mpremote connect /dev/ttyACM1 soft-reset +$ mpremote connect /dev/ttyACM1 cp pyrobusta.env :/pyrobusta.env ``` ## Parameter Description @@ -44,54 +44,55 @@ $ mpremote a0 cp pyrobusta.env :/pyrobusta.env | Name | Description | Default | | --- | --- | --- | | `wifi_ssid` | Name of the Wi-Fi network. When empty, Wi-Fi is not initialized by the built-in `wifi.py` module. | None | -| `wifi_password` | Password of the Wi-Fi network. When empty, Wi-Fi is not initialized by the built-in `wifi.py` module. | None | +| `wifi_password` | Password of the Wi-Fi network. | None | +| `tls` | Enables or disables TLS. When enabled, `cert.der` and `key.der` must be installed at the server root. | False | +| `tls_cert_file` | Alternative path to the TLS certificate. | `/cert.der` | +| `tls_key_file` | Alternative path to the TLS private key. | `/key.der` | +| `passwd_file` | Path to the file containing user credentials used for authentication. | `/pyrobusta.passwd` | +| `roles_file` | Path to the file containing RBAC role definitions used for authorization. | `/pyrobusta.roles` | +| `log_level` | Logging level. Can be one of: `error`, `warning`, `info`, `debug`. | `info` | +| `socket_max_con` | Maximum number of simultaneous socket connections. | 2 | +| `http_served_paths` | Space-separated list of filesystem paths that may be served over HTTP. | `/www` | +| `http_mem_cap` | Fraction of available heap memory reserved for stream buffers. Valid range: (0, 1]. | 0.1 | | `http_port` | Port number for HTTP. | 80 | | `https_port` | Port number for HTTPS. | 443 | -| `http_multipart` | Enables or disables multipart request and response processing. Enabling multipart support increases memory usage. | False | -| `http_mem_cap` | Fraction of available heap memory reserved for stream buffers. Valid range: (0, 1]. | 0.1 | -| `http_served_paths` | Space-separated list of filesystem paths that may be served over HTTP. | `/www` | +| `http_multipart` | Enables or disables multipart request and response processing (`Content-Type: multipart/*`). | False | | `http_files_api` | Enables or disables the file management API endpoint (`/files`), allowing upload, download, and listing of files. | False | | `http_auth` | Selects the type of authentication method enforced by the server. Currently, basic authentication (`basic`) is supported. | None | -| `http_browser_security` | Enables or disables browser security features, including CSRF protection, and browser security headers (Content Security Policy, referrer policy). Disabling browser security is only recommended when using non-browser clients or during local development and testing. | True | +| `http_browser_security` | Enables or disables browser security features including browser security headers (Content Security Policy, referrer policy) and CSRF protection (if authentication is enabled). Disabling browser security is only recommended when using non-browser clients or during local development and testing. | True | | `http_insecure_auth` | Allows clients to authenticate over unsecured HTTP (without TLS). This may expose credentials or authentication tokens in transit. | False | -| `http_sessions` | Allow the creation of session cookies after successful authentication requests. | False | +| `http_sessions` | Enables browser session cookies for authenticated clients. When enabled, successful authentication establishes a session that can be reused without resending authentication credentials. | True | | `http_session_ttl_sec` | Duration of validity of session cookies in seconds. | 900 | -| `socket_max_con` | Maximum number of simultaneous socket connections. | 2 | -| `tls` | Enables or disables TLS. When enabled, `cert.der` and `key.der` must be installed at the server root. | False | -| `tls_cert_file` | Path to the TLS certificate. | `/cert.der` | -| `tls_key_file` | Path to the TLS private key. | `/key.der` | -| `passwd_file` | Path to the file containing user credentials used for authentication. | `/pyrobusta.passwd` | -| `roles_file` | Path to the file containing RBAC role definitions used for authorization. | `/pyrobusta.roles` | -| `log_level` | Logging level. Can be one of: `error`, `warning`, `info`, `debug`. | `info` | -## Configuration API +## Configuration Loading -Configuration values can be accessed through the -`pyrobusta.utils.config` module. -Values are loaded from `pyrobusta.env` during server initialization. -Configuration values can be retrieved using -`get_config()` together with one of the -predefined `CONF_*` constants. +Configuration is represented by the `Config` class in +`pyrobusta.utils.config`. During application initialization, +`pyrobusta.application` creates and loads a `Config` instance from +`pyrobusta.env`, converting values to their expected runtime types. -After initialization, configuration values are retrieved from an internal cache. -The cached values are normalized to their expected runtime types to avoid repeated -parsing of environment strings. +Configuration values are exposed as attributes on the `Config` instance: -Configuration values are treated as immutable during runtime. -Changes are applied only when the configuration cache is reloaded. -The configuration cache can be reloaded by calling -`read_config()`, which re-reads `pyrobusta.env` -and rebuilds the internal normalized cache. ``` -from pyrobusta.utils.config import get_config, CONF_TLS +config = Config("/pyrobusta.env") -@HttpEngine.route("/tls", "GET") -def tls_status(http_ctx, _): - enabled = get_config(CONF_TLS) - return "text/plain", f"TLS enabled: {enabled}" +if config.tls: + ... ``` +The application uses the configuration to initialize and specialize the +relevant subsystems during startup. The `Config` instance is then discarded; +runtime components do not access configuration directly. + +Configuration is **immutable for the lifetime of an application**. Changes +to `pyrobusta.env` therefore require a new application instance and, under +normal operation, an application restart. + +The `pyrobusta.utils.config` module does not maintain a global configuration +object or runtime configuration cache. It provides the `Config` definition +and configuration-loading functionality. + --- PyRobusta v0.8.0 Web Server diff --git a/docs/application_development/introduction.md b/docs/application_development/introduction.md index e7e3fcd..6784293 100644 --- a/docs/application_development/introduction.md +++ b/docs/application_development/introduction.md @@ -20,10 +20,11 @@ The following application demonstrates the basic structure of a PyRobusta applic response generation, and server initialization. The application implements a simple HTTP API that returns the application version. The example includes boot.py, which starts the application, and app.py, which registers routes and starts the HTTP server. -``` +```python +# /app.py import asyncio -from pyrobusta.server import http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine APP_VERSION = "v0.1.0" @@ -34,32 +35,24 @@ def version(http_ctx, _): return "application/json", { "version": APP_VERSION } - return "text/plain", f"{APP_VERSION}\n" async def main(): - server = http_server.HttpServer() - await server.start_socket_server() - + await application.run() while True: await asyncio.sleep(1) ``` -``` +```python # /boot.py # This file is executed on every boot +import asyncio import machine -from os import listdir - -from pyrobusta.connectivity import wifi +import app -connected = wifi.initialize() -if connected and not machine.reset_cause() == machine.SOFT_RESET: - if "app.py" in listdir(): - import app - - asyncio.run(app.main()) +if machine.reset_cause() != machine.SOFT_RESET: + asyncio.run(app.main()) ``` In the example, `boot.py` conditionally starts the server when no REPL session is active. @@ -67,33 +60,32 @@ This allows `mpremote` to connect after a soft reset and upload files during dev ## Deployment with mpremote -Perform a soft reset and upload app.py and boot.py using `mpremote`. +Perform a soft reset and upload app.py and boot.py using `mpremote`: -``` -$ mpremote a0 soft-reset -$ mpremote a0 cp app.py :/app.py -$ mpremote a0 cp boot.py :/boot.py +```bash +$ mpremote connect /dev/ttyACM1 soft-reset +$ mpremote connect /dev/ttyACM1 cp app.py :/app.py +$ mpremote connect /dev/ttyACM1 cp boot.py :/boot.py ``` -Perform a hard reset to start the application and connect to the REPL. +Perform a hard reset to start the application and connect to the REPL: -``` -$ mpremote a0 reset repl -Connected to MicroPython at /dev/ttyACM0 -... -[INFO] pyrobusta.con.wifi: network b'Home-Wi-Fi' found! -[INFO] pyrobusta.con.wifi: connected, available at 192.168.1.101 -[WARN] pyrobusta.server.http_server.init_pools: low-memory mode with reduced buffer size -[INFO] pyrobusta.server.http_server.init_pools: 2 connection(s) allowed -[INFO] pyrobusta.server.http_server: started - -# You can now reach the device at 192.168.1.101 (replace with your IP) +```bash +$ mpremote connect /dev/ttyACM1 reset sleep 1 repl +Connected to MicroPython at /dev/ttyACM1 +Use Ctrl-] or Ctrl-x to exit this shell +[...] +2711 INFO pyrobusta.application: connected, ip=[192.168.1.101] +2732 INFO pyrobusta.server.http_server: 4 connection(s) allowed +2762 INFO pyrobusta.server.http_server: started + +# You can now reach the device at the indicated IP address # Press Ctrl-x to exit ``` -Use curl to test the application. +Use curl to test the application: -``` +```bash $ curl "http://192.168.1.101/version" v0.1.0 diff --git a/docs/application_development/request.md b/docs/application_development/request.md index ff8119d..f942bba 100644 --- a/docs/application_development/request.md +++ b/docs/application_development/request.md @@ -116,7 +116,7 @@ for each chunk received. As a result, the application must process the request b incrementally rather than assuming the full payload is available at once. ``` -import pyrobusta.server.http_server as http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine from pyrobusta.utils.lexpath import normalize_path @@ -140,8 +140,7 @@ def upload_chunks(http_ctx, payload: bytes): return "text/plain", "OK" async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() while True: await asyncio.sleep(1) ``` @@ -172,7 +171,7 @@ upload using request-scoped temporary file buffering. import asyncio from os import listdir, remove, rename, mkdir -import pyrobusta.server.http_server as http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine from pyrobusta.utils.lexpath import normalize_path @@ -198,8 +197,7 @@ def handle_parts(http_ctx, payload: tuple): async def main(): mkdir(normalize_path("/tmp")) - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() while True: await asyncio.sleep(1) ``` diff --git a/docs/application_development/response.md b/docs/application_development/response.md index e585e3b..f8dbf1c 100644 --- a/docs/application_development/response.md +++ b/docs/application_development/response.md @@ -121,7 +121,7 @@ The following requirements must be fulfilled by the generator: # /app.py import asyncio -from pyrobusta.server import http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine @HttpEngine.route("/stream", "GET") @@ -146,8 +146,7 @@ def stream_handler(http_ctx, _): http_ctx.resp_handler = generate_chunks async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() while True: await asyncio.sleep(1) ``` @@ -183,7 +182,7 @@ Routes producing streamed multipart responses must satisfy the following require # /app.py import asyncio -from pyrobusta.server import http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine def multipart_response(num_responses, part_size): @@ -204,8 +203,7 @@ def multipart_handler(http_ctx, _): return "multipart/form-data", multipart_response(part_count, part_size) async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() while True: await asyncio.sleep(1) ``` diff --git a/docs/application_development/security.md b/docs/application_development/security.md index 267b0ae..3c7737e 100644 --- a/docs/application_development/security.md +++ b/docs/application_development/security.md @@ -20,7 +20,7 @@ whether the authenticated user is permitted to access a resource. + [Authentication & Authorization Flow](#authentication-authorization-flow) + [Content Serving & Browser Security Headers](#content-serving-browser-security-headers) + [HTTPS / TLS](#https-tls) - + [Certificate Installation](#certificate-installation) + + [Certificate Creation & Installation](#certificate-creation-installation) --- @@ -70,7 +70,7 @@ bob:api_user,app_maintainer:sOqLqi48jCQUiR+VpcCcfMgKcKCspbE902y0yFe0DV4=:5PzMbQQ New users can be added programmatically through the IAM API: -```python3 +```python from pyrobusta.utils.iam import IAMDatabase iam_db = IAMDatabase("pyrobusta.passwd", "pyrobusta.roles") iam_db.load() @@ -84,7 +84,7 @@ Because lower iteration counts reduce the computational cost of each password gu PyRobusta enforces strong password requirements to increase the password search space and improve resistance against brute-force attacks. -```python3 +```python iam_db.create_user("john", "secret", ["role-1", "role-2"]) Traceback (most recent call last): File "", line 1, in @@ -353,9 +353,118 @@ The following default is applicable to the `Referrer-Policy` header: ## HTTPS / TLS +TLS is enabled by the `tls` configuration parameter in `pyrobusta.env`. When enabled, the certificate and private +key specified by the `tls_cert_file` and `tls_key_file` configuration are loaded. The underlying asyncio server +accepts a configured MicroPython `ssl.SSLContext` through the `ssl` parameter of `asyncio.start_server()`. + +TLS capabilities, including supported certificate and private-key formats, key types, cipher suites, and cryptographic +algorithms, depend on the SSL implementation and configuration provided by the target MicroPython port. The configurations +documented here are those supported and tested for PyRobusta on the ESP32 port. Larger RSA key sizes and larger elliptic +curves require additional RAM and computational resources during TLS initialization and connection handling. +The recommended configurations provide a balance between compatibility, security, and resource usage. + +| Algorithm | Size/curve | Key/Certificate format | Comment | +| --- | --- | --- | --- | +| ECDSA | P-256 | DER | Recommended ECDSA configuration | +| ECDSA | P-384 | DER | | +| ECDSA | P-256 | PEM | | +| RSA | 2048 | DER | Recommended RSA configuration | +| RSA | 3072 | DER | | +| RSA | 4096 | DER | | +| RSA | 2048 | PEM | | + +## Certificate Creation & Installation + +The following section provides examples for certificate creation. +The examples below use ECDSA P-256, which is the recommended configuration. +RSA certificates can be generated similarly by replacing the EC key-generation commands +with RSA key generation. + +Replace `device.local` with the hostname used to access the device, as well as +the certificate subject (`subjectAltName`) values according to your deployment. +If the device is accessed by IP address, use an `IP` SAN instead, for +example `subjectAltName=IP:192.168.1.101`. + +### Self-signed Certificate + +```bash +# Create private key for the server +openssl genpkey -algorithm EC \ + -pkeyopt ec_paramgen_curve:P-256 \ + -outform DER -out key.der + +# Create certificate for the server, signed by itself +openssl req -x509 -new \ + -key key.der -keyform DER \ + -out cert.der -outform DER \ + -days 365 \ + -subj /CN=device.local \ + -addext 'basicConstraints=critical,CA:FALSE' \ + -addext 'keyUsage=critical,digitalSignature' \ + -addext 'subjectAltName=DNS:device.local' +``` + +The above commands produce: +``` +key.der ECDSA P-256 private key +cert.der self-signed certificate +``` + +### CA-signed Certificate + +```bash +# Optional: generate CA private key and certificate if does not exist +openssl genpkey -algorithm EC \ + -pkeyopt ec_paramgen_curve:P-256 \ + -outform DER -out ca.key.der + +openssl req -new -x509 \ + -key ca.key.der -keyform DER \ + -out ca.crt.der -outform DER \ + -days 3650 -subj /CN=TestCA \ + -addext 'basicConstraints=critical,CA:TRUE' \ + -addext 'keyUsage=critical,keyCertSign,cRLSign' + +# Create server private key certificate signing request (CSR) +openssl genpkey -algorithm EC \ + -pkeyopt ec_paramgen_curve:P-256 \ + -outform DER -out key.der + +openssl req -new \ + -key key.der -keyform DER \ + -out server.csr \ + -subj /CN=device.local + +# Create certificate for the server, signed by the CA +openssl x509 -req \ + -in server.csr \ + -CA ca.crt.der -CAform DER \ + -CAkey ca.key.der -CAkeyform DER \ + -CAcreateserial \ + -out cert.der -outform DER \ + -days 365 \ + -extfile <(printf '%s\n' \ + 'basicConstraints=critical,CA:FALSE' \ + 'keyUsage=critical,digitalSignature' \ + 'subjectAltName=DNS:device.local') +``` +The above commands produce: +``` +ca.key.der CA private key +ca.crt.der CA certificate +server.csr Server CSR +key.der Server private key +cert.der CA-signed server certificate +``` -## Certificate Installation +Install certificates into the root directory with `mpremote`: + +```bash +$ mpremote connect /dev/ttyACM1 soft-reset +$ mpremote connect /dev/ttyACM1 cp key.der :/key.der +$ mpremote connect /dev/ttyACM1 cp cert.der :/cert.der +``` --- diff --git a/docs/development.md b/docs/development.md index 741a5ab..4a775d3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,7 +50,8 @@ make run-unix # Run example application on the UNIX port of MicroPytho make toolchain # Setup mpy-cross and micropython make build # Cross-compile, create build artifacts make deploy # Upload build artifacts to the device using mpremote -make tls-cert # Optional: generate self-signed certificate for the device +make tls-ca # Optional: regenerate the key and certificate for the CA (certificate authority) +make tls-cert # Optional: generate CA-signed certificate for the device make deploy-cert # Optional: upload generated certificate to the device make deploy-app # Deploy the selected example application using mpremote make run-device # Reset the device and connect through REPL @@ -63,7 +64,7 @@ make APP_DIR=example/demo_app deploy-app ``` Make targets that communicate with a device (`deploy`, `deploy-cert`, `deploy-app`, `run-device`) use the `DEVICE` -variable, which defaults to `u0` (/dev/ttyUSB0). +variable, which defaults to `u0` (`/dev/ttyUSB0`). Override `DEVICE` to select a different serial device, for example: @@ -96,5 +97,5 @@ Performance tests must be run on a physical device. Results are exported to a di ```bash # Run performance tests and export results to docs/dimensioning/esp32_c3 -make DEVICE=a1 DEVICE_IP=192.168.0.100 DEVICE_NAME=ESP32-C3 perf-test-device +make DEVICE=a1 DEVICE_IP=192.168.1.101 DEVICE_NAME=ESP32-C3 perf-test-device ``` diff --git a/docs/setup.md b/docs/setup.md index 6f8f833..ba7e970 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,6 +1,6 @@ # Device Setup -Use [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) to access your device over a serial connection.\ +Use [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) to access your device over a serial connection. You can install mpremote via pip. It is also included in the project's requirements.txt ```bash @@ -11,7 +11,7 @@ python3 -m pip install -r requirements.txt After installing mpremote, check if you can connect to your device: ```bash -$ mpremote a1 soft-reset repl +$ mpremote connect /dev/ttyACM1 soft-reset repl Connected to MicroPython at /dev/ttyACM1 Use Ctrl-] or Ctrl-x to exit this shell > @@ -22,7 +22,7 @@ Type "help()" for more information. # Connect to Wi-Fi -During the initial setup, you’ll need to connect your device to a Wi-Fi network in order to install PyRobusta using the mip package manager.\ +During the initial setup, you’ll need to connect your device to a Wi-Fi network in order to install PyRobusta using the mip package manager. After connecting to your device with mpremote, run the following script: ```python @@ -54,22 +54,38 @@ Alternatively, follow the [development guide](./development.md) to build and dep # Automatic Connection on Boot -Once PyRobusta is installed, you can use its built-in Wi-Fi helper to automatically connect on boot. +Once PyRobusta is installed, the server automatically initializes Wi-Fi in station mode, requiring a correct SSID and password +of a Wi-Fi network. Override `wifi_ssid` and `wifi_password` in the configuration stored in [pyrobusta.env](./application_development/configuration.md). +The server will skip Wi-Fi connection if the credentials are missing, allowing the user application to manage network connectivity. + ```python # boot.py import machine +from pyrobusta import application -from pyrobusta.connectivity import wifi - -connected = wifi.initialize() +async def main(): + await application.run() + while True: + await asyncio.sleep(1) -# Keep mpremote access available after a soft reset -if connected and not machine.reset_cause() == machine.SOFT_RESET: - # +if machine.reset_cause() != machine.SOFT_RESET: + asyncio.run(main()) ``` Upload boot.py to your device with mpremote: ```bash -$ mpremote a1 cp boot.py :/boot.py +$ mpremote connect /dev/ttyACM1 cp boot.py :/boot.py +$ mpremote connect /dev/ttyACM1 reset +``` + +Observe logs by connecting to the device: +```bash +$ mpremote connect /dev/ttyACM1 reset sleep 1 repl +Connected to MicroPython at /dev/ttyACM1 +Use Ctrl-] or Ctrl-x to exit this shell +[...] +14205 INFO pyrobusta.application: connected, ip=[192.168.1.101] +14212 INFO pyrobusta.server.http_server: 4 connection(s) allowed +14366 INFO pyrobusta.server.http_server: started ``` diff --git a/example/boot.py b/example/boot.py index e286346..2d1d911 100644 --- a/example/boot.py +++ b/example/boot.py @@ -3,10 +3,7 @@ import machine from os import listdir -from pyrobusta.connectivity import wifi - -connected = wifi.initialize() -if connected and not machine.reset_cause() == machine.SOFT_RESET: +if not machine.reset_cause() == machine.SOFT_RESET: if "app.py" in listdir(): import app diff --git a/example/demo_app/app.py b/example/demo_app/app.py index 6fa1b8e..bcd50c5 100644 --- a/example/demo_app/app.py +++ b/example/demo_app/app.py @@ -1,8 +1,7 @@ import asyncio -from pyrobusta.server import http_server +from pyrobusta import application, PYROBUSTA_VERSION from pyrobusta.protocol.http import HttpEngine -from pyrobusta.utils.config import PYROBUSTA_VERSION APP_VERSION = "v0.0.1" @@ -12,9 +11,7 @@ def version(http_ctx, _): include_server_version = False if http_ctx.query: - is_detailed = http_ctx.get_query_param( - "detailed", default="false" - ).lower() + is_detailed = http_ctx.get_query_param("detailed", default="false").lower() if is_detailed not in ("true", "false"): http_ctx.terminate(400) @@ -36,14 +33,13 @@ def version(http_ctx, _): @HttpEngine.route("/{app_or_server}/version", "GET") def version(http_ctx, _): - include_server_version = False resource = http_ctx.path_segment(0) - if resource not in (b"app", b"server"): + if resource not in ("app", "server"): http_ctx.terminate(404) return "text/plain", "Not found" - version_string = APP_VERSION if resource == b"app" else PYROBUSTA_VERSION + version_string = APP_VERSION if resource == "app" else PYROBUSTA_VERSION if http_ctx.headers.get("accept") == "application/json": return "application/json", {"version": version_string} @@ -52,8 +48,8 @@ def version(http_ctx, _): async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() + while True: await asyncio.sleep(1) diff --git a/example/mem_usage/app.py b/example/mem_usage/app.py index f5c63dd..7c3251e 100644 --- a/example/mem_usage/app.py +++ b/example/mem_usage/app.py @@ -1,7 +1,7 @@ import asyncio from gc import mem_free, mem_alloc, collect -import pyrobusta.server.http_server as http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine @@ -34,7 +34,6 @@ def mem_usage(http_ctx, _): http_ctx.terminate(400) return "text/plain", "Invalid query" - return "text/plain", ( f"Currently used: {usage_percentage:.2f}%\n" f"Free [bytes]: {free}\n" @@ -44,8 +43,8 @@ def mem_usage(http_ctx, _): async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() + while True: await asyncio.sleep(1) diff --git a/example/mip_repo/app.py b/example/mip_repo/app.py index 07502d3..db7a281 100644 --- a/example/mip_repo/app.py +++ b/example/mip_repo/app.py @@ -1,8 +1,9 @@ import asyncio -import pyrobusta.server.http_server as http_server +from pyrobusta import application from pyrobusta.protocol.http import HttpEngine -from pyrobusta.utils import logging, config, assets, lexpath +from pyrobusta.utils import logging, assets, lexpath +from pyrobusta import PYROBUSTA_VERSION def append_package_files(dir, package_files, host_name, protocol): @@ -22,19 +23,9 @@ def append_package_files(dir, package_files, host_name, protocol): @HttpEngine.route("/pyrobusta/package.json", "GET") def self_serve_mip_package(http_ctx, _): - package_files = {"version": config.PYROBUSTA_VERSION, "deps": [], "urls": []} - tls_enabled = config.get_config(config.CONF_TLS) + package_files = {"version": PYROBUSTA_VERSION, "deps": [], "urls": []} server_addr = http_ctx.headers["host"] - if ":" not in server_addr: - port = ( - http_server.HttpServer.LISTEN_PORT_HTTPS - if tls_enabled - else http_server.HttpServer.LISTEN_PORT_HTTP - ) - if not server_addr in (80, 443): - server_addr += f":{port}" - - protocol = "https" if tls_enabled else "http" + protocol = "https" if http_ctx.tls else "http" logging.debug("mip_repo addr=[%s]", server_addr) append_package_files("/lib/pyrobusta", package_files, server_addr, protocol) @@ -42,11 +33,11 @@ def self_serve_mip_package(http_ctx, _): async def main(): - server = http_server.HttpServer() - await server.start_socket_server() + await application.run() + while True: await asyncio.sleep(1) if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/scripts/clean_device.py b/scripts/clean_device.py index 22d06ab..b2593f7 100644 --- a/scripts/clean_device.py +++ b/scripts/clean_device.py @@ -21,8 +21,18 @@ def delete_path(path): delete_path("/lib/pyrobusta") delete_path("/www") -for f in ("/app.py", "/boot.py", "/pyrobusta.env", "/cert.der", "/key.der"): +for f in ( + "/app.py", + "/boot.py", + "/pyrobusta.env", + "/pyrobusta.passwd", + "/pyrobusta.roles", + "/cert.der", + "/key.der", + "/cert.pem", + "/key.pem", +): try: remove(f) except OSError: - pass \ No newline at end of file + pass diff --git a/src/pyrobusta/utils/config.py b/src/pyrobusta/utils/config.py index 7d09729..95cf038 100644 --- a/src/pyrobusta/utils/config.py +++ b/src/pyrobusta/utils/config.py @@ -61,9 +61,9 @@ def __init__(self, path): self.http_multipart = False self.http_files_api = False self.http_auth = None - self.http_browser_security = False + self.http_browser_security = True self.http_insecure_auth = False - self.http_sessions = False + self.http_sessions = True self.http_session_ttl_sec = 900 self._read() diff --git a/tests/system/load_test.py b/tests/system/load_test.py index 0909fb2..d8a8a8b 100644 --- a/tests/system/load_test.py +++ b/tests/system/load_test.py @@ -270,12 +270,13 @@ def run_test( print( generate_measurement_table( measurements, - excluded_keys={ + { "http_port", "https_port", "http_served_paths", "http_insecure_auth", "log_level", }, + target_dir, ) ) diff --git a/tests/system/summary.py b/tests/system/summary.py index 1282944..8d4fcee 100644 --- a/tests/system/summary.py +++ b/tests/system/summary.py @@ -14,7 +14,9 @@ # ----------------------------------- -def generate_measurement_table(measurements: list, excluded_keys: list): +def generate_measurement_table( + measurements: list, excluded_keys: list, target_dir: str +): """ Generate a table in markdown format for measurement data. """ @@ -30,21 +32,24 @@ def generate_measurement_table(measurements: list, excluded_keys: list): config_keys = sorted(k for k in config_keys if k not in excluded_keys) headers = ["id"] + config_keys + ["footprint_bytes"] rows = [] - base_cfg = dict(base["config"]) - base_row = [base["id"]] + base_row = [f"[{base["id"]}](./{target_dir}/{base["id"]}.png)"] # Base config for key in config_keys: - base_row.append(base_cfg.get(key, "")) + value = base["config"].get(key, "N/A") + value = value if value not in ("", None) else "N/A" + base_row.append(value) base_row.append(base["idle"]) rows.append(base_row) # Measurements for m in measurements[1:]: - row = [m["id"]] + row = [f"[{m["id"]}](./{target_dir}/{m["id"]}.png)"] for key in config_keys: - row.append(m["config"].get(key, "")) + value = m["config"].get(key, "N/A") + value = value if value not in ("", None) else "N/A" + row.append(value) row.append(m["idle"]) rows.append(row) @@ -296,12 +301,13 @@ def main(): table = generate_measurement_table( measurements, - excluded_keys={ + { "http_port", "https_port", "http_served_paths", "log_level", }, + target_dir, ) print(table)