Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTPS implementation #88

Merged
merged 13 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 91 additions & 10 deletions adafruit_httpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -52,25 +59,81 @@ 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.

:param socket: An object that is a source of sockets. This could be a `socketpool`
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
Expand Down Expand Up @@ -197,6 +260,7 @@ def serve_forever(
@staticmethod
def _create_server_socket(
socket_source: _ISocketPool,
ssl_context: "SSLContext | None",
host: str,
port: int,
) -> _ISocket:
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
48 changes: 40 additions & 8 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
----------------

Expand All @@ -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.
Expand Down Expand Up @@ -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.
30 changes: 30 additions & 0 deletions examples/httpserver_https.py
Original file line number Diff line number Diff line change
@@ -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)
Loading