From 9b06f5a3e4cb6f6ada14c01dc18d0914fcf58759 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 23 Feb 2024 18:23:15 +0000 Subject: [PATCH 01/11] Modified Server to create and use SSLContext --- adafruit_httpserver/server.py | 55 +++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index ea9ba46..ea9805c 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -12,6 +12,7 @@ except ImportError: pass +from ssl import SSLContext, create_default_context from errno import EAGAIN, ECONNRESET, ETIMEDOUT from sys import implementation from time import monotonic, sleep @@ -39,6 +40,9 @@ REQUEST_HANDLED_NO_RESPONSE = "request_handled_no_response" REQUEST_HANDLED_RESPONSE_SENT = "request_handled_response_sent" +# CircuitPython does not have these error codes +MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE = -30592 + class Server: # pylint: disable=too-many-instance-attributes """A basic socket-based HTTP server.""" @@ -52,8 +56,28 @@ class Server: # pylint: disable=too-many-instance-attributes root_path: str """Root directory to serve files from. ``None`` if serving files is disabled.""" + @staticmethod + def _validate_https_cert_provided(certfile: str, keyfile: str) -> None: + if not certfile or not keyfile: + raise ValueError("Both certfile and keyfile must be specified for HTTPS") + + @staticmethod + def _create_ssl_context(certfile: str, keyfile: str) -> SSLContext: + ssl_context = create_default_context() + ssl_context.load_verify_locations(cadata="") + ssl_context.load_cert_chain(certfile, keyfile) + + return ssl_context + def __init__( - self, socket_source: _ISocketPool, root_path: str = None, *, debug: bool = False + self, + socket_source: _ISocketPool, + root_path: str = None, + *, + https: bool = False, + certfile: str = None, + keyfile: str = None, + debug: bool = False, ) -> None: """Create a server, and get it ready to run. @@ -61,16 +85,28 @@ def __init__( in CircuitPython or the `socket` module in CPython. :param str root_path: Root directory to serve files from :param bool debug: Enables debug messages useful during development + :param bool https: If True, the server will use HTTPS + :param str certfile: Path to the certificate file, required if ``https`` is True + :param str keyfile: Path to the private key file, required if ``https`` is True """ - self._auths = [] self._buffer = bytearray(1024) self._timeout = 1 + + self._auths = [] self._routes: "List[Route]" = [] + self.headers = Headers() + self._socket_source = socket_source self._sock = None - self.headers = Headers() + self.host, self.port = None, None self.root_path = root_path + self.https = https + + if https: + self._validate_https_cert_provided(certfile, keyfile) + self._ssl_context = self._create_ssl_context(certfile, keyfile) + if root_path in ["", "/"] and debug: _debug_warning_exposed_files(root_path) self.stopped = True @@ -197,6 +233,7 @@ def serve_forever( @staticmethod def _create_server_socket( socket_source: _ISocketPool, + ssl_context: SSLContext, host: str, port: int, ) -> _ISocket: @@ -206,6 +243,9 @@ def _create_server_socket( if implementation.version >= (9,) or implementation.name != "circuitpython": sock.setsockopt(socket_source.SOL_SOCKET, socket_source.SO_REUSEADDR, 1) + if ssl_context is not None: + sock = ssl_context.wrap_socket(sock, server_side=True) + sock.bind((host, port)) sock.listen(10) sock.setblocking(False) # Non-blocking socket @@ -225,7 +265,9 @@ def start(self, host: str = "0.0.0.0", port: int = 5000) -> None: self.host, self.port = host, port self.stopped = False - self._sock = self._create_server_socket(self._socket_source, host, port) + self._sock = self._create_server_socket( + self._socket_source, self._ssl_context, host, port + ) if self.debug: _debug_started_server(self) @@ -439,6 +481,8 @@ def poll(self) -> str: # Connection reset by peer, try again later. if error.errno == ECONNRESET: return NO_REQUEST + if error.errno == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE: + return NO_REQUEST if self.debug: _debug_exception_in_handler(error) @@ -547,9 +591,10 @@ def _debug_warning_exposed_files(root_path: str): def _debug_started_server(server: "Server"): """Prints a message when the server starts.""" + scheme = "https" if server.https else "http" host, port = server.host, server.port - print(f"Started development server on http://{host}:{port}") + print(f"Started development server on {scheme}://{host}:{port}") def _debug_response_sent(response: "Response", time_elapsed: float): From 1b9371a943e3aa6d59003eca50517b08fe0d2725 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Fri, 23 Feb 2024 18:25:06 +0000 Subject: [PATCH 02/11] Added example of using HTTPS --- examples/httpserver_https.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 examples/httpserver_https.py diff --git a/examples/httpserver_https.py b/examples/httpserver_https.py new file mode 100644 index 0000000..c006676 --- /dev/null +++ b/examples/httpserver_https.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2024 MichaƂ Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Server, Request, Response + + +pool = socketpool.SocketPool(wifi.radio) +server = Server( + pool, + root_path="/static", + https=True, + certfile="cert.pem", + keyfile="key.pem", + debug=True, +) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTPS Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address), 443) From bc9c844e608f4d54b0e855a1842ba2224ab175f5 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:42:06 +0000 Subject: [PATCH 03/11] Fix: Setting Server._ssl_context to None if not using HTTPS --- adafruit_httpserver/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index ea9805c..713bbb6 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -106,6 +106,8 @@ def __init__( if https: self._validate_https_cert_provided(certfile, keyfile) self._ssl_context = self._create_ssl_context(certfile, keyfile) + else: + self._ssl_context = None if root_path in ["", "/"] and debug: _debug_warning_exposed_files(root_path) @@ -233,7 +235,7 @@ def serve_forever( @staticmethod def _create_server_socket( socket_source: _ISocketPool, - ssl_context: SSLContext, + ssl_context: "SSLContext | None", host: str, port: int, ) -> _ISocket: From d943404f72cb05ca30d652248dcd7d353c66e6d6 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 1 May 2024 01:35:21 +0000 Subject: [PATCH 04/11] Changed moment of starting debug timing handlers --- adafruit_httpserver/server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 713bbb6..57b0edc 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -443,11 +443,12 @@ def poll(self) -> str: conn = None try: + if self.debug: + _debug_start_time = monotonic() + conn, client_address = self._sock.accept() conn.settimeout(self._timeout) - _debug_start_time = monotonic() - # Receive the whole request if (request := self._receive_request(conn, client_address)) is None: conn.close() @@ -468,9 +469,8 @@ def poll(self) -> str: # Send the response response._send() # pylint: disable=protected-access - _debug_end_time = monotonic() - if self.debug: + _debug_end_time = monotonic() _debug_response_sent(response, _debug_end_time - _debug_start_time) return REQUEST_HANDLED_RESPONSE_SENT From 3d4a64916d1d88676277bd6f9e71f3a2fafcf954 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 1 May 2024 01:57:27 +0000 Subject: [PATCH 05/11] Fix and refactor of SSL handling for CPython --- adafruit_httpserver/server.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 57b0edc..9bdea52 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -34,6 +34,9 @@ from .route import Route from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 +if implementation.name != "circuitpython": + from ssl import Purpose, CERT_NONE, SSLError + NO_REQUEST = "no_request" CONNECTION_TIMED_OUT = "connection_timed_out" @@ -62,13 +65,33 @@ def _validate_https_cert_provided(certfile: str, keyfile: str) -> None: raise ValueError("Both certfile and keyfile must be specified for HTTPS") @staticmethod - def _create_ssl_context(certfile: str, keyfile: str) -> SSLContext: + def __create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: ssl_context = create_default_context() + ssl_context.load_verify_locations(cadata="") ssl_context.load_cert_chain(certfile, keyfile) return ssl_context + @staticmethod + def __create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: + ssl_context = create_default_context(purpose=Purpose.CLIENT_AUTH) + + ssl_context.load_cert_chain(certfile, keyfile) + + ssl_context.verify_mode = CERT_NONE + ssl_context.check_hostname = False + + return ssl_context + + @classmethod + def _create_ssl_context(cls, certfile: str, keyfile: str) -> SSLContext: + return ( + cls.__create_circuitpython_ssl_context(certfile, keyfile) + if implementation.name == "circuitpython" + else cls.__create_cpython_ssl_context(certfile, keyfile) + ) + def __init__( self, socket_source: _ISocketPool, @@ -483,9 +506,16 @@ def poll(self) -> str: # Connection reset by peer, try again later. if error.errno == ECONNRESET: return NO_REQUEST + # Handshake failed, try again later. if error.errno == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE: return NO_REQUEST + # CPython specific SSL related errors + if implementation.name != "circuitpython" and isinstance(error, SSLError): + # Ignore unknown SSL certificate errors + if getattr(error, "reason", None) == "SSLV3_ALERT_CERTIFICATE_UNKNOWN": + return NO_REQUEST + if self.debug: _debug_exception_in_handler(error) From 736471afe19c3650dc3d6bd61fc0cdc9de20c1e4 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 1 May 2024 02:03:17 +0000 Subject: [PATCH 06/11] Fix: pylint CI --- adafruit_httpserver/server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 9bdea52..4d75734 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -35,7 +35,7 @@ from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404 if implementation.name != "circuitpython": - from ssl import Purpose, CERT_NONE, SSLError + from ssl import Purpose, CERT_NONE, SSLError # pylint: disable=ungrouped-imports NO_REQUEST = "no_request" @@ -453,7 +453,9 @@ def _set_default_server_headers(self, response: Response) -> None: name, value ) - def poll(self) -> str: + def poll( # pylint: disable=too-many-branches,too-many-return-statements + self, + ) -> str: """ Call this method inside your main loop to get the server to check for new incoming client requests. When a request comes in, it will be handled by the handler function. From 14d6bef9e2c59d3acb7575ef876ab9290841b151 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 1 May 2024 02:40:03 +0000 Subject: [PATCH 07/11] Added docs about SSL/TLS --- docs/examples.rst | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/examples.rst b/docs/examples.rst index 92b6e25..8720363 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -352,6 +352,35 @@ This might change in the future, but for now, it is recommended to use Websocket :emphasize-lines: 12,20,65-72,88,99 :linenos: +SSL/TLS (HTTPS) +--------------- + +.. warning:: + For now HTTPS on CircuitPython is **only supported on ESP32-S3 boards**. + +When you want to expose your server to the internet or an untrusted network, it is recommended to use HTTPS. +Together with authentication, it provides a secure way to communicate with the server, without the risk of eavesdropping. + +.. note:: + Using HTTPS slows down the server, because of additional work with encryption and decryption. + +Enabling HTTPS is straightforward and comes down to passing the path to the certificate and key files to the ``Server`` constructor +and setting ``https=True``. + +.. literalinclude:: ../examples/httpserver_https.py + :caption: examples/httpserver_https.py + :emphasize-lines: 15-17 + :linenos: + + +To create your own certificate, you can use the following command: + +.. code-block:: bash + + sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout key.pem -out cert.pem + +You might have to change permissions of the files, so that the server can read them. + Multiple servers ---------------- From b2b01aaaa932a5a57d9ff3df863a811ccc0e0384 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Wed, 1 May 2024 02:42:27 +0000 Subject: [PATCH 08/11] Minor refactor of some parts of docs --- docs/examples.rst | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 8720363..ba3e17d 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -322,8 +322,10 @@ This can be overcomed by periodically polling the server, but it is not an elega Response is initialized on ``return``, events can be sent using ``.send_event()`` method. Due to the nature of SSE, it is necessary to store the response object somewhere, so that it can be accessed later. -**Because of the limited number of concurrently open sockets, it is not possible to process more than one SSE response at the same time. -This might change in the future, but for now, it is recommended to use SSE only with one client at a time.** + +.. warning:: + Because of the limited number of concurrently open sockets, it is **not possible to process more than one SSE response at the same time**. + This might change in the future, but for now, it is recommended to use SSE **only with one client at a time.**. .. literalinclude:: ../examples/httpserver_sse.py :caption: examples/httpserver_sse.py @@ -344,8 +346,9 @@ This is anologous to calling ``.poll()`` on the ``Server`` object. The following example uses ``asyncio``, which has to be installed separately. It is not necessary to use ``asyncio`` to use Websockets, but it is recommended as it makes it easier to handle multiple tasks. It can be used in any of the examples, but here it is particularly useful. -**Because of the limited number of concurrently open sockets, it is not possible to process more than one Websocket response at the same time. -This might change in the future, but for now, it is recommended to use Websocket only with one client at a time.** +.. warning:: + Because of the limited number of concurrently open sockets, it is **not possible to process more than one Websocket response at the same time**. + This might change in the future, but for now, it is recommended to use Websocket **only with one client at a time**. .. literalinclude:: ../examples/httpserver_websocket.py :caption: examples/httpserver_websocket.py @@ -390,7 +393,7 @@ Using ``.serve_forever()`` for this is not possible because of it's blocking beh Each server **must have a different port number**. -In order to distinguish between responses from different servers a 'X-Server' header is added to each response. +To distinguish between responses from different servers a 'X-Server' header is added to each response. **This is an optional step**, both servers will work without it. In combination with separate authentication and diffrent ``root_path`` this allows creating moderately complex setups. @@ -433,5 +436,5 @@ This is the default format of the logs:: If you need more information about the server or request, or you want it in a different format you can modify functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``. -NOTE: -*This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code.* +.. note:: + This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code. From ff165dd59cf9499716c3f70780535de805fc071e Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sun, 30 Jun 2024 23:12:16 +0000 Subject: [PATCH 09/11] Added info about HTTPS support to README.md --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 38a5307..d2eb3e8 100644 --- a/README.rst +++ b/README.rst @@ -32,6 +32,7 @@ HTTP Server for CircuitPython. - Supports URL parameters and wildcard URLs. - Supports HTTP Basic and Bearer Authentication on both server and route per level. - Supports Websockets and Server-Sent Events. +- Limited support for HTTPS (only on selected microcontrollers e.g. ESP32-S3). Dependencies From 17364edf80ebbf4f43e811f2f1cdda32fde464f9 Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 21 Dec 2024 01:34:38 +0100 Subject: [PATCH 10/11] Last changes in docs and minor refactor/fixes in typing in Server method --- README.rst | 2 +- adafruit_httpserver/server.py | 14 ++++++++------ docs/examples.rst | 8 ++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index d2eb3e8..fe5b99e 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ HTTP Server for CircuitPython. - Supports URL parameters and wildcard URLs. - Supports HTTP Basic and Bearer Authentication on both server and route per level. - Supports Websockets and Server-Sent Events. -- Limited support for HTTPS (only on selected microcontrollers e.g. ESP32-S3). +- Limited support for HTTPS (only on selected microcontrollers with enough memory e.g. ESP32-S3). Dependencies diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 4d75734..eba455f 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -60,12 +60,14 @@ class Server: # pylint: disable=too-many-instance-attributes """Root directory to serve files from. ``None`` if serving files is disabled.""" @staticmethod - def _validate_https_cert_provided(certfile: str, keyfile: str) -> None: - if not certfile or not keyfile: + def _validate_https_cert_provided( + certfile: Union[str, None], keyfile: Union[str, None] + ) -> None: + if certfile is None or keyfile is None: raise ValueError("Both certfile and keyfile must be specified for HTTPS") @staticmethod - def __create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: + def _create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: ssl_context = create_default_context() ssl_context.load_verify_locations(cadata="") @@ -74,7 +76,7 @@ def __create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContex return ssl_context @staticmethod - def __create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: + def _create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: ssl_context = create_default_context(purpose=Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(certfile, keyfile) @@ -87,9 +89,9 @@ def __create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: @classmethod def _create_ssl_context(cls, certfile: str, keyfile: str) -> SSLContext: return ( - cls.__create_circuitpython_ssl_context(certfile, keyfile) + cls._create_circuitpython_ssl_context(certfile, keyfile) if implementation.name == "circuitpython" - else cls.__create_cpython_ssl_context(certfile, keyfile) + else cls._create_cpython_ssl_context(certfile, keyfile) ) def __init__( diff --git a/docs/examples.rst b/docs/examples.rst index 56af9a4..9cb8bfd 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -372,14 +372,14 @@ video to multiple clients while simultaneously handling other requests. :emphasize-lines: 31-77,92 :linenos: -SSL/TLS (HTTPS) ---------------- +HTTPS +----- .. warning:: - For now HTTPS on CircuitPython is **only supported on ESP32-S3 boards**. + HTTPS on CircuitPython **works only on boards with enough memory e.g. ESP32-S3**. When you want to expose your server to the internet or an untrusted network, it is recommended to use HTTPS. -Together with authentication, it provides a secure way to communicate with the server, without the risk of eavesdropping. +Together with authentication, it provides a relatively secure way to communicate with the server. .. note:: Using HTTPS slows down the server, because of additional work with encryption and decryption. From 5f696e77e79676fc1d730d8e68218f5c60ae730a Mon Sep 17 00:00:00 2001 From: michalpokusa <72110769+michalpokusa@users.noreply.github.com> Date: Sat, 21 Dec 2024 02:00:54 +0100 Subject: [PATCH 11/11] Fixed typos in examples.rst --- docs/examples.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 9cb8bfd..229a15d 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -196,7 +196,7 @@ It is important to use correct ``enctype``, depending on the type of data you wa - ``application/x-www-form-urlencoded`` - For sending simple text data without any special characters including spaces. If you use it, values will be automatically parsed as strings, but special characters will be URL encoded e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello+World%21+%5E-%24%25"`` -- ``multipart/form-data`` - For sending textwith special characters and files +- ``multipart/form-data`` - For sending text with special characters and files When used, non-file values will be automatically parsed as strings and non plain text files will be saved as ``bytes``. e.g. ``"Hello World! ^-$%"`` will be saved as ``'Hello World! ^-$%'``, and e.g. a PNG file will be saved as ``b'\x89PNG\r\n\x1a\n\x00\...``. - ``text/plain`` - For sending text data with special characters. @@ -325,7 +325,7 @@ response object somewhere, so that it can be accessed later. .. warning:: Because of the limited number of concurrently open sockets, it is **not possible to process more than one SSE response at the same time**. - This might change in the future, but for now, it is recommended to use SSE **only with one client at a time.**. + This might change in the future, but for now, it is recommended to use SSE **only with one client at a time**. .. literalinclude:: ../examples/httpserver_sse.py :caption: examples/httpserver_sse.py