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
documentation
wsgi-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).