ADR-015 : Handshake TLS par thread client pour le serveur de développement¶
Statut¶
Accepté, Forge 1.0.0-beta.9 (ticket APP-PY-TLS-HANDSHAKE-PER-THREAD-001).
Date¶
2026-05-25
Contexte¶
Forge fournit un serveur de développement HTTPS intégré à app.py, basé sur http.server.ThreadingHTTPServer.
Ce serveur est destiné au développement local, à la pédagogie et aux tests : il permet d'expérimenter avec un cookie Secure, un en-tête HSTS, ou le comportement réel d'un navigateur sur HTTPS sans imposer un reverse proxy en local.
En production, le périmètre reste clair : TLS doit être terminé par Nginx ou un reverse proxy équivalent, et Forge écoute en HTTP local.
Voir la documentation wsgi-deployment et le ticket WSGI-APP-FACTORY-CONFIG-001.
État avant la décision¶
L'activation TLS dans app.py se faisait par :
Cette ligne enveloppe le socket d'écoute : chaque appel accept() rend alors un SSLSocket, et do_handshake_on_connect=True par défaut force le handshake TLS à s'exécuter de manière synchrone dans le thread appelant.
Comme accept() tourne dans le thread principal de serve_forever(), le handshake TLS bloque ce thread tant qu'il n'est pas terminé.
ThreadingMixIn ne lance son thread enfant qu'après le retour de get_request(), donc après le handshake.
Le thread-par-requête ne protège donc pas du handshake TLS bloquant.
Symptômes terrain¶
Reproductibles sur la configuration courante du projet (VS Code Remote SSH, navigateur distant, port forwarding, certificat auto-signé non encore accepté) :
- Après 2 à 6 requêtes navigateur (selon le pre-connect), le serveur se fige sans traceback.
ss -tlnp | grep 8000montreRecv-Q: 5, la file d'attente du kernel est saturée.- Côté client :
ERR_TIMED_OUTdans Chrome,Connection timed out after 10001 millisecondssur curl. - Particulièrement facile à déclencher quand le navigateur refuse le certificat auto-signé : les handshakes interrompus (alerte
CERTIFICATE_UNKNOWN) se cumulent et finissent par bloquer la boucle.
Décision¶
- Le socket d'écoute n'est pas wrappé par
ssl_ctx.wrap_socket(...).
accept()rend toujours un socket TCP brut. - Le wrap TLS s'exécute dans
process_request_thread()de la sous-classeTLSThreadingHTTPServer, dans le thread du client lancé parThreadingMixIn. - Le handshake est borné par
TLS_HANDSHAKE_TIMEOUT = 10sviarequest.settimeout(...).
Après le handshake, le socket repasse en mode bloquant standard pour la requête HTTP elle-même. - Les erreurs
ssl.SSLError,OSErroretsocket.timeoutsont attrapées, loggées enWARNINGavec l'adresse client et le message d'origine, et la connexion est fermée proprement viashutdown_request(request). - Le contexte SSL est posé en attribut
server.ssl_contextau lieu de muterserver.socket.
Flux résultant :
TCP accept() (thread principal - non bloquant)
→ ThreadingMixIn lance un thread (thread du client)
→ wrap_socket() (handshake TLS, borné par timeout)
→ RequestHandler (requête HTTP normale)
Conséquences¶
Positives¶
- Le serveur dev ne se fige plus après quelques connexions TLS interrompues ou refusées.
- Un client TLS lent, muet, mal configuré, ou parlant le mauvais protocole n'occupe que son propre thread, la boucle d'accept reste disponible.
- Le port 8000 reste utilisable avec VS Code Remote SSH, port forwarding, navigateurs distants, et scanners de sécurité agressifs sans dégradation.
- Le comportement est plus robuste face aux certificats auto-signés non encore acceptés par le navigateur.
- Les handshakes échoués sont visibles dans les logs (
WARNING, Handshake TLS échoué depuis <addr> : <message>).
Limites¶
- Le serveur dev reste un serveur de développement.
Il ne remplace pas Gunicorn + reverse proxy en production (cf ADR-005, ADR-006, et la documentationwsgi-deployment). TLS_HANDSHAKE_TIMEOUTest une constante de module.
Sa configurabilité viaconfig.pyest un ticket de suivi (APP-TLS-HANDSHAKE-TIMEOUT-CONFIG, hors scope de cet ADR).- L'observabilité des handshakes TLS échoués reste limitée au log (
WARNING).
Pas de compteur, pas de métrique exportée, un ticket de suivi optionnel a été suggéré. - Le serveur dev reste mono-processus.
Le multi-worker (Gunicorn, uWSGI) reste l'apanage du déploiement production via WSGI.
Alternatives écartées¶
A : Continuer à wrapper le socket d'écoute¶
Maintenir server.socket = ssl_ctx.wrap_socket(server.socket, ...).
Rejeté : c'est exactement le code qui produit le blocage.
Tout client TLS qui ne complète pas son handshake fige toute la boucle d'acceptation.
La preuve a été reproduite et documentée.
B : Surcharger uniquement get_request() pour wrapper là¶
Surcharger BaseServer.get_request() pour appeler wrap_socket() après accept().
Rejeté : get_request() est appelée par serve_forever() dans le thread principal, exactement comme accept().
Le handshake TLS y est aussi bloquant.
Cette approche avait été proposée dans un ticket antérieur, c'est une fausse piste.
C : Supprimer TLS du serveur de développement¶
Désactiver le mode HTTPS de app.py, imposer HTTP en dev.
Rejeté : HTTPS local reste utile pour tester certains comportements navigateur, notamment les cookies Secure, le contenu mixte HTTPS/HTTP, et les en-têtes HSTS.
Supprimer ce mode appauvrirait la pédagogie sans contrepartie.
D : Imposer un reverse proxy même en développement¶
Documenter l'usage obligatoire d'un Nginx/Caddy local pour terminer TLS, même en dev.
Rejeté : trop lourd pour un usage pédagogique et quotidien.
Forge vise un démarrage en quelques minutes (python app.py).
Imposer un reverse proxy en plus contredirait cet objectif.
E : Utiliser ThreadingHTTPSServer (Python 3.14+)¶
http.server.ThreadingHTTPSServer a été ajouté en Python 3.14 et résout nativement ce problème.
Rejeté pour l'instant : Forge cible Python 3.12+ (ADR-006).
La compatibilité 3.12 et 3.13 doit être préservée.
Une future bascule sur ThreadingHTTPSServer sera évaluée quand Python 3.14 sera la cible minimale.
Hors périmètre de cet ADR¶
- Rendre
TLS_HANDSHAKE_TIMEOUTconfigurable viaconfig.py. - Modifier les niveaux de logs TLS (déclasser
CERTIFICATE_UNKNOWNen INFO). - Ajouter un compteur d'observabilité des handshakes échoués.
- Documenter un guide complet Nginx/Caddy/systemd pour la production.
- Modifier la configuration WSGI ou la stratégie de déploiement production.
Référence¶
- Code livré :
app.py, classeTLSThreadingHTTPServer, constanteTLS_HANDSHAKE_TIMEOUT. - Ticket d'implémentation :
APP-PY-TLS-HANDSHAKE-PER-THREAD-001(commitfc520cfsur la branchefix/tls-per-thread-handshake). - Ticket documentaire :
APP-PY-TLS-HANDSHAKE-DOCS-001(cet ADR). - ADR-005 Packaging :
docs/adr/005-packaging.md(périmètre WSGI/prod). - ADR-006 Python 3.12+ :
docs/adr/006-python-version.md(pourquoiThreadingHTTPSServern'est pas une option immédiate). - Documentation WSGI :
docs/wsgi-deployment.md(terminer TLS via reverse proxy en production).