diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index bef4756..d3f788b 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -7475,10 +7475,192 @@ "ticket_id": "850f0a15-6a3f-4f3b-9499-e3d102bae203", "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" }, - "state": "running", + "state": "completed", "wakePolicy": "recordOnly", "createdAtMs": 1784220484402, - "updatedAtMs": 1784220484402, + "updatedAtMs": 1784220752458, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784220752458, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Les trois sont faits, sans conflit, strictement en local. `develop` est sur **`e8a72f5`**, vert, arbre propre.\n\n## B1 : mergé, et j'ai refait la QA hors sandbox\n\nTon avertissement était le bon, et je l'ai suivi à la lettre : `cargo test -p web-server` **hors sandbox** → **57 passed**, avec `run_embedded_stop_shuts_down_accept_loop` et `run_embedded_with_core_uses_injected_core_for_http_invokes` **visibles nommément dans la sortie**, donc réellement exécutés sur le vrai chemin réseau. `backend` 41 · `app-tauri` 35, 0 échec. Puis re-vérifié sur `develop` après merge.\n\nJ'ai aussi vérifié deux choses plutôt que de te croire sur parole. D'abord que le fix est réel : dans `run_embedded_with_core`, l'ordre est bien bind → `local_addr` → `effective_config.listen = local_addr` → **puis** `ServerState::with_core`. La config porte le port effectif avant que le state n'existe. Ensuite qu'aucun repli EPERM ne subsiste — `grep` sur `PermissionDenied|EPERM|ErrorKind::Permission` dans `web-server` ne rend rien. Un bind refusé fera désormais échouer le test au lieu de le peindre en vert.\n\n**Le faux vert est écrit dans le message de commit**, pas seulement dans un carnet. C'est le genre de fait qu'on doit retrouver dans six mois : un lot dont le premier vert était un artefact d'environnement, et la raison. J'y ai aussi tracé la dette B0.\n\nJ'ai confirmé cette dette de mon côté : `run_server` reçoit un `Arc` **déjà construit** en paramètre, le bind arrive après. L'hypothèse fausse est intacte sur le chemin CLI. Le cadrage d'Architect vers #72 est exact.\n\n| Commit | |\n|---|---|\n| `b7d38f1` | `feat(web-server)` — seam + fix du port effectif |\n| `328c941` | merge `--no-ff` dans `develop` |\n\n## La doc : mergée, complète\n\nUn point que tu n'avais pas : **la phrase n'était pas sur la branche**. DevBackend l'a bien écrite, mais elle n'existait que dans le `stash@{0}` où je l'avais mise en sécurité au dernier changement de branche — le sommet de `docs/remote-mode-proxy-clarification` ne la contenait pas. Si j'avais mergé sur ta parole, `develop` recevait la doc **sans** la phrase, exactement ce que je bloquais. Je l'ai restituée, committée (`677f64d`), puis mergée (`e8a72f5`). Le stash est consommé.\n\nTa condition est levée et la branche est supprimée.\n\n## Le reste du working tree\n\nIl ne contenait pas que B1. J'ai séparé en trois intentions plutôt que de tout empiler dans le commit de B1 :\n\n- `7c677c4` — `CLAUDE.md` : le rôle UX et le cycle mis à jour. Contexte projet, sans rapport avec #68.\n- `f915635` — l'ouverture de #72.\n\nLes deux directement sur `develop`, ils n'attendaient aucune branche.\n\n## La branche pour #72 : `feature/ticket72-proxy-trust-hardening`\n\nCréée depuis `develop@e8a72f5`, arbre propre. Le plan se réordonne comme Architect l'a décidé, sans rien changer à ma topologie — c'est précisément l'intérêt d'une branche par lot : **#68 B1 (fait)** → **#72** → **#71 lot 1** → **Settings Deployment de #68**. La contrainte « pas de panneau avant les diagnostics » reste garantie par la topologie, puisque la branche du panneau n'existera qu'après.\n\nUne remarque : **#72 est né du drapeau creux que je t'avais signalé au tour précédent**, et son intitulé le reprend mot pour mot. Le ticket porte `blocks: #68` — ce qui est cohérent avec l'ordre retenu.\n\n## Stashes\n\nToujours 3, inchangés, tous non intégrés (`ticket4-git` MEMORY.md, `composer`, `layouts-active-self-heal`). `stash@{0}` de la doc est consommé comme prévu.\n\n`develop` est en avance de 16 commits sur `origin/develop`. Aucune action sortante : pas de push, pas de PR.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "8c0530cc-905b-40d2-a12c-ad8dcb84aea2", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "cfde72d0-254b-4c15-8c83-c66a64ec6a0a", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784220786906, + "updatedAtMs": 1784221360763, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784221360763, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Implémentation #72 faite sur `feature/ticket72-proxy-trust-hardening`, sans commit.\n\n**Signatures / surfaces**\n- [ServerConfig](/home/anthony/Documents/Projects/IdeA/crates/web-server/src/lib.rs:90) ajoute :\n ```rust\n pub trusted_proxies: Vec\n ```\n- [TrustedProxy](/home/anthony/Documents/Projects/IdeA/crates/web-server/src/lib.rs:109) :\n ```rust\n pub struct TrustedProxy { ... }\n pub fn parse(value: &str) -> Result\n ```\n- CLI : `--trusted-proxy IP_OR_CIDR`, répétable.\n- Runtime guard :\n ```rust\n fn validate_reverse_proxy(\n headers: &HeaderMap,\n peer_ip: Option,\n state: &ServerState,\n _route: &str,\n ) -> Result<(), Box>>\n ```\n\n**Fichiers touchés**\n- [crates/web-server/src/lib.rs](/home/anthony/Documents/Projects/IdeA/crates/web-server/src/lib.rs:88)\n- [docs/server-client-mode-remote.md](/home/anthony/Documents/Projects/IdeA/docs/server-client-mode-remote.md:50)\n\n**Ce qui est couvert**\n- Non-loopback + remote + trilogie sans `--trusted-proxy` : refusé par `validate()`.\n- `--trust-reverse-proxy` reste accepté mais ne suffit plus en non-loopback.\n- HTTP et WS vérifient le peer TCP réel avant de faire confiance aux headers.\n- `X-Forwarded-Proto: https` obligatoire en remote.\n- `X-Forwarded-Host` doit matcher l’authority de `public_origin`.\n- `X-Forwarded-For` n’autorise rien ; il est seulement repris dans le diagnostic.\n- B0 : `run_server` construit maintenant le `ServerState` après bind avec `config.listen = local_addr`, comme l’embedded.\n\n**Tests exécutés**\n```text\ncargo fmt --check\nexit code 0\n\ncargo test -p web-server --no-run\nexit code 0\n\ncargo test -p web-server proxy -- --nocapture\nexit code 0\n8 passed; 0 failed\n\ncargo test -p web-server effective_listen -- --nocapture\nexit code 0\n1 passed; 0 failed\n\ncargo test -p web-server public_bind -- --nocapture\nexit code 0\n1 passed; 0 failed\n\ncargo test -p web-server pairing_sets_secure_cookie_for_remote_https_origin -- --nocapture\nexit code 0\n1 passed; 0 failed\n\ngit diff --check -- crates/web-server/src/lib.rs docs/server-client-mode-remote.md\nexit code 0\n```\n\n`cargo test -p web-server` complet ne passe pas dans ce sandbox, et c’est visible :\n\n```text\nexit code 101\n63 passed; 2 failed\nfailures:\n tests::run_embedded_stop_shuts_down_accept_loop\n tests::run_embedded_with_core_uses_injected_core_for_http_invokes\n\nErr:\n\"failed to bind 127.0.0.1:0: Operation not permitted (os error 1)\"\n```\n\nQA a relancé les mêmes commandes avec le même blocage EPERM sur les tests qui bindent TCP. Je n’ai donc pas pu produire un vert hors sandbox depuis cette session.\n\nÉcart de cadrage : je n’ai pas ajouté de DTO desktop équivalent dans `app-tauri`, parce qu’il n’existe pas encore de surface `embedded_server_*` ou de config serveur desktop sur cette branche ; créer un DTO non câblé aurait anticipé le lot B2 de #68.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "9a232676-0220-4fb6-b9ad-713c772ca638", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "4f069d35-df99-41a4-8797-165794527270", + "conversation_id": "bec31f20-db12-0995-00f2-ed824a618dea" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784220797334, + "updatedAtMs": 1784220809415, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784220809415, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Rien à faire : le worktree y était déjà.\n\n**Branche active** : `feature/ticket72-proxy-trust-hardening`\n**HEAD** : `e8a72f5` (`Merge docs/remote-mode-proxy-clarification into develop (#65)`) — c'est bien le sommet de `develop`, la branche n'a encore aucun commit propre.\n\nAucun commit, aucun merge, aucune bascule effectuée.\n\nUne seule modification non liée dans l'arbre, intacte et non touchée : `.ideai/background-tasks/97b49ac2-*.json` (journal des tâches de fond, état runtime). DevBackend peut attaquer #72 — B0 en premier, le bug de port sur `run_server`.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "2e6cf77c-5da6-4db6-a10b-b1feaa1aa4aa", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "62e5a1bd-5b88-4da3-8a90-3a9d5a67db3a", + "conversation_id": "dd35eeb0-2329-0dfa-3255-df7dba90a0aa" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784221247561, + "updatedAtMs": 1784221314739, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784221314739, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Validation QA #72 : **vert sur le périmètre testable ici, avec blocage environnemental connu sur les tests qui bindent TCP**. Je n’ai rien modifié et je n’ai pas committé.\n\nRelu :\n- [crates/web-server/src/lib.rs](/home/anthony/Documents/Projects/IdeA/crates/web-server/src/lib.rs:88)\n- [docs/server-client-mode-remote.md](/home/anthony/Documents/Projects/IdeA/docs/server-client-mode-remote.md:50)\n\nConstat QA :\n- `TrustedProxy` IP/CIDR, `ServerConfig.trusted_proxies`, CLI `--trusted-proxy` répétable : présents.\n- Non-loopback remote sans trusted proxy : refusé par `validate()`.\n- Garde runtime HTTP et WS : basé sur le peer TCP réel, pas sur `X-Forwarded-For`.\n- `X-Forwarded-Proto: https` et `X-Forwarded-Host == public_origin authority` requis après validation du peer.\n- Diagnostics `UntrustedProxyPeer`, `ForwardedProtoRejected`, `ForwardedHostRejected` présents et testés.\n- B0 effectif : `run_server` et embedded construisent l’état avec `config.listen = local_addr`.\n\nCommandes relancées :\n\n```text\ncargo fmt --check\nexit code 0\n```\n\n```text\ncargo test -p web-server --no-run\nexit code 0\nExecutable unittests src/lib.rs\nExecutable unittests src/bin/idea-serve.rs\n```\n\n```text\ncargo test -p web-server proxy -- --nocapture\nexit code 0\n8 passed; 0 failed\n```\n\n```text\ncargo test -p web-server effective_listen -- --nocapture\nexit code 0\n1 passed; 0 failed\n```\n\n```text\ncargo test -p web-server public_bind -- --nocapture\nexit code 0\n1 passed; 0 failed\n```\n\n```text\ncargo test -p web-server\nexit code 101\n63 passed; 2 failed\nfailures:\n tests::run_embedded_stop_shuts_down_accept_loop\n tests::run_embedded_with_core_uses_injected_core_for_http_invokes\n\nErr value:\n\"failed to bind 127.0.0.1:0: Operation not permitted (os error 1)\"\n```\n\n```text\ncargo test -p web-server run_embedded -- --nocapture --test-threads=1\nexit code 101\n0 passed; 2 failed; 63 filtered out\nsame EPERM bind failure on 127.0.0.1:0\n```\n\n```text\ngit diff --check -- crates/web-server/src/lib.rs docs/server-client-mode-remote.md\nexit code 0\n```\n\nNote d’environnement : chaque commande affiche encore le bruit shell `Fatal Python error: Failed to import encodings module`, mais les codes de sortie Cargo/Git ci-dessus sont fiables.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "06008123-31ca-489b-9ea3-fe2e72dba479", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "49ad64f4-7de3-4cd8-b0af-e3c7f06ab14f", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784221423323, + "updatedAtMs": 1784221505053, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784221505053, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Corrigé. Je n’ai pas touché au message runtime avec `{peer}`.\n\nDiff ciblé de la correction :\n\n```diff\n- return Err(\"non-loopback remote bind requires --trusted-proxy ; for a proxy at 192.168.1.22 use --trusted-proxy 192.168.1.22\".to_owned());\n+ return Err(\"non-loopback remote bind requires --trusted-proxy ; add the IP address or CIDR of the reverse proxy that connects to this server\".to_owned());\n```\n\n```diff\n- assert!(err.contains(\"--trusted-proxy 192.168.1.22\"));\n+ assert!(err.contains(\"--trusted-proxy \"));\n+ assert!(!err.contains(\"192.168.1.22\"));\n```\n\nJ’ai aussi remplacé les IPs réelles dans la doc par des adresses de documentation :\n\n```diff\n- --listen 192.168.1.75:17373\n- --trusted-proxy 192.168.1.22\n+ --listen 192.0.2.75:17373\n+ --trusted-proxy 192.0.2.22\n```\n\nVérification grep : `192.168.1.22` / `192.168.1.75` ne restent que dans les tests Rust, où elles servent à vérifier l’interpolation dynamique du peer.\n\nTests exécutés :\n\n```text\ncargo fmt --check\nexit code 0\n\ncargo test -p web-server public_bind -- --nocapture\nexit code 0\n1 passed\n\ncargo test -p web-server proxy -- --nocapture\nexit code 0\n8 passed\n\ncargo test -p web-server --no-run\nexit code 0\n\ngit diff --check -- crates/web-server/src/lib.rs docs/server-client-mode-remote.md\nexit code 0\n```\n\nComplet local sandbox :\n\n```text\ncargo test -p web-server\nexit code 101\n63 passed; 2 failed\nfailures:\n run_embedded_stop_shuts_down_accept_loop\n run_embedded_with_core_uses_injected_core_for_http_invokes\nreason: failed to bind 127.0.0.1:0: Operation not permitted (os error 1)\n```", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "dc882236-3e66-4ca4-b0d9-4c7e956f465a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "7e16dcb3-48cc-42c2-beae-73622461212a", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784223080438, + "updatedAtMs": 1784223171263, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784223171263, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict**\n\nLe raisonnement est juste, avec une nuance : le TLS intégré ne supprime pas les problèmes réseau réels, par exemple DNS, NAT, port 443 fermé, Docker qui publie un port, firewall cloud. Mais il supprime la cérémonie fragile `trusted-proxy + X-Forwarded-Proto + X-Forwarded-Host` pour le cas standard “j’expose IdeA directement”.\n\nLa complexité actuelle vient bien du fait que le serveur ne termine pas TLS.\n\n**#73 — Cadrage TLS Intégré**\n\nChoix techno : **`rustls-acme` en premier choix**, si DevBackend confirme compatibilité avec `rustls` 0.23 dans l’arbre actuel.\n\nRaison : c’est la brique la plus légère côté code produit, car elle prend en charge le resolver certificat + renouvellement + challenge TLS-ALPN-01. `instant-acme` est plus bas niveau : bon fallback si incompatibilité, mais il nous laisse écrire plus de plomberie ACME, stockage, challenge, reload.\n\nPas de nginx/Caddy embarqué : trop lourd, double serveur, nouvelle surface d’exploitation.\n\nFrontière :\n\n- TLS vit dans `crates/web-server`, adapter HTTP/TLS entrant.\n- Pas de port domaine.\n- Pas de use case application.\n- Le `BackendCore` reste inchangé.\n- Le serveur choisit un transport entrant :\n - `httpLoopback`\n - `httpsAcmeDirect`\n - `reverseProxyHttps`\n - éventuellement plus tard `httpsCustomCert`.\n\nConfig V1 directe :\n\n```text\nidea-serve \\\n --listen 0.0.0.0:443 \\\n --tls acme \\\n --domain idea.example.com \\\n --acme-email user@example.com\n```\n\nTLS-ALPN-01 :\n\n- exige le port 443 joignable depuis Internet ;\n- pas de port 80 ;\n- pas de web root HTTP ;\n- colle bien au modèle “ouvrir un port”.\n\nCertificats :\n\n```text\n/deployment/tls/acme/\n```\n\n- clé privée user-only (`0600` Unix, ACL utilisateur best-effort Windows) ;\n- certificats et compte ACME persistés ;\n- renouvellement automatique par tâche serveur ;\n- erreurs de renouvellement visibles via diagnostics #71.\n\nLAN sans domaine :\n\n- ne pas réintroduire du LAN HTTP remote comme “solution”.\n- V1 : local-only reste HTTP loopback.\n- pour LAN pur, soit hors V1, soit mode avancé “self-signed/dev” explicitement marqué avec avertissement navigateur. Je ne le mettrais pas dans le chemin principal #73.\n\n**Impact Sur #72**\n\n#72 ne disparaît pas, mais son périmètre change : il devient **hardening du mode reverse proxy**, pas passage obligé du mode remote.\n\nMode TLS direct :\n\n- pas de `--trusted-proxy`;\n- pas de `X-Forwarded-*`;\n- le serveur sait que la connexion est HTTPS puisqu’il termine TLS.\n\nMode reverse proxy :\n\n- garde `--trusted-proxy`;\n- garde `X-Forwarded-Proto: https`;\n- garde les refus de pair non autorisé.\n\nDonc je ne jetterais pas #72. Je le ferais converger maintenant vers “proxy mode only”, puis #73 ajoute un mode direct qui contourne toute cette logique.\n\nLes messages `lib.rs:1984` et `lib.rs:2003` doivent être corrigés seulement si les chemins restent. Le chemin proto reste utile en reverse proxy, donc oui : corriger `1984`. Pour `2003`, voir point suivant.\n\n**X-Forwarded-Host**\n\nJe tranche : **ne pas en faire un hard reject V1**.\n\n`Origin` strict protège les requêtes navigateur `/api/*` contre l’usage cross-origin normal. Un client non-navigateur peut falsifier `Origin`, mais il peut aussi falsifier `X-Forwarded-Host` s’il arrive à passer par un proxy de confiance mal configuré ; le vrai contrôle d’accès reste pairing/session + peer proxy autorisé.\n\n`X-Forwarded-Host` est surtout un diagnostic de proxy mal configuré. Le rendre obligatoire casse nginx par défaut et augmente la friction pour un gain trop faible.\n\nDécision :\n\n- `X-Forwarded-Proto: https` reste obligatoire en reverse proxy.\n- `X-Forwarded-Host` / `Forwarded host=` / `Host` peuvent être utilisés pour diagnostic.\n- mismatch host => warning diagnostic #71, pas 403, sauf si on découvre une route sans `Origin` qui mute de l’état sensible.\n\nDonc ne pas faire travailler DevBackend sur le message `2003` si on applique cette décision : ce refus doit disparaître ou devenir warning.\n\n**#66 Docker**\n\n#66 doit être réaligné après #73.\n\nNe pas embarquer nginx/Caddy dans l’image : plus lourd et contredit la contrainte. L’image doit utiliser TLS applicatif IdeA.\n\nMode Docker recommandé :\n\n```yaml\nports:\n - \"443:443\"\nvolumes:\n - idea-data:/data\nenvironment:\n IDEA_APP_DATA_DIR: /data\ncommand:\n - idea-serve\n - --listen\n - 0.0.0.0:443\n - --tls\n - acme\n - --domain\n - idea.example.com\n - --acme-email\n - user@example.com\n```\n\nAvertissement docs : publier un port Docker expose réellement le service, souvent malgré `ufw`. Donc l’image ne doit plus documenter `--listen 0.0.0.0:17373` HTTP comme chemin distant.\n\nReverse proxy Docker reste un mode avancé, pas le chemin principal.\n\n**Config Proxy Générée**\n\nÀ faire, mais pas dans #71 pur.\n\nC’est une aide de déploiement, pas seulement un diagnostic. Je la mettrais dans #73 ou #68 Settings Deployment, avec génération backend depuis la config effective.\n\nSurfaces :\n\n- UI Settings Deployment : bloc nginx prêt à coller.\n- CLI/headless : `idea-serve --print-nginx-config` ou `--print-proxy-config nginx`.\n\nLe snippet doit inclure les headers attendus réellement. Si on retire le hard reject `X-Forwarded-Host`, le snippet peut quand même envoyer :\n\n```nginx\nproxy_set_header Host $host;\nproxy_set_header X-Forwarded-Proto https;\nproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n```\n\n**Ordre Du Sprint**\n\nNouvel ordre recommandé :\n\n1. **#72 ajusté et mergé**\n - garder `trustedProxy` pour reverse proxy ;\n - bug CLI `--listen 127.0.0.1:0` en B0 ;\n - retirer `X-Forwarded-Host` hard reject, le transformer en diagnostic/warning ;\n - corriger le message `X-Forwarded-Proto`.\n\n2. **#73 TLS intégré**\n - transport `httpsAcmeDirect`;\n - stockage certs ;\n - renouvellement ;\n - CLI directe ;\n - Docker path réaligné conceptuellement.\n\n3. **#71 lot 1**\n - diagnostics visibles, incluant origin rejected, proto rejected, host warning, ACME state.\n\n4. **#68 Settings Deployment**\n - persistance ;\n - UI Settings ;\n - mode recommandé par défaut : TLS direct si domaine configuré ;\n - reverse proxy en mode avancé.\n\n5. **#66 Docker réaligné**\n - image/documentation sur TLS applicatif ;\n - abandon du distant HTTP `0.0.0.0:17373`.\n\n```idea-memory\nslug: ticket73-integrated-tls-cadrage\ntitle: #73 — TLS intégré pour IdeA server\ntype: architecture\ndescription: Décision d'ajouter TLS applicatif léger au web-server pour éviter la cérémonie reverse proxy comme chemin principal.\n---\nIdeA gagne un mode TLS intégré prioritaire. Le raisonnement produit est validé : la complexité `trusted-proxy + X-Forwarded-*` vient du fait que le serveur ne termine pas TLS. TLS direct ne supprime pas DNS/NAT/firewall, mais supprime la confiance proxy comme passage obligé.\n\nChoix techno : `rustls-acme` en premier choix si compatible avec `rustls` 0.23 déjà dans l'arbre ; `instant-acme` seulement en fallback si incompatibilité. Pas de nginx/Caddy embarqué.\n\nFrontière : TLS vit dans `crates/web-server`, adapter entrant. Pas de domaine/application. BackendCore inchangé.\n\nModes :\n- `httpLoopback` local-only ;\n- `httpsAcmeDirect` recommandé remote ;\n- `reverseProxyHttps` avancé, gardant `trustedProxy` et `X-Forwarded-Proto`.\n\nTLS direct n'exige ni `--trusted-proxy` ni `X-Forwarded-*`.\n\n`X-Forwarded-Host` ne doit pas être un hard reject V1 ; le transformer en diagnostic/warning. `X-Forwarded-Proto: https` reste obligatoire en reverse proxy.\n\nDocker #66 doit être réaligné après #73 : image avec TLS applicatif, port 443 publié, volume certs/app-data. Ne plus documenter `0.0.0.0:17373` HTTP comme mode distant.\n\nOrdre : #72 ajusté -> #73 -> #71 lot 1 -> #68 Settings Deployment -> #66 Docker.\n```", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "0555c7e5-28b6-4daf-8708-2e021eebb094", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "5cfe29f7-3e9f-4311-af9f-81c3ba345c16", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784235873168, + "updatedAtMs": 1784236006782, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784236006782, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Ajustement appliqué sur `feature/ticket72-proxy-trust-hardening`, sans commit.\n\nDiff utile de ce changement :\n\n```diff\n- ForwardedHostRejected {\n+ ForwardedHostMismatch {\n peer: IpAddr,\n host: Option,\n expected: String,\n },\n```\n\n```diff\n- SecurityLogEvent::ForwardedHostRejected { ... } => {\n- format!(\"forwardedHostRejected ...\")\n+ SecurityLogEvent::ForwardedHostMismatch { ... } => {\n+ format!(\"forwardedHostMismatch ...\")\n }\n```\n\n```diff\n- \"IdeA rejected the request because the trusted proxy did not report X-Forwarded-Proto: https\"\n+ \"IdeA rejected the request because the trusted proxy did not report X-Forwarded-Proto: https. Configure the reverse proxy to send it; for nginx add: proxy_set_header X-Forwarded-Proto $scheme;\"\n```\n\n```diff\n- return Err(Box::new(error_response(\n- StatusCode::FORBIDDEN,\n- \"FORBIDDEN\",\n- format!(\"IdeA rejected the request because X-Forwarded-Host does not match {expected}\"),\n- None,\n- )));\n+ // no hard reject: diagnostic only\n```\n\nTests adaptés :\n- `trusted_proxy_allows_forwarded_host_mismatch_with_warning`\n- `trusted_proxy_allows_missing_forwarded_host_with_warning`\n\nDoc ajustée : `X-Forwarded-Proto: https` reste obligatoire ; le host forwardé devient recommandé pour diagnostics, pas bloquant.\n\nSorties réelles :\n\n```text\ncargo fmt --check\nexit code 0\n```\n\n```text\ncargo test -p web-server proxy -- --nocapture\nexit code 0\n9 passed; 0 failed\n```\n\n```text\ncargo test -p web-server --no-run\nexit code 0\n```\n\n```text\ngit diff --check -- crates/web-server/src/lib.rs docs/server-client-mode-remote.md\nexit code 0\n```\n\nComplet local sandbox :\n\n```text\ncargo test -p web-server\nexit code 101\n64 passed; 2 failed\nfailures:\n run_embedded_stop_shuts_down_accept_loop\n run_embedded_with_core_uses_injected_core_for_http_invokes\nreason: failed to bind 127.0.0.1:0: Operation not permitted (os error 1)\n```\n\nVérification chaînes utilisateur : pas d’IP réelle dans la doc ni dans les messages produit ; `192.168.1.x` ne reste que dans les tests Rust.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "fb6f906c-c8f7-4f7b-b8c4-f1c743cf7830", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "be71eeb6-f960-43f9-a317-9e76fc30197c", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "running", + "wakePolicy": "recordOnly", + "createdAtMs": 1784236059726, + "updatedAtMs": 1784236059726, "deadlineMs": null, "result": null, "completionDelivered": false diff --git a/.ideai/tickets/73/carnet.md b/.ideai/tickets/73/carnet.md new file mode 100644 index 0000000..f2aae01 --- /dev/null +++ b/.ideai/tickets/73/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#73" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784223044517 +--- diff --git a/.ideai/tickets/73/issue.md b/.ideai/tickets/73/issue.md new file mode 100644 index 0000000..c93bb13 --- /dev/null +++ b/.ideai/tickets/73/issue.md @@ -0,0 +1,46 @@ +--- +id: "a43a07d0-573f-41e9-9b7b-2f88beb4e660" +number: 73 +title: "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy" +status: "open" +priority: "high" +sprint: null +links: [{"target":"#72","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"},{"target":"#71","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784223044517 +updatedAt: 1784223044517 +version: 1 +--- +**Décision utilisateur (2026-07-16) : IdeA gagne le TLS intégré.** Contrainte posée : prendre la technologie la plus légère. + +**La cause racine, établie par une session de debug réelle.** Un utilisateur a mis une matinée à exposer `idea-serve` derrière son nginx. Cinq obstacles, découverts un par un en tapant dans le mur : bind loopback injoignable par un proxy distant (timeout muet), ufw (timeout muet), `--trusted-proxy` obligatoire (#72), `X-Forwarded-Proto` absent (403), `X-Forwarded-Host` absent (403). + +**Ces cinq obstacles ont UNE cause : le serveur ne sait pas chiffrer.** Ne pouvant pas terminer le TLS, il doit *croire quelqu'un d'autre sur parole* quand on lui dit que la connexion publique était en HTTPS. `--trusted-proxy` sert à savoir qui a le droit de lui mentir ; `X-Forwarded-Proto` est le mensonge lui-même. Toute la machinerie de #72 n'existe **que** parce qu'il manque cette capacité. #66 avait acté l'exclusion noir sur blanc (« Pas de TLS applicatif V1 », « Hors périmètre V1 : TLS intégré ») — c'est cette ligne qui a produit les cinq obstacles. + +**Ce que le TLS intégré change.** L'utilisateur ouvre le 443 de son routeur, donne son domaine, terminé : pas de `--trusted-proxy`, pas de `X-Forwarded-*`, pas de proxy du tout. Un reverse proxy devant reste possible, mais devient un **choix**, plus une obligation structurelle. Une bonne partie de #72 se simplifie mécaniquement — sans disparaître : le mode « derrière proxy » reste supporté et garde ses contrôles. + +**Constat technique qui répond à la contrainte de légèreté (vérifié dans `Cargo.lock`) :** +- `rustls` 0.23, `tokio-rustls`, `ring`, `webpki-roots`, `hyper-rustls` sont **DÉJÀ** dans l'arbre de dépendances. +- **Aucun** `openssl`, `openssl-sys` ni `native-tls`. +- Le coût marginal d'un TLS serveur est donc quasi nul : la brique est déjà vendue avec le projet. Pas de nouvelle chaîne de dépendances, pas de dépendance système (contrairement à OpenSSL). + +**Pistes ACME à arbitrer par Architect :** +- `rustls-acme` — bâti sur rustls/tokio, fournit le resolver de certificats et le renouvellement automatique. Plus intégré, moins de code à écrire. +- `instant-acme` — plus bas niveau, pure Rust, minimal ; le challenge et le stockage restent à notre charge. +- **Argument fort pour le challenge TLS-ALPN-01 : il ne nécessite QUE le port 443.** Pas de port 80, pas de web root séparé pour HTTP-01. Cohérent avec « l'utilisateur ouvre un port ». + +**Limites à assumer et à documenter (ne pas vendre du rêve) :** +- ACME exige un **domaine public** et un port joignable depuis l'extérieur. Couvre l'exposition Internet, **pas** le LAN pur sans domaine. +- LAN pur ⇒ certificat auto-signé ⇒ avertissement navigateur. À traiter comme un mode distinct, pas à masquer. +- IdeA devient **responsable des certificats** : stockage, permissions, renouvellement, gestion des rate limits Let's Encrypt (utiliser l'environnement de staging en test, sous peine de blocage). +- Ne jamais mettre de certificat/clé dans un dossier synchronisé ou versionné. + +**Interactions à cadrer :** +- **#72** : le mode « TLS direct » ne doit exiger ni `--trusted-proxy` ni `X-Forwarded-*` — le serveur SAIT que la connexion est HTTPS puisqu'il la termine. Le mode « derrière proxy » garde ses contrôles. +- **#66 (Docker)** : son défaut `--listen 0.0.0.0:17373` + distant est **déjà invalide** depuis #72 (refusé au démarrage sans `--trusted-proxy`). Le TLS intégré rouvre la question de l'image : proxy embarqué ou TLS applicatif ? +- **#68** : les modes d'exposition du panneau desktop gagnent un mode « TLS direct », et le champ « IP du proxy autorisé » disparaît dans ce mode. +- **#71** : les diagnostics doivent couvrir les échecs ACME (challenge, DNS, port 443 injoignable), qui seront la nouvelle classe d'échec silencieux. + +**Exigence produit transverse, issue du même incident :** l'échec ne doit jamais être muet. Un certificat qui ne s'obtient pas doit le dire, avec la raison et l'action. C'est exactement le défaut qu'on corrige partout ailleurs dans ce sprint (#71). \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 48a7b19..b869c64 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 73 + "nextNumber": 74 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index e807a9c..0cec201 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -758,6 +758,16 @@ "sprint": null, "assignedAgentIds": [], "updatedAt": 1784218596269 + }, + { + "issueRef": "#73", + "path": "73", + "title": "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784223044517 } ] } \ No newline at end of file