diff --git a/README.rst b/README.rst index 38a5307..fe5b99e 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 with enough memory e.g. ESP32-S3). Dependencies diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index ea9ba46..eba455f 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 @@ -33,12 +34,18 @@ 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 # pylint: disable=ungrouped-imports + NO_REQUEST = "no_request" CONNECTION_TIMED_OUT = "connection_timed_out" 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 +59,50 @@ 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: 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: + 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, 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 +110,30 @@ 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) + else: + self._ssl_context = None + if root_path in ["", "/"] and debug: _debug_warning_exposed_files(root_path) self.stopped = True @@ -197,6 +260,7 @@ def serve_forever( @staticmethod def _create_server_socket( socket_source: _ISocketPool, + ssl_context: "SSLContext | None", host: str, port: int, ) -> _ISocket: @@ -206,6 +270,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 +292,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) @@ -386,7 +455,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. @@ -399,11 +470,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() @@ -424,9 +496,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 @@ -439,6 +510,15 @@ 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) @@ -547,9 +627,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): diff --git a/docs/examples.rst b/docs/examples.rst index c1b5bba..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. @@ -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 @@ -369,6 +372,35 @@ video to multiple clients while simultaneously handling other requests. :emphasize-lines: 31-77,92 :linenos: +HTTPS +----- + +.. warning:: + 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 relatively secure way to communicate with the server. + +.. 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 ---------------- @@ -378,7 +410,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. @@ -421,5 +453,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. 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)