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>
38 KiB
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+Entreepour naviguer - menu contextuel au clic droit (copier, coller, rechercher, effacer, police)
Ctrl+Ccopie s'il y a une selection, sinon envoie SIGINT ;Ctrl+VcolleCtrl+molettepour 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. Ctrl+V=coller 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_authest la directive de Caddy >= 2.8. Sur une version anterieure, ecrirebasicauth.- 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 Upgrademanuel. - 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
- Enregistrement DNS public :
claude.mondomaine.fr-> IP publique (type A). Si l'IP est dynamique, utiliser un client DDNS. - 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.
- Ne jamais rediriger le port 7681. Il donne un shell root sans authentification.
- Si le reseau local resout deja
*.mondomaine.fr, ajouter une entree interneclaude.mondomaine.fr -> 192.168.1.2pour eviter de sortir sur internet. - 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+Vcolle une seule fois le contenu du presse-papier- selectionner du texte puis
Ctrl+Caffiche 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 ttydpuis 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
--credentialet basic_auth Caddy donnent une double invite. Une seule, au niveau du proxy. -
Modifier
index.htmlne 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 --checkavant 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 deresize,#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 diagAucun 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.0sans authentification