Skip to content

Commit bcef270

Browse files
authored
Merge pull request #88 from michalpokusa/https-implementation
HTTPS implementation
2 parents be94572 + 5f696e7 commit bcef270

File tree

4 files changed

+162
-18
lines changed

4 files changed

+162
-18
lines changed

README.rst

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ HTTP Server for CircuitPython.
3232
- Supports URL parameters and wildcard URLs.
3333
- Supports HTTP Basic and Bearer Authentication on both server and route per level.
3434
- Supports Websockets and Server-Sent Events.
35+
- Limited support for HTTPS (only on selected microcontrollers with enough memory e.g. ESP32-S3).
3536

3637

3738
Dependencies

adafruit_httpserver/server.py

+91-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
except ImportError:
1313
pass
1414

15+
from ssl import SSLContext, create_default_context
1516
from errno import EAGAIN, ECONNRESET, ETIMEDOUT
1617
from sys import implementation
1718
from time import monotonic, sleep
@@ -33,12 +34,18 @@
3334
from .route import Route
3435
from .status import BAD_REQUEST_400, UNAUTHORIZED_401, FORBIDDEN_403, NOT_FOUND_404
3536

37+
if implementation.name != "circuitpython":
38+
from ssl import Purpose, CERT_NONE, SSLError # pylint: disable=ungrouped-imports
39+
3640

3741
NO_REQUEST = "no_request"
3842
CONNECTION_TIMED_OUT = "connection_timed_out"
3943
REQUEST_HANDLED_NO_RESPONSE = "request_handled_no_response"
4044
REQUEST_HANDLED_RESPONSE_SENT = "request_handled_response_sent"
4145

46+
# CircuitPython does not have these error codes
47+
MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE = -30592
48+
4249

4350
class Server: # pylint: disable=too-many-instance-attributes
4451
"""A basic socket-based HTTP server."""
@@ -52,25 +59,81 @@ class Server: # pylint: disable=too-many-instance-attributes
5259
root_path: str
5360
"""Root directory to serve files from. ``None`` if serving files is disabled."""
5461

62+
@staticmethod
63+
def _validate_https_cert_provided(
64+
certfile: Union[str, None], keyfile: Union[str, None]
65+
) -> None:
66+
if certfile is None or keyfile is None:
67+
raise ValueError("Both certfile and keyfile must be specified for HTTPS")
68+
69+
@staticmethod
70+
def _create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContext:
71+
ssl_context = create_default_context()
72+
73+
ssl_context.load_verify_locations(cadata="")
74+
ssl_context.load_cert_chain(certfile, keyfile)
75+
76+
return ssl_context
77+
78+
@staticmethod
79+
def _create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext:
80+
ssl_context = create_default_context(purpose=Purpose.CLIENT_AUTH)
81+
82+
ssl_context.load_cert_chain(certfile, keyfile)
83+
84+
ssl_context.verify_mode = CERT_NONE
85+
ssl_context.check_hostname = False
86+
87+
return ssl_context
88+
89+
@classmethod
90+
def _create_ssl_context(cls, certfile: str, keyfile: str) -> SSLContext:
91+
return (
92+
cls._create_circuitpython_ssl_context(certfile, keyfile)
93+
if implementation.name == "circuitpython"
94+
else cls._create_cpython_ssl_context(certfile, keyfile)
95+
)
96+
5597
def __init__(
56-
self, socket_source: _ISocketPool, root_path: str = None, *, debug: bool = False
98+
self,
99+
socket_source: _ISocketPool,
100+
root_path: str = None,
101+
*,
102+
https: bool = False,
103+
certfile: str = None,
104+
keyfile: str = None,
105+
debug: bool = False,
57106
) -> None:
58107
"""Create a server, and get it ready to run.
59108
60109
:param socket: An object that is a source of sockets. This could be a `socketpool`
61110
in CircuitPython or the `socket` module in CPython.
62111
:param str root_path: Root directory to serve files from
63112
:param bool debug: Enables debug messages useful during development
113+
:param bool https: If True, the server will use HTTPS
114+
:param str certfile: Path to the certificate file, required if ``https`` is True
115+
:param str keyfile: Path to the private key file, required if ``https`` is True
64116
"""
65-
self._auths = []
66117
self._buffer = bytearray(1024)
67118
self._timeout = 1
119+
120+
self._auths = []
68121
self._routes: "List[Route]" = []
122+
self.headers = Headers()
123+
69124
self._socket_source = socket_source
70125
self._sock = None
71-
self.headers = Headers()
126+
72127
self.host, self.port = None, None
73128
self.root_path = root_path
129+
self.https = https
130+
131+
if https:
132+
self._validate_https_cert_provided(certfile, keyfile)
133+
self._ssl_context = self._create_ssl_context(certfile, keyfile)
134+
else:
135+
self._ssl_context = None
136+
74137
if root_path in ["", "/"] and debug:
75138
_debug_warning_exposed_files(root_path)
76139
self.stopped = True
@@ -197,6 +260,7 @@ def serve_forever(
197260
@staticmethod
198261
def _create_server_socket(
199262
socket_source: _ISocketPool,
263+
ssl_context: "SSLContext | None",
200264
host: str,
201265
port: int,
202266
) -> _ISocket:
@@ -206,6 +270,9 @@ def _create_server_socket(
206270
if implementation.version >= (9,) or implementation.name != "circuitpython":
207271
sock.setsockopt(socket_source.SOL_SOCKET, socket_source.SO_REUSEADDR, 1)
208272

273+
if ssl_context is not None:
274+
sock = ssl_context.wrap_socket(sock, server_side=True)
275+
209276
sock.bind((host, port))
210277
sock.listen(10)
211278
sock.setblocking(False) # Non-blocking socket
@@ -225,7 +292,9 @@ def start(self, host: str = "0.0.0.0", port: int = 5000) -> None:
225292
self.host, self.port = host, port
226293

227294
self.stopped = False
228-
self._sock = self._create_server_socket(self._socket_source, host, port)
295+
self._sock = self._create_server_socket(
296+
self._socket_source, self._ssl_context, host, port
297+
)
229298

230299
if self.debug:
231300
_debug_started_server(self)
@@ -386,7 +455,9 @@ def _set_default_server_headers(self, response: Response) -> None:
386455
name, value
387456
)
388457

389-
def poll(self) -> str:
458+
def poll( # pylint: disable=too-many-branches,too-many-return-statements
459+
self,
460+
) -> str:
390461
"""
391462
Call this method inside your main loop to get the server to check for new incoming client
392463
requests. When a request comes in, it will be handled by the handler function.
@@ -399,11 +470,12 @@ def poll(self) -> str:
399470

400471
conn = None
401472
try:
473+
if self.debug:
474+
_debug_start_time = monotonic()
475+
402476
conn, client_address = self._sock.accept()
403477
conn.settimeout(self._timeout)
404478

405-
_debug_start_time = monotonic()
406-
407479
# Receive the whole request
408480
if (request := self._receive_request(conn, client_address)) is None:
409481
conn.close()
@@ -424,9 +496,8 @@ def poll(self) -> str:
424496
# Send the response
425497
response._send() # pylint: disable=protected-access
426498

427-
_debug_end_time = monotonic()
428-
429499
if self.debug:
500+
_debug_end_time = monotonic()
430501
_debug_response_sent(response, _debug_end_time - _debug_start_time)
431502

432503
return REQUEST_HANDLED_RESPONSE_SENT
@@ -439,6 +510,15 @@ def poll(self) -> str:
439510
# Connection reset by peer, try again later.
440511
if error.errno == ECONNRESET:
441512
return NO_REQUEST
513+
# Handshake failed, try again later.
514+
if error.errno == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE:
515+
return NO_REQUEST
516+
517+
# CPython specific SSL related errors
518+
if implementation.name != "circuitpython" and isinstance(error, SSLError):
519+
# Ignore unknown SSL certificate errors
520+
if getattr(error, "reason", None) == "SSLV3_ALERT_CERTIFICATE_UNKNOWN":
521+
return NO_REQUEST
442522

443523
if self.debug:
444524
_debug_exception_in_handler(error)
@@ -547,9 +627,10 @@ def _debug_warning_exposed_files(root_path: str):
547627

548628
def _debug_started_server(server: "Server"):
549629
"""Prints a message when the server starts."""
630+
scheme = "https" if server.https else "http"
550631
host, port = server.host, server.port
551632

552-
print(f"Started development server on http://{host}:{port}")
633+
print(f"Started development server on {scheme}://{host}:{port}")
553634

554635

555636
def _debug_response_sent(response: "Response", time_elapsed: float):

docs/examples.rst

+40-8
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ It is important to use correct ``enctype``, depending on the type of data you wa
196196
- ``application/x-www-form-urlencoded`` - For sending simple text data without any special characters including spaces.
197197
If you use it, values will be automatically parsed as strings, but special characters will be URL encoded
198198
e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello+World%21+%5E-%24%25"``
199-
- ``multipart/form-data`` - For sending textwith special characters and files
199+
- ``multipart/form-data`` - For sending text with special characters and files
200200
When used, non-file values will be automatically parsed as strings and non plain text files will be saved as ``bytes``.
201201
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\...``.
202202
- ``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
322322
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
323323
response object somewhere, so that it can be accessed later.
324324

325-
**Because of the limited number of concurrently open sockets, it is not possible to process more than one SSE response at the same time.
326-
This might change in the future, but for now, it is recommended to use SSE only with one client at a time.**
325+
326+
.. warning::
327+
Because of the limited number of concurrently open sockets, it is **not possible to process more than one SSE response at the same time**.
328+
This might change in the future, but for now, it is recommended to use SSE **only with one client at a time**.
327329

328330
.. literalinclude:: ../examples/httpserver_sse.py
329331
:caption: examples/httpserver_sse.py
@@ -344,8 +346,9 @@ This is anologous to calling ``.poll()`` on the ``Server`` object.
344346
The following example uses ``asyncio``, which has to be installed separately. It is not necessary to use ``asyncio`` to use Websockets,
345347
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.
346348

347-
**Because of the limited number of concurrently open sockets, it is not possible to process more than one Websocket response at the same time.
348-
This might change in the future, but for now, it is recommended to use Websocket only with one client at a time.**
349+
.. warning::
350+
Because of the limited number of concurrently open sockets, it is **not possible to process more than one Websocket response at the same time**.
351+
This might change in the future, but for now, it is recommended to use Websocket **only with one client at a time**.
349352

350353
.. literalinclude:: ../examples/httpserver_websocket.py
351354
:caption: examples/httpserver_websocket.py
@@ -369,6 +372,35 @@ video to multiple clients while simultaneously handling other requests.
369372
:emphasize-lines: 31-77,92
370373
:linenos:
371374

375+
HTTPS
376+
-----
377+
378+
.. warning::
379+
HTTPS on CircuitPython **works only on boards with enough memory e.g. ESP32-S3**.
380+
381+
When you want to expose your server to the internet or an untrusted network, it is recommended to use HTTPS.
382+
Together with authentication, it provides a relatively secure way to communicate with the server.
383+
384+
.. note::
385+
Using HTTPS slows down the server, because of additional work with encryption and decryption.
386+
387+
Enabling HTTPS is straightforward and comes down to passing the path to the certificate and key files to the ``Server`` constructor
388+
and setting ``https=True``.
389+
390+
.. literalinclude:: ../examples/httpserver_https.py
391+
:caption: examples/httpserver_https.py
392+
:emphasize-lines: 15-17
393+
:linenos:
394+
395+
396+
To create your own certificate, you can use the following command:
397+
398+
.. code-block:: bash
399+
400+
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout key.pem -out cert.pem
401+
402+
You might have to change permissions of the files, so that the server can read them.
403+
372404
Multiple servers
373405
----------------
374406

@@ -378,7 +410,7 @@ Using ``.serve_forever()`` for this is not possible because of it's blocking beh
378410

379411
Each server **must have a different port number**.
380412

381-
In order to distinguish between responses from different servers a 'X-Server' header is added to each response.
413+
To distinguish between responses from different servers a 'X-Server' header is added to each response.
382414
**This is an optional step**, both servers will work without it.
383415

384416
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::
421453
If you need more information about the server or request, or you want it in a different format you can modify
422454
functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``.
423455

424-
NOTE:
425-
*This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code.*
456+
.. note::
457+
This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code.

examples/httpserver_https.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# SPDX-FileCopyrightText: 2024 Michał Pokusa
2+
#
3+
# SPDX-License-Identifier: Unlicense
4+
5+
import socketpool
6+
import wifi
7+
8+
from adafruit_httpserver import Server, Request, Response
9+
10+
11+
pool = socketpool.SocketPool(wifi.radio)
12+
server = Server(
13+
pool,
14+
root_path="/static",
15+
https=True,
16+
certfile="cert.pem",
17+
keyfile="key.pem",
18+
debug=True,
19+
)
20+
21+
22+
@server.route("/")
23+
def base(request: Request):
24+
"""
25+
Serve a default static plain text message.
26+
"""
27+
return Response(request, "Hello from the CircuitPython HTTPS Server!")
28+
29+
30+
server.serve_forever(str(wifi.radio.ipv4_address), 443)

0 commit comments

Comments
 (0)