# Mode operatoire : acces web a une session Claude Code persistante Document destine a etre donne tel quel a une instance Claude Code disposant d'un acces root sur l'hyperviseur / le serveur cible. Reproduit une stack deja en production. Fichier joint indispensable : `ttyd-custom.html` (frontend personnalise, etape 6). Si tu ne l'as pas, la section 6.3 contient son contenu integral. --- ## 0. Resultat vise et architecture Depuis n'importe quel navigateur, on ouvre `https://claude.mondomaine.fr`, on saisit un identifiant, et on retombe sur **la meme session Claude Code**, exactement la ou elle en etait, y compris apres fermeture du navigateur, redemarrage du PC ou changement d'appareil. ``` navigateur -> https://claude.mondomaine.fr (443, TLS Let's Encrypt) -> reverse proxy Caddy (basic_auth + websocket) -> conteneur/VM claude-code : 7681 (jamais expose publiquement) -> ttyd 1.7.7 (frontend xterm.js personnalise) -> tmux attach-session -t claude (persistance de la session) -> claude (Claude Code CLI) ``` Points de conception qui comptent : - La persistance vient de **tmux**, pas de ttyd. ttyd n'est qu'un pont websocket vers un PTY. - tmux tourne dans un service systemd **distinct** de ttyd. Redemarrer ttyd ne tue donc jamais la session. C'est ce qui rend les mises a jour du frontend sans risque. - L'authentification est faite par le **reverse proxy**, pas par ttyd. Le port 7681 doit rester inaccessible depuis l'exterieur. Reference materielle de l'installation existante (largement suffisant) : LXC Proxmox non privilegie, Ubuntu 24.04, 4 vCPU, 4 Go RAM, disque 20 Go. --- ## 1. Prerequis | Element | Detail | |---|---| | Un domaine | ex. `mondomaine.fr`, avec acces a la zone DNS | | Une IP publique | fixe, ou dynamique + client DDNS | | Acces au routeur/box | pour rediriger les ports 80 et 443 | | Un serveur Linux | conteneur LXC, VM ou machine physique, x86_64 | | Un compte Claude | abonnement Claude ou cle API Anthropic | Choisir des maintenant : - le sous-domaine : `claude.mondomaine.fr` - l'IP interne du serveur Claude : ex. `192.168.1.123` - l'IP interne du reverse proxy : ex. `192.168.1.2` Le reverse proxy et le serveur Claude peuvent etre la meme machine. Le document les separe car c'est plus propre, mais l'etape 7 indique comment tout regrouper. --- ## 2. Etape 1 : creer le conteneur Sous Proxmox (adapter les identifiants et le pont reseau) : ```bash pct create 123 local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \ --hostname claude-code \ --cores 4 --memory 4096 --swap 512 \ --rootfs local-lvm:20 \ --net0 name=eth0,bridge=vmbr0,ip=192.168.1.123/24,gw=192.168.1.1 \ --unprivileged 1 --features nesting=1 \ --onboot 1 --start 1 ``` `nesting=1` n'est necessaire que si tu comptes faire tourner Docker ou des outils qui montent des systemes de fichiers dans le conteneur. Sans cela, ce n'est pas requis. Sur une VM ou une machine physique : installer Ubuntu 24.04, IP fixe, c'est tout. Toute la suite s'execute **dans** ce serveur, en root. --- ## 3. Etape 2 : base systeme ```bash apt update && apt install -y \ curl git tmux ca-certificates locales build-essential \ python3 jq unzip # Locale UTF-8 : indispensable, sinon les accents et les caracteres de dessin # de l'interface Claude Code s'affichent en carres. sed -i 's/^# *fr_FR.UTF-8/fr_FR.UTF-8/' /etc/locale.gen locale-gen update-locale LANG=fr_FR.UTF-8 # Node.js 22 LTS curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt install -y nodejs # Claude Code npm install -g @anthropic-ai/claude-code claude --version ``` Configuration tmux minimale mais **obligatoire** pour le confort de l'interface web : ```bash cat > /root/.tmux.conf <<'EOF' set -g mouse on set -g set-clipboard on EOF ``` - `mouse on` : molette = defilement de l'historique, selection a la souris. - `set-clipboard on` : tmux emet des sequences OSC 52, que le frontend convertit en ecriture dans le presse-papier du navigateur. Sans cela, la copie depuis le terminal distant ne remonte pas jusqu'a la machine cliente. Authentification Claude : lancer `claude` une premiere fois en SSH et suivre le flux de connexion. Les identifiants sont stockes dans `/root/.claude/.credentials.json`. Notification de fin de commande (facultatif, utilise par l'etape 6) : ```bash cat >> /root/.bashrc <<'EOF' # Notification terminal : emet OSC 777 a chaque prompt (capte par le JS de ttyd) __ttyd_prompt_cmd() { local code=$? printf '\033]777;%d\007' "$code" } PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }__ttyd_prompt_cmd" EOF ``` --- ## 4. Etape 3 : installer ttyd Binaire statique, pas de compilation : ```bash curl -fL -o /usr/local/bin/ttyd \ https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 chmod +x /usr/local/bin/ttyd ttyd --version # doit afficher 1.7.7 ``` Verifier le nom exact de l'asset sur la page des releases si le telechargement echoue ; les noms de fichiers changent selon les versions. Rester en **1.7.7** : le frontend personnalise de l'etape 6 a ete corrige contre ce bundle xterm.js precis, une autre version peut deplacer les points d'accroche. Transfert de fichiers dans le navigateur (facultatif, active par `-t enableTrzsz=true`) : ```bash curl -fL https://github.com/trzsz/trzsz-go/releases/download/v1.1.8/trzsz_1.1.8_linux_x86_64.tar.gz \ | tar xz -C /tmp install -m755 /tmp/trzsz_1.1.8_linux_x86_64/tr[sz]* /usr/local/bin/ ``` --- ## 5. Etape 4 : session tmux persistante et service ttyd ### 5.1 Script de session ```bash cat > /usr/local/bin/claude-session.sh <<'EOF' #!/bin/bash # Maintient la session tmux "claude" toujours en vie tmux has-session -t claude 2>/dev/null || \ tmux new-session -d -s claude -c /root -x 220 -y 50 echo "[claude-session] Session tmux 'claude' active" while true; do if ! tmux has-session -t claude 2>/dev/null; then echo "[claude-session] Session perdue, recreation..." tmux new-session -d -s claude -c /root -x 220 -y 50 fi sleep 5 done EOF chmod +x /usr/local/bin/claude-session.sh ``` Les dimensions `-x 220 -y 50` ne sont qu'une valeur de depart : le premier client qui s'attache redimensionne la session. ### 5.2 Script de lancement ttyd ```bash cat > /usr/local/bin/claude-start.sh <<'EOF' #!/bin/bash export LANG=fr_FR.UTF-8 export LC_ALL=fr_FR.UTF-8 exec /usr/local/bin/ttyd --port 7681 --writable --index /etc/ttyd/index.html \ -t enableTrzsz=true tmux attach-session -t claude EOF chmod +x /usr/local/bin/claude-start.sh ``` Ne pas ajouter `--credential` ici : l'authentification est geree par le reverse proxy. Si le serveur Claude est expose autrement que derriere le proxy, alors il faut la remettre. ### 5.3 Unites systemd ```bash cat > /etc/systemd/system/claude-session.service <<'EOF' [Unit] Description=Claude Code - Session tmux persistante After=network-online.target Wants=network-online.target [Service] Type=simple User=root Environment=HOME=/root ExecStart=/usr/local/bin/claude-session.sh Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF cat > /etc/systemd/system/ttyd.service <<'EOF' [Unit] Description=Claude Code - Web Terminal (ttyd) After=claude-session.service Requires=claude-session.service [Service] Type=simple User=root Environment=HOME=/root ExecStart=/usr/local/bin/claude-start.sh Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now claude-session.service ttyd.service systemctl status ttyd.service --no-pager ``` Le couple `After=` + `Requires=` garantit l'ordre au demarrage. La separation en deux unites est **le point structurant** : `systemctl restart ttyd` ne touche pas au serveur tmux, donc la session Claude survit a toutes les manipulations du frontend. Test local avant d'aller plus loin : ```bash curl -sI http://127.0.0.1:7681/ | head -3 ``` --- ## 6. Etape 5 : frontend personnalise C'est ici que se trouve tout le travail d'ergonomie. Le frontend stock de ttyd est un terminal nu : pas de barre d'outils, pas de themes, pas de recherche, copier-coller approximatif. ### 6.1 Ce que le bloc personnalise apporte - barre d'outils fixe de 30 px : indicateur de connexion websocket, taille de police +/-, effacer, recherche, selecteur de theme (5 themes), boutons de split et de navigation tmux - taille de police et theme persistes en localStorage - recherche dans l'historique du buffer, `Ctrl+F`, `Entree` / `Maj+Entree` pour naviguer - menu contextuel au clic droit (copier, coller, rechercher, effacer, police) - `Ctrl+C` copie s'il y a une selection, sinon envoie SIGINT ; `Ctrl+V` colle - `Ctrl+molette` pour zoomer - OSC 52 : le presse-papier de tmux remonte dans celui du navigateur - OSC 777 : notification desktop quand une commande longue se termine, onglet en arriere-plan - les liens cliquables s'ouvrent dans un nouvel onglet au lieu de remplacer la page ### 6.2 Installation Recuperer d'abord le `index.html` stock que ttyd embarque, puis y injecter le bloc : ```bash mkdir -p /etc/ttyd # instance jetable sur un autre port, uniquement pour recuperer la page embarquee /usr/local/bin/ttyd --port 7699 tmux ls >/dev/null 2>&1 & P=$! sleep 1 curl -s http://127.0.0.1:7699/ > /etc/ttyd/index.stock.html kill $P wc -c /etc/ttyd/index.stock.html # 729693 octets attendus en 1.7.7 ``` Le service `ttyd` en production n'a pas besoin d'etre arrete : le port est different. Placer `ttyd-custom.html` (fourni avec ce document) dans `/root/`, puis : ```bash python3 - <<'EOF' stock = open('/etc/ttyd/index.stock.html', encoding='utf-8').read() custom = open('/root/ttyd-custom.html', encoding='utf-8').read() tail = '' assert stock.rstrip().endswith(tail), "structure du index.html stock inattendue" out = stock.rstrip()[:-len(tail)] + custom + tail + '\n' open('/etc/ttyd/index.html','w',encoding='utf-8').write(out) print(len(out), 'octets ecrits') EOF ``` Verifier la syntaxe avant de recharger la page (une erreur JS ici donne un terminal blanc) : ```bash python3 -c " s=open('/etc/ttyd/index.html',encoding='utf-8').read() js=s[s.rindex('')] open('/tmp/check.js','w',encoding='utf-8').write(js)" node --check /tmp/check.js && echo "JS OK" ``` **Aucun redemarrage n'est necessaire.** ttyd relit le fichier `--index` sur le disque a chaque requete HTTP et repond `cache-control: no-store` : un simple rechargement de page suffit, la session n'est jamais coupee. Toujours garder une sauvegarde avant modification : ```bash cp /etc/ttyd/index.html /etc/ttyd/index.html.bak-$(date +%Y%m%d) ``` ### 6.3 Contenu de `ttyd-custom.html` Si le fichier joint est absent, creer `/root/ttyd-custom.html` avec exactement ce contenu. Les sequences `\x02` (prefixe tmux Ctrl+B) et `\x1b` (ESC) sont des echappements JavaScript volontaires : ne pas les convertir en caracteres bruts. ```html
13
Ctrl+C=copier/inter.   Ctrl+V=coller   clic droit=menu
``` --- ## 7. Etape 6 : reverse proxy, domaine et TLS Caddy est le choix retenu : HTTPS automatique par Let's Encrypt, websocket natif sans configuration, fichier de conf de trois lignes. Nginx ou Traefik fonctionnent aussi, mais demandent une configuration websocket explicite (`Upgrade` / `Connection`), source classique de terminal qui ne se connecte pas. ### 7.1 Installer Caddy Sur une machine dediee (ex. `192.168.1.2`) ou sur le serveur Claude lui-meme : ```bash apt install -y debian-keyring debian-archive-keyring apt-transport-https curl curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | tee /etc/apt/sources.list.d/caddy-stable.list apt update && apt install -y caddy caddy version ``` ### 7.2 Generer le hash du mot de passe ```bash caddy hash-password # saisir le mot de passe deux fois, copier le hash bcrypt affiche ($2a$14$...) ``` Stocker le mot de passe en clair dans un gestionnaire de mots de passe : le hash bcrypt n'est pas reversible, un mot de passe perdu impose d'en regenerer un. ### 7.3 Caddyfile ``` claude.mondomaine.fr { basic_auth { claude $2a$14$REMPLACER_PAR_LE_HASH_GENERE } reverse_proxy 192.168.1.123:7681 } ``` ```bash caddy validate --config /etc/caddy/Caddyfile systemctl reload caddy ``` Notes : - `basic_auth` est la directive de Caddy >= 2.8. Sur une version anterieure, ecrire `basicauth`. - Le hash contient des `$`. Dans un Caddyfile ce n'est pas un probleme, mais si tu generes ce fichier depuis un script shell, protege-le des expansions (guillemets simples). - Les websockets passent seuls : ne surtout pas ajouter de `header_up Upgrade` manuel. - Si le proxy et le serveur Claude sont sur la meme machine, remplacer par `reverse_proxy 127.0.0.1:7681`. ### 7.4 DNS et ouverture de ports 1. Enregistrement DNS public : `claude.mondomaine.fr` -> IP publique (type A). Si l'IP est dynamique, utiliser un client DDNS. 2. Redirection sur le routeur : **80 -> proxy:80** et **443 -> proxy:443**. Rien d'autre. Le port 80 est requis pour la validation Let's Encrypt en HTTP-01. 3. **Ne jamais rediriger le port 7681.** Il donne un shell root sans authentification. 4. Si le reseau local resout deja `*.mondomaine.fr`, ajouter une entree interne `claude.mondomaine.fr -> 192.168.1.2` pour eviter de sortir sur internet. 5. Pare-feu du serveur Claude : n'autoriser 7681 que depuis l'IP du proxy. ```bash # sur le serveur claude-code, si ufw est utilise ufw allow from 192.168.1.2 to any port 7681 proto tcp ufw deny 7681 ``` Le premier acces en HTTPS declenche l'emission du certificat, ce qui prend quelques secondes. En cas d'echec, `journalctl -u caddy -f` donne la raison exacte (port 80 non joignable depuis l'exterieur dans 90 % des cas). --- ## 8. Verification Dans l'ordre, en s'arretant a la premiere etape qui echoue : ```bash # 1. ttyd repond en local curl -sI http://127.0.0.1:7681/ | head -1 # 200 # 2. la session tmux existe tmux ls # claude: 1 windows # 3. le proxy demande bien une authentification curl -sI https://claude.mondomaine.fr | head -1 # 401 # 4. avec les identifiants curl -sI -u claude:MOTDEPASSE https://claude.mondomaine.fr | head -1 # 200 ``` Puis dans un navigateur, controler point par point : - [ ] le terminal occupe toute la hauteur, la derniere ligne n'est pas coupee - [ ] la pastille de connexion en haut a gauche est verte - [ ] `Ctrl+V` colle **une seule fois** le contenu du presse-papier - [ ] selectionner du texte puis `Ctrl+C` affiche le toast "Copie" et le texte est recuperable dans une autre application - [ ] les boutons A+ / A- changent la taille **et** la grille se recalcule (le nombre de lignes visibles change) - [ ] le clic droit ouvre le menu contextuel - [ ] fermer l'onglet, le rouvrir : la session est retrouvee intacte - [ ] `systemctl restart ttyd` puis recharger : la session est toujours la --- ## 9. Pieges connus et bugs deja corriges Cette section est la raison d'etre du document. Ces defauts ont tous ete rencontres et diagnostiques en production ; les correctifs sont **deja integres** dans `ttyd-custom.html`. Elle sert a ne pas les reintroduire lors d'une modification ulterieure. ### 9.1 Bas du terminal tronque (deux causes cumulees) **Cause A : le padding du parent est compte comme espace disponible par le FitAddon.** Poser `#terminal-container{padding-top:30px}` pour degager la barre d'outils semble naturel. C'est structurellement faux. `proposeDimensions()` du FitAddon calcule : ```js r = parseInt(getComputedStyle(parentElement).getPropertyValue("height")) a = r - (padding-top + padding-bottom de .terminal) // le padding du PARENT n'est jamais retranche rows = floor(a / cell.height) ``` Il mesure donc 30 px de trop, soit environ deux lignes de trop a chaque fit. Regle : **ne jamais poser de padding, de bordure ou de decoration sur `#terminal-container`.** C'est l'element mesure par le FitAddon, il doit contenir exactement l'espace utilisable. Pour degager de la place, positionner le conteneur, ne pas le rembourrer : ```css #terminal-container{position:absolute !important;top:30px;left:0;right:0;bottom:0; height:auto !important;padding-top:0 !important;} ``` **Cause B : police changee apres l'initialisation, sans refit.** Le frontend applique la taille de police memorisee en localStorage alors que le fit initial a deja tourne avec la cellule d'origine, plus petite, donc avec trop de lignes. De plus, `term.fit()` **n'existe pas** sur un Terminal xterm.js : `fit()` appartient au FitAddon. L'appel est ignore silencieusement et les boutons A+ / A- ne recalculent jamais la grille. Regle : **tout changement de `term.options.fontSize` doit etre suivi de** ```js window.dispatchEvent(new Event('resize')); ``` C'est l'evenement que ttyd ecoute reellement (`g(window,"resize",()=>t.fit())` dans son bundle). Le meme appel est place en differe de 50 ms apres l'application de la police initiale, dans `waitForTerm()`. Mesures reelles avant / apres, fenetre de 980 px, police 14, dpr 1.25 : | | padding conteneur | rows | bas de .xterm-screen | |---|---|---|---| | avant, au chargement | 30 | 62 | 1027 (deborde de 69 px) | | apres, au chargement | 0 | 61 | 1011 | | apres, refit a +50 ms | 0 | 58 | **963, tient dans 980** | ### 9.2 Ctrl+V collait en double Le gestionnaire de touches faisait `readClip(t => term.paste(t)); return false;` sans `preventDefault()`. `return false` empeche seulement xterm.js de traiter la touche : le navigateur emettait quand meme son evenement `paste` natif vers le textarea cache, ou xterm l'ecoute (`handlePasteEvent` -> `getData("text/plain")` -> `paste`). Deux insertions. Correctif : `e.preventDefault()` dans les branches `Ctrl+V` et `Ctrl+Maj+V`. `return false` est conserve pour que xterm n'envoie pas `\x16` au PTY. Le seul chemin de collage restant est `readClip`. ```js if (e.code==='KeyV') { e.preventDefault(); readClip(function(t){ term.paste(t); }); return false; } ``` Meme defaut latent sur la branche `Ctrl+C`, laisse tel quel car sans consequence visible : la copie native et `writeClip` copient la meme selection. ### 9.3 Copier depuis le terminal distant ne remontait pas Il faut les deux moities : `set -g set-clipboard on` cote tmux (emission de l'OSC 52) et le handler `registerOscHandler(52, ...)` cote frontend, qui decode le base64 et appelle `navigator.clipboard.writeText`. L'un sans l'autre ne donne rien. `navigator.clipboard` n'existe qu'en contexte securise : cela fonctionne en HTTPS ou sur `localhost`, **jamais en HTTP simple sur une IP**. C'est une raison de plus de toujours passer par le proxy TLS, meme pour tester. Un repli `document.execCommand('copy')` est present pour les navigateurs anciens. ### 9.4 Les liens ouvraient une page blanche Le WebLinksAddon appelle `window.open('')` puis assigne `location.href`, comportement que les navigateurs recents bloquent ou traitent mal. Le bloc personnalise remplace `window.open` : quand l'URL est vide, il retourne un objet dont le setter `href` cree un `` et le clique. Ne pas retirer ce patch. ### 9.5 Pieges d'exploitation - **Ne pas mettre l'authentification a deux endroits.** ttyd `--credential` **et** basic_auth Caddy donnent une double invite. Une seule, au niveau du proxy. - **Modifier `index.html` ne demande pas de redemarrage.** ttyd relit le fichier a chaque requete. Redemarrer ttyd est neanmoins sans danger pour la session (elle appartient a l'autre unite systemd). - **Une erreur de syntaxe JS donne un terminal blanc et muet.** Toujours passer `node --check` avant de recharger, et garder la sauvegarde datee. - **Ne pas suivre les mises a jour de ttyd sans verifier.** Le bloc personnalise s'accroche a des details du bundle xterm.js de la 1.7.7 (`window.term`, l'ecoute de `resize`, `#terminal-container`). Apres une montee de version, rejouer toute la checklist du 8. - **Methode de debug du frontend, sans rien installer.** ttyd journalise le **chemin** de chaque requete HTTP. Il suffit d'encoder les mesures dans un chemin bidon : ttyd repond 404 et la valeur atterrit dans le journal, lisible cote serveur. ```js fetch('/diag/win'+innerHeight+'/rows'+term.rows+'/sBot'+rect.bottom); ``` ```bash journalctl -u ttyd -f | grep diag ``` Aucun endpoint a creer, aucun port a ouvrir, et le retour est lisible directement au lieu d'etre dicte par l'utilisateur. Penser a retirer l'instrumentation ensuite. - **Lecon de methode.** Sur le bug du bas tronque, la premiere hypothese, purement CSS, etait fausse et n'a rien corrige. Le signal d'alerte a ete une prediction contredite par l'observation. Sur ce frontend, mesurer avant de conclure coute moins cher que corriger au jugé. --- ## 10. Options utiles **Redemarrage propre de la session Claude** sans toucher aux services : ```bash tmux send-keys -t claude C-c tmux send-keys -t claude 'claude' Enter ``` **Editeur web** sur le meme serveur, pratique pour editer les fichiers de config : ```bash curl -fsSL https://code-server.dev/install.sh | sh systemctl enable --now code-server@root # port 8080 ``` Ajouter un bloc Caddy dedie, avec la meme protection : ``` code.mondomaine.fr { basic_auth { claude $2a$14$LE_HASH } reverse_proxy 192.168.1.123:8080 } ``` **Sauvegarde de la configuration** : versionner `/etc/ttyd/index.html`, les unites systemd et les scripts `/usr/local/bin/claude-*.sh` dans un depot git prive. C'est ce qui permet de rejouer l'installation ou de revenir en arriere apres une modification ratee du frontend. Attention en versionnant : filtrer tout secret avant le commit. Un fichier d'environnement pousse par erreur reste dans l'historique meme apres correction, et la seule remediation reelle est de **regenerer les identifiants concernes**. --- ## 11. Rappel de securite `claude.mondomaine.fr` donne un **shell root persistant** sur le serveur, expose sur internet. Le niveau de protection doit etre a la hauteur : - mot de passe long et unique, stocke dans un gestionnaire de mots de passe - port 7681 jamais redirige, et filtre au pare-feu sur l'IP du proxy - HTTPS obligatoire, jamais d'acces en HTTP simple - evolution recommandee : remplacer basic_auth par une authentification SSO avec second facteur (forward_auth vers Authentik ou Authelia), ou restreindre l'acces a un VPN - si un service d'upload de fichiers est ajoute a cote, verifier qu'il ne tourne pas avec un mot de passe par defaut et qu'il n'ecoute pas sur `0.0.0.0` sans authentification