Files
Claude Code Bot 830cb5d679 Mode operatoire : session Claude Code persistante dans un navigateur
Stack ttyd + tmux + Caddy, reproduction d'une installation en production.
Inclut le frontend personnalise et les correctifs deja valides (FitAddon,
refit apres changement de police, double collage Ctrl+V, OSC 52, window.open).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:28:02 +00:00

38 KiB
Raw Permalink Blame History

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) :

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

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 :

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) :

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 :

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) :

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

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

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

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 :

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 :

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 :

python3 - <<'EOF'
stock  = open('/etc/ttyd/index.stock.html', encoding='utf-8').read()
custom = open('/root/ttyd-custom.html', encoding='utf-8').read()
tail = '</body></html>'
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) :

python3 -c "
s=open('/etc/ttyd/index.html',encoding='utf-8').read()
js=s[s.rindex('<script>')+8:s.rindex('</script>')]
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 :

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.

<style>
#ttyd-bar{position:fixed;top:0;left:0;right:0;height:30px;background:#111;display:flex;align-items:center;padding:0 6px;gap:4px;z-index:8000;border-bottom:1px solid #2a2a2a;}
#ttyd-bar button{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 7px;cursor:pointer;font:11px monospace;white-space:nowrap;}
#ttyd-bar button:hover{background:#333;color:#fff;}
#ttyd-bar select{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 4px;font:11px monospace;cursor:pointer;}
#ttyd-conn{width:8px;height:8px;border-radius:50%;background:#555;margin:0 4px;flex-shrink:0;}
#ttyd-conn.ok{background:#4caf50;}
#ttyd-conn.err{background:#f44336;animation:blink 1s infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
#ttyd-spacer{flex:1;}
#ttyd-search-bar{position:fixed;top:30px;right:0;background:#1a1a1a;border:1px solid #333;border-top:none;border-right:none;border-radius:0 0 0 6px;display:flex;align-items:center;gap:4px;padding:4px 8px;z-index:8001;display:none;}
#ttyd-search-bar input{background:#111;color:#ddd;border:1px solid #333;border-radius:3px;padding:2px 6px;font:12px monospace;width:200px;outline:none;}
#ttyd-search-bar span{color:#666;font:11px monospace;min-width:60px;}
#ttyd-search-bar button{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 7px;cursor:pointer;font:11px monospace;}
#terminal-container{position:absolute !important;top:30px;left:0;right:0;bottom:0;height:auto !important;padding-top:0 !important;}
#ttyd-ctx{position:fixed;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px 0;z-index:9999;min-width:150px;box-shadow:0 4px 12px rgba(0,0,0,.5);display:none;}
#ttyd-ctx div{padding:5px 14px;cursor:pointer;color:#ccc;font:12px monospace;}
#ttyd-ctx div:hover{background:#2d2d2d;color:#fff;}
#ttyd-ctx hr{border:none;border-top:1px solid #333;margin:3px 0;}
#ttyd-toast{position:fixed;bottom:8px;right:8px;background:rgba(20,20,20,.93);color:#7fff7f;font:12px monospace;padding:4px 10px;border-radius:4px;z-index:9999;pointer-events:none;opacity:0;transition:opacity .2s;}
#ttyd-toast.show{opacity:1;}
</style>
<div id="ttyd-bar">
  <div id="ttyd-conn" title="Connexion WebSocket"></div>
  <button onclick="ttydFontSize(-1)" title="Police -">A</button>
  <span id="ttyd-fsize" style="color:#555;font:11px monospace;min-width:28px;text-align:center;">13</span>
  <button onclick="ttydFontSize(+1)" title="Police +">A+</button>
  <button onclick="ttydClear()" title="Vider">✕ Clear</button>
  <button onclick="ttydSearch()" title="Rechercher (Ctrl+F)">⌕ Rech.</button>
  <select id="ttyd-theme-sel" onchange="ttydSetTheme(this.value)" title="Thème"></select>
  <button onclick="ttydSplit('h')" title="Split vertical (côte à côte)">⬜ ⬜</button>
  <button onclick="ttydSplit('v')" title="Split horizontal (haut/bas)"><br style='line-height:3px'></button>
  <button onclick="ttydPaneClose()" title="Fermer ce panneau">✕ Pan.</button>
  <button onclick="ttydPaneNav('Left')"  title="Panneau gauche"></button>
  <button onclick="ttydPaneNav('Right')" title="Panneau droite"></button>
  <button onclick="ttydPaneNav('Up')"    title="Panneau haut"></button>
  <button onclick="ttydPaneNav('Down')"  title="Panneau bas"></button>
  <div id="ttyd-spacer"></div>
  <span style="color:#333;font:10px monospace;">Ctrl+C=copier/inter. &nbsp; Ctrl+V=coller &nbsp; clic droit=menu</span>
</div>
<div id="ttyd-search-bar">
  <input id="ttyd-sq" placeholder="Rechercher…" oninput="ttydDoSearch()" onkeydown="ttydSearchKey(event)">
  <span id="ttyd-sr"></span>
  <button onclick="ttydSearchNav(-1)"></button>
  <button onclick="ttydSearchNav(+1)"></button>
  <button onclick="ttydSearchClose()"></button>
</div>
<div id="ttyd-ctx"></div>
<div id="ttyd-toast"></div>
<script>
(function(){
var _fontSize = parseInt(localStorage.getItem('ttyd-font') || '13');
var _themes = {"Defaut": {"background": "#2b2b2b", "foreground": "#d2d2d2", "cursor": "#adadad", "black": "#000000", "red": "#d81e00", "green": "#5ea702", "yellow": "#cfae00", "blue": "#427ab3", "magenta": "#89658e", "cyan": "#00a7aa", "white": "#dbded8", "brightBlack": "#686a66", "brightRed": "#f54235", "brightGreen": "#99e343", "brightYellow": "#fdeb61", "brightBlue": "#84b0d8", "brightMagenta": "#bc94b7", "brightCyan": "#37e6e8", "brightWhite": "#f1f1f0"}, "Dracula": {"background": "#282a36", "foreground": "#f8f8f2", "cursor": "#f8f8f2", "black": "#21222c", "red": "#ff5555", "green": "#50fa7b", "yellow": "#f1fa8c", "blue": "#bd93f9", "magenta": "#ff79c6", "cyan": "#8be9fd", "white": "#f8f8f2", "brightBlack": "#6272a4", "brightRed": "#ff6e6e", "brightGreen": "#69ff94", "brightYellow": "#ffffa5", "brightBlue": "#d6acff", "brightMagenta": "#ff92df", "brightCyan": "#a4ffff", "brightWhite": "#ffffff"}, "One Dark": {"background": "#282c34", "foreground": "#abb2bf", "cursor": "#528bff", "black": "#3f4451", "red": "#e06c75", "green": "#98c379", "yellow": "#e5c07b", "blue": "#61afef", "magenta": "#c678dd", "cyan": "#56b6c2", "white": "#abb2bf", "brightBlack": "#4f5666", "brightRed": "#ff616e", "brightGreen": "#a5e075", "brightYellow": "#f0a45d", "brightBlue": "#4dc4ff", "brightMagenta": "#de73ff", "brightCyan": "#4cd1e0", "brightWhite": "#ffffff"}, "Solarized": {"background": "#002b36", "foreground": "#839496", "cursor": "#839496", "black": "#073642", "red": "#dc322f", "green": "#859900", "yellow": "#b58900", "blue": "#268bd2", "magenta": "#d33682", "cyan": "#2aa198", "white": "#eee8d5", "brightBlack": "#002b36", "brightRed": "#cb4b16", "brightGreen": "#586e75", "brightYellow": "#657b83", "brightBlue": "#839496", "brightMagenta": "#6c71c4", "brightCyan": "#93a1a1", "brightWhite": "#fdf6e3"}, "Clair": {"background": "#ffffff", "foreground": "#383a42", "cursor": "#526fff", "black": "#383a42", "red": "#e45649", "green": "#50a14f", "yellow": "#c18401", "blue": "#4078f2", "magenta": "#a626a4", "cyan": "#0184bc", "white": "#fafafa", "brightBlack": "#4f525e", "brightRed": "#e06c75", "brightGreen": "#98c379", "brightYellow": "#e5c07b", "brightBlue": "#61afef", "brightMagenta": "#c678dd", "brightCyan": "#56b6c2", "brightWhite": "#ffffff"}};
var _themeName = localStorage.getItem('ttyd-theme') || 'Defaut';
var _searchMatches = [], _searchIdx = 0;
var _notifPerm = false;
var _lastCmdStart = 0;

/* ---- Toast ---- */
function toast(msg, dur) {
  var t = document.getElementById('ttyd-toast');
  t.textContent = msg; t.classList.add('show');
  clearTimeout(t._tid);
  t._tid = setTimeout(function(){ t.classList.remove('show'); }, dur||1800);
}

/* ---- Clipboard ---- */
function writeClip(text) {
  if (!text) return;
  var ok = function(){ toast('Copié : ' + text.slice(0,40).replace(/\n/g,' ') + (text.length>40?'..':'')); };
  if (navigator.clipboard && navigator.clipboard.writeText)
    navigator.clipboard.writeText(text).then(ok).catch(function(){ legacyCopy(text); });
  else legacyCopy(text);
}
function legacyCopy(text) {
  var ta = document.createElement('textarea');
  ta.value = text; ta.style.cssText='position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;';
  document.body.appendChild(ta); ta.focus(); ta.select();
  var ok=false; try{ ok=document.execCommand('copy'); }catch(e){}
  document.body.removeChild(ta);
  toast(ok ? 'Copié (legacy)' : 'Erreur copie');
}
function readClip(cb) {
  if (navigator.clipboard && navigator.clipboard.readText)
    navigator.clipboard.readText().then(cb).catch(function(){ toast('Accès presse-papier refusé'); });
  else toast('API clipboard non disponible');
}

/* ---- Police ---- */
window.ttydFontSize = function(d) {
  _fontSize = Math.max(8, Math.min(28, _fontSize + d));
  localStorage.setItem('ttyd-font', _fontSize);
  if (window.term) { window.term.options.fontSize = _fontSize; window.dispatchEvent(new Event('resize')); }
  document.getElementById('ttyd-fsize').textContent = _fontSize;
};

/* ---- Clear ---- */
window.ttydClear = function() { if (window.term) window.term.clear(); };

/* ---- Tmux split & navigation ---- */
function sendRaw(data) {
  // Envoie données brutes au PTY sans bracketed paste
  var term = window.term;
  if (!term) return;
  if (term._core && term._core.coreService)
    term._core.coreService.triggerDataEvent(data, true);
  else
    term.paste(data);
}
window.ttydSplit = function(dir) {
  // Ctrl+B (\x02) + % pour vertical, + " pour horizontal
  sendRaw('\x02' + (dir === 'h' ? '%' : '"'));
};
window.ttydPaneClose = function() {
  // Ctrl+B + x, puis y pour confirmer
  sendRaw('\x02x');
  setTimeout(function(){ sendRaw('y'); }, 200);
};
window.ttydPaneNav = function(dir) {
  var arrows = {Left:'\x1b[D', Right:'\x1b[C', Up:'\x1b[A', Down:'\x1b[B'};
  if (arrows[dir]) sendRaw('\x02' + arrows[dir]);
};

/* ---- Thèmes ---- */
var sel = document.getElementById('ttyd-theme-sel');
Object.keys(_themes).forEach(function(n){
  var o = document.createElement('option'); o.value=n; o.textContent=n;
  if(n===_themeName) o.selected=true;
  sel.appendChild(o);
});
window.ttydSetTheme = function(name) {
  if (!_themes[name] || !window.term) return;
  window.term.options.theme = _themes[name];
  _themeName = name;
  localStorage.setItem('ttyd-theme', name);
  toast('Thème : ' + name);
};

/* ---- Recherche ---- */
var _searchOpen = false;
window.ttydSearch = function() {
  var bar = document.getElementById('ttyd-search-bar');
  _searchOpen = !_searchOpen;
  bar.style.display = _searchOpen ? 'flex' : 'none';
  if (_searchOpen) setTimeout(function(){ document.getElementById('ttyd-sq').focus(); }, 50);
};
window.ttydSearchClose = function() {
  _searchOpen = false;
  document.getElementById('ttyd-search-bar').style.display = 'none';
  _searchMatches = []; document.getElementById('ttyd-sr').textContent = '—';
};
window.ttydDoSearch = function() {
  var q = document.getElementById('ttyd-sq').value;
  var sr = document.getElementById('ttyd-sr');
  if (!q || !window.term) { _searchMatches=[]; sr.textContent='—'; return; }
  var buf = window.term.buffer.active;
  var matches = [];
  var total = buf.length;
  for (var i = 0; i < total; i++) {
    var line = buf.getLine(i);
    if (line && line.translateToString().indexOf(q) >= 0) matches.push(i);
  }
  _searchMatches = matches; _searchIdx = matches.length - 1;
  sr.textContent = matches.length ? ((_searchIdx+1)+'/'+matches.length) : '0 résultat';
  if (matches.length) window.term.scrollToLine(matches[_searchIdx]);
};
window.ttydSearchNav = function(dir) {
  if (!_searchMatches.length) return;
  _searchIdx = (_searchIdx + dir + _searchMatches.length) % _searchMatches.length;
  document.getElementById('ttyd-sr').textContent = (_searchIdx+1)+'/'+_searchMatches.length;
  window.term.scrollToLine(_searchMatches[_searchIdx]);
};
window.ttydSearchKey = function(e) {
  if (e.key === 'Enter') ttydSearchNav(e.shiftKey ? -1 : 1);
  if (e.key === 'Escape') ttydSearchClose();
};

/* ---- Connexion ---- */
var _connEl = document.getElementById('ttyd-conn');
function checkConn() {
  fetch('/token', {cache:'no-store'})
    .then(function(r){ _connEl.className = r.ok ? 'ok' : 'err'; })
    .catch(function(){ _connEl.className = 'err'; });
}
checkConn();
setInterval(checkConn, 5000);

/* ---- Patch window.open (liens WebLinksAddon) ---- */
var _open = window.open;
window.open = function(url, target, features) {
  if (url === undefined || url === null || url === '') {
    var loc = {};
    Object.defineProperty(loc,'href',{set:function(u){
      var a=document.createElement('a'); a.href=u; a.target='_blank'; a.rel='noopener noreferrer';
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
    }});
    return {opener:null, location:loc};
  }
  return _open.apply(this, arguments);
};

/* ---- Menu contextuel ---- */
var _ctx = document.getElementById('ttyd-ctx');
function hideCtx(){ _ctx.style.display='none'; }
document.addEventListener('click', hideCtx);
document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ hideCtx(); ttydSearchClose(); }});
function showCtx(x,y) {
  hideCtx();
  _ctx.innerHTML = '';
  var items = [
    ['Copier', function(){ if(window.term){var s=window.term.getSelection();if(s)writeClip(s);} }],
    ['Coller', function(){ if(window.term) readClip(function(t){window.term.paste(t);}); }],
    null,
    ['Rechercher…', ttydSearch],
    null,
    ['Effacer', ttydClear],
    ['Police +', function(){ttydFontSize(+1);}],
    ['Police ', function(){ttydFontSize(-1);}],
  ];
  items.forEach(function(item){
    if(!item){var hr=document.createElement('hr');_ctx.appendChild(hr);return;}
    var d=document.createElement('div'); d.textContent=item[0];
    d.addEventListener('click',function(e){e.stopPropagation();hideCtx();item[1]();});
    _ctx.appendChild(d);
  });
  _ctx.style.cssText='display:block;left:'+x+'px;top:'+y+'px;';
  var r=_ctx.getBoundingClientRect();
  if(r.right>window.innerWidth) _ctx.style.left=(x-r.width)+'px';
  if(r.bottom>window.innerHeight) _ctx.style.top=(y-r.height)+'px';
}

/* ---- Notifications ---- */
function requestNotifPerm() {
  if ('Notification' in window && Notification.permission === 'default')
    Notification.requestPermission().then(function(p){ _notifPerm = p==='granted'; });
  else _notifPerm = ('Notification' in window && Notification.permission === 'granted');
}
function desktopNotif(title, body) {
  if (_notifPerm && 'Notification' in window)
    new Notification(title, {body:body, icon:''});
}
requestNotifPerm();

/* ---- Initialisation terminal ---- */
function waitForTerm() {
  var term = window.term;
  if (!term || !term.parser) { setTimeout(waitForTerm, 100); return; }

  /* Police initiale */
  term.options.fontSize = _fontSize;
  document.getElementById('ttyd-fsize').textContent = _fontSize;

  /* Thème initial */
  if (_themes[_themeName]) term.options.theme = _themes[_themeName];

  /* Refit : le fit initial a ete calcule avec l'ancienne taille de cellule */
  setTimeout(function(){ window.dispatchEvent(new Event('resize')); }, 50);

  /* OSC 52 : tmux set-clipboard -> presse-papier */
  try {
    term.parser.registerOscHandler(52, function(data) {
      var parts = data.split(';');
      if (parts.length>=2 && parts[1] && parts[1]!=='?') {
        try { writeClip(atob(parts[1])); } catch(e){}
      }
      return true;
    });
  } catch(e){}

  /* OSC 777 : PROMPT_COMMAND -> notification fin de commande */
  try {
    term.parser.registerOscHandler(777, function(data) {
      var code = parseInt(data) || 0;
      var elapsed = Date.now() - _lastCmdStart;
      /* Notifier si onglet caché ET commande > 5s */
      if (document.hidden && elapsed > 5000) {
        var msg = code === 0 ? 'Commande terminée (succès)' : 'Commande terminée (code '+code+')';
        desktopNotif('Terminal', msg);
      }
      _lastCmdStart = Date.now();
      return true;
    });
  } catch(e){}

  /* Ctrl+C = copie si sélection, sinon SIGINT */
  /* Ctrl+V = colle */
  /* Ctrl+F = recherche */
  term.attachCustomKeyEventHandler(function(e) {
    if (e.type !== 'keydown') return true;
    if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
      if (e.code==='KeyC') {
        var s = term.getSelection ? term.getSelection() : '';
        if (s) { writeClip(s); return false; }
        return true;
      }
      if (e.code==='KeyV') {
        e.preventDefault();
        readClip(function(t){ term.paste(t); }); return false;
      }
      if (e.code==='KeyF') { ttydSearch(); return false; }
    }
    if (e.ctrlKey && e.shiftKey) {
      if (e.code==='KeyC') { var s=term.getSelection?term.getSelection():''; if(s) writeClip(s); return false; }
      if (e.code==='KeyV') { e.preventDefault(); readClip(function(t){ term.paste(t); }); return false; }
    }
    return true;
  });

  /* Clic droit -> menu contextuel */
  term.element.addEventListener('contextmenu', function(e){
    e.preventDefault(); showCtx(e.clientX, e.clientY);
  });

  /* Zoom souris */
  term.element.addEventListener('wheel', function(e){
    if (!e.ctrlKey) return;
    e.preventDefault();
    ttydFontSize(e.deltaY < 0 ? 1 : -1);
  }, {passive:false});
}
waitForTerm();


})();
</script>

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 :

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

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
}
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.
# 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 :

# 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 :

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 :

#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

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.

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 <a target="_blank" rel="noopener noreferrer"> 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.

    fetch('/diag/win'+innerHeight+'/rows'+term.rows+'/sBot'+rect.bottom);
    
    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 :

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 :

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