Aller au contenu

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 :

server.socket = ssl_ctx.wrap_socket(server.socket, server_side=True)

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 8000 montre Recv-Q: 5 — la file d'attente du kernel est saturée.
  • Côté client : ERR_TIMED_OUT dans Chrome, Connection timed out after 10001 milliseconds sur 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

  1. Le socket d'écoute n'est pas wrappé par ssl_ctx.wrap_socket(...). accept() rend toujours un socket TCP brut.
  2. Le wrap TLS s'exécute dans process_request_thread() de la sous-classe TLSThreadingHTTPServer, dans le thread du client lancé par ThreadingMixIn.
  3. Le handshake est borné par TLS_HANDSHAKE_TIMEOUT = 10s via request.settimeout(...). Après le handshake, le socket repasse en mode bloquant standard pour la requête HTTP elle-même.
  4. Les erreurs ssl.SSLError, OSError et socket.timeout sont attrapées, loggées en WARNING avec l'adresse client et le message d'origine, et la connexion est fermée proprement via shutdown_request(request).
  5. Le contexte SSL est posé en attribut server.ssl_context au lieu de muter server.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_TIMEOUT est une constante de module. Sa configurabilité via config.py est 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_TIMEOUT configurable via config.py.
  • Modifier les niveaux de logs TLS (déclasser CERTIFICATE_UNKNOWN en 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, classe TLSThreadingHTTPServer, constante TLS_HANDSHAKE_TIMEOUT.
  • Ticket d'implémentation : APP-PY-TLS-HANDSHAKE-PER-THREAD-001 (commit fc520cf sur la branche fix/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 (pourquoi ThreadingHTTPSServer n'est pas une option immédiate).
  • Documentation WSGI : docs/wsgi-deployment.md (terminer TLS via reverse proxy en production).