<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr" xml:base="https://vincent.bernat.ch">
  <title>Vincent Bernat — Web</title>
  <link href="https://vincent.bernat.ch/fr/blog/atom-web.xml" rel="self"/>
  <link href="https://vincent.bernat.ch/fr/blog#tag-web" rel="alternate"/>
  <id>http://www.luffy.cx/fr/blog/atom-web.xml/</id>
  <updated>2026-10-25T15:08:12Z</updated>

  <entry>
    <title type="html">Tunnels HTTP auto-hébergés avec SSH et nginx</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-http-over-ssh" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-http-over-ssh#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-10-03T07:23:12Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-http-over-ssh.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>Un ami veut relire votre article de blog en cours de rédaction, mais sa
prévisualisation ne tourne que sur <code>localhost:8080</code>. <a href="https://github.com/anderspitman/awesome-tunneling" title="awesome-tunneling: a list of tunneling software and services">Plusieurs outils</a> peuvent aider. Certains sont des services commerciaux, comme <a href="https://ngrok.com/" title="ngrok">ngrok</a> ou
<a href="https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/" title="Quick Tunnels with TryCloudflare">Cloudflare Quick Tunnels</a>. D’autres peuvent être auto-hébergés, mais exigent
un client dédié, comme <a href="https://github.com/fatedier/frp" title="frp: a fast reverse proxy">frp</a> ou <a href="https://github.com/localtunnel/localtunnel" title="localtunnel: expose your localhost to the world">localtunnel</a>. D’autres encore se contentent
d’un simple client SSH, mais reposent sur un serveur SSH spécifique, comme
<a href="https://docs.ssi.sh/" title="sish: host public web services on localhost">sish</a>. Voyons comment mettre en place une solution auto-hébergée avec juste
<em>OpenSSH</em> et <em>nginx</em> !</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>ssh<span class="w"> </span>-R<span class="w"> </span><span class="m">0</span>:localhost:8080<span class="w"> </span>http-over-ssh
<span class="go">Allocated port 41535 for remote forward to localhost:8080</span>
<span class="go">https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/</span>
</code></pre></div>

<h1 id="configuration-de-base">Configuration de base</h1>
<p>Tout d’abord, nous redirigeons vers votre service local les connexions reçues
sur un port d’un serveur distant :</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>ssh<span class="w"> </span>-N<span class="w"> </span>-R<span class="w"> </span><span class="m">0</span>:localhost:8080<span class="w"> </span>web02.luffy.cx
<span class="go">Allocated port 41535 for remote forward to localhost:8080</span>
</code></pre></div>

<p>Lorsque nous indiquons <code>0</code> comme port distant, le serveur alloue un port libre.
Ensuite, nous configurons nginx pour relayer les requêtes de
<code>https://p41535.ssh.luffy.cx</code> vers <code>http://127.0.0.1:41535</code> :</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="n">0.0.0.0</span><span class="p">:</span><span class="mi">443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="s">[::0]:443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">server_name</span><span class="w"> </span><span class="p">~</span><span class="sr">^p(?&lt;port&gt;\d\d\d\d\d)\.ssh\.luffy\.cx$;</span>
<span class="w">  </span><span class="s">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">proxy_pass</span><span class="w"> </span><span class="s">http://127.0.0.1:</span><span class="nv">$port</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Il faut aussi ajouter des enregistrements DNS pour <code>*.ssh.luffy.cx</code> et obtenir un
certificat générique auprès de <a href="https://letsencrypt.org/">Let’s Encrypt</a> :</p>
<div class="language-zone codehilite"><pre><span/><code><span class="n">*.ssh.luffy.cx.</span><span class="w">               </span><span class="kt">CNAME</span><span class="w"> </span><span class="l">web02.luffy.cx.</span>
<span class="n">ssh.luffy.cx.</span><span class="w">                 </span><span class="kt">CAA</span><span class="w">   </span><span class="mi">0</span><span class="w"> </span><span class="l">issuewild</span><span class="w"> </span><span class="s">"letsencrypt.org"</span>
<span class="n">_acme-challenge.ssh.luffy.cx</span><span class="w">  </span><span class="kt">CNAME</span><span class="w"> </span><span class="l">ssh.luffy.cx.acme.luffy.cx.</span>
</code></pre></div>

<p><code>acme.luffy.cx</code> est une zone hébergée sur Route 53. Je l’utilise pour les
<a href="https://letsencrypt.org/docs/challenge-types/#dns-01-challenge" title="DNS-01 challenge">défis ACME DNS-01</a>, aussi bien pour les certificats génériques que pour
les domaines servis par plusieurs serveurs web. Dans mon cas, NixOS <a href="https://wiki.nixos.org/wiki/ACME" title="ACME on the NixOS Wiki">obtient les
certificats automatiquement</a>.</p>
<h1 id="controle-dacces">Contrôle d’accès</h1>
<p>Le port est le seul « secret<sup id="fnref:port"><a class="footnote-ref" href="#fn:port">1</a></sup> » qui protège la confidentialité du contenu.
D’autres solutions de redirection ajoutent une chaîne aléatoire au nom de
domaine pour empêcher un intrus d’énumérer les valeurs possibles.</p>
<p>Grâce au module <a href="https://nginx.org/en/docs/http/ngx_http_secure_link_module.html" title="Module ngx_http_secure_link_module for nginx"><code>ngx_http_secure_link_module</code></a>, nous
pouvons renforcer un peu la sécurité de cette configuration. Ce module calcule
une empreinte<sup id="fnref:md5"><a class="footnote-ref" href="#fn:md5">2</a></sup> sur un ensemble de valeurs, dont un secret, et la compare à
celle fournie dans la requête. L’empreinte est encodée en base64 : impossible
donc de la placer dans le nom de domaine, qui est insensible à la casse. Nous
l’insérons plutôt dans l’URL, comme nom d’utilisateur, avec sa date
d’expiration<sup id="fnref:expiration"><a class="footnote-ref" href="#fn:expiration">3</a></sup> :</p>
<div class="language-text-only codehilite"><pre><span/><code>https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/fr/blog
        ╰─────────┬──────────╯  ╰───┬────╯  ╰─┬─╯             ╰──┬───╯
              empreinte        expiration   port              chemin
</code></pre></div>

<p>Le client envoie le nom d’utilisateur au serveur avec l’<a href="https://www.rfc-editor.org/rfc/rfc7617" title="RFC 7617: The 'Basic' HTTP Authentication Scheme">authentification HTTP
basique</a>. Cela fonctionne avec la plupart des clients HTTP, dont <code>curl</code>.
Nginx expose le nom d’utilisateur dans la variable <code>$remote_user</code>. Le module
attend l’empreinte et la date d’expiration séparées par une virgule. À
l’aide d’une directive <code>map</code>, nous extrayons les deux parties de <code>$remote_user</code>
et les formatons comme attendu<sup id="fnref:comma"><a class="footnote-ref" href="#fn:comma">4</a></sup>. Nous fournissons aussi au module la
chaîne à hacher. Elle contient la date d’expiration, le port et un secret :</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">map</span><span class="w"> </span><span class="nv">$remote_user</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">"~^([-_A-Za-z0-9]{22})--([0-9]+)$"</span><span class="w"> </span><span class="s">"</span><span class="nv">$1,$2"</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="c1"># […]</span>
<span class="w">  </span><span class="kn">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">secure_link</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="p">;</span>
<span class="w">    </span><span class="kn">secure_link_md5</span><span class="w"> </span><span class="s">"</span><span class="nv">$secure_link_expires</span><span class="w"> </span><span class="nv">$port</span><span class="w"> </span><span class="s">ZuPerS3cr3!"</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Le module renvoie le résultat de la vérification dans la variable
<code>$secure_link</code> :</p>
<ul>
<li>vide si les empreintes ne correspondent pas ;</li>
<li><code>"0"</code> si elles correspondent, mais que le lien a expiré ;</li>
<li><code>"1"</code> sinon.</li>
</ul>
<p>Si l’empreinte est incorrecte ou absente, nous renvoyons une erreur 401
avec un en-tête <code>WWW-Authenticate</code> pour réclamer des identifiants. Si le lien a
expiré, nous renvoyons une erreur 410. Nous retirons l’en-tête <code>Authorization</code>
avant de transmettre la requête et ajoutons quelques directives pour <a href="https://nginx.org/en/docs/http/websocket.html" title="nginx documentation: WebSocket proxying">relayer
les connexions WebSocket</a>. Voici la configuration
complète<sup id="fnref:security"><a class="footnote-ref" href="#fn:security">5</a></sup> :</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">map</span><span class="w"> </span><span class="nv">$remote_user</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">"~^([-_A-Za-z0-9]{22})--([0-9]+)$"</span><span class="w"> </span><span class="s">"</span><span class="nv">$1,$2"</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="n">0.0.0.0</span><span class="p">:</span><span class="mi">443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="s">[::0]:443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">server_name</span><span class="w"> </span><span class="p">~</span><span class="sr">^p(?&lt;port&gt;\d\d\d\d\d)\.ssh\.luffy\.cx$;</span>
<span class="w">  </span><span class="s">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">secure_link</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="p">;</span>
<span class="w">    </span><span class="kn">secure_link_md5</span><span class="w"> </span><span class="s">"</span><span class="nv">$secure_link_expires</span><span class="w"> </span><span class="nv">$port</span><span class="w"> </span><span class="s">ZuPerS3cr3!"</span><span class="p">;</span>
<span class="w">    </span><span class="kn">if</span><span class="w"> </span><span class="s">(</span><span class="nv">$secure_link</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"")</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="kn">add_header</span><span class="w"> </span><span class="s">WWW-Authenticate</span><span class="w"> </span><span class="s">'Basic</span><span class="w"> </span><span class="s">realm="tunnel"'</span><span class="w"> </span><span class="s">always</span><span class="p">;</span>
<span class="w">      </span><span class="kn">return</span><span class="w"> </span><span class="mi">401</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="kn">if</span><span class="w"> </span><span class="s">(</span><span class="nv">$secure_link</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"0")</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="kn">return</span><span class="w"> </span><span class="mi">410</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="kn">proxy_pass</span><span class="w"> </span><span class="s">http://127.0.0.1:</span><span class="nv">$port</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Host</span><span class="w"> </span><span class="nv">$host</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Forwarded-For</span><span class="w"> </span><span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Authorization</span><span class="w"> </span><span class="s">""</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_http_version</span><span class="w"> </span><span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Upgrade</span><span class="w"> </span><span class="nv">$http_upgrade</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Connection</span><span class="w"> </span><span class="s">"upgrade"</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_buffering</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_read_timeout</span><span class="w"> </span><span class="mi">30m</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Je pense que vous vous posez maintenant la question évidente : « Comment générer
l’empreinte ? » Un jeu d’enfant !</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span><span class="nv">expires</span><span class="o">=</span><span class="k">$((</span><span class="w"> </span><span class="k">$(</span>date<span class="w"> </span>+%s<span class="k">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="m">86400</span><span class="w"> </span><span class="k">))</span>
<span class="gp">$ </span><span class="nv">port</span><span class="o">=</span><span class="m">41535</span>
<span class="gp">$ </span><span class="nv">secret</span><span class="o">=</span><span class="s1">'ZuPerS3cr3!'</span>
<span class="gp">$ </span><span class="nb">printf</span><span class="w"> </span><span class="s1">'%s %s %s'</span><span class="w"> </span><span class="s2">"</span><span class="nv">$expires</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$port</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$secret</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>md5<span class="w"> </span>-binary<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>base64<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>+/<span class="w"> </span>-_<span class="w"> </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>-d<span class="w"> </span><span class="o">=</span>
<span class="go">6J3jK1WmB15c6WmjW_X-Wg</span>
</code></pre></div>

<p>Bon, je suppose que vous vous dites maintenant : « Vincent, ce n’est pas très
pratique ! Si ça ne te dérange pas, je vais garder ngrok. » D’accord, d’accord.
Écrivons un script pour fluidifier ça.</p>
<h1 id="script-utilitaire">Script utilitaire</h1>
<p>La principale difficulté est de trouver le port éphémère alloué par OpenSSH, car
il n’apparaît dans aucune variable d’environnement<sup id="fnref:env"><a class="footnote-ref" href="#fn:env">6</a></sup>. Pour contourner cet
obstacle, nous cherchons parmi les ancêtres les processus
<code>sshd-session</code><sup id="fnref:sshd-session"><a class="footnote-ref" href="#fn:sshd-session">7</a></sup> :</p>
<div class="language-bash codehilite"><pre><span/><code><span class="nv">pids</span><span class="o">=</span><span class="k">$(</span>
<span class="w">  </span><span class="nv">pid</span><span class="o">=</span><span class="nv">$$</span>
<span class="w">  </span><span class="k">while</span><span class="w"> </span><span class="o">[</span><span class="w"> </span><span class="s2">"</span><span class="nv">$pid</span><span class="s2">"</span><span class="w"> </span>-gt<span class="w"> </span><span class="m">1</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">    </span><span class="nv">line</span><span class="o">=</span><span class="k">$(</span>ps<span class="w"> </span>-o<span class="w"> </span><span class="nv">comm</span><span class="o">=</span>,pid<span class="o">=</span>,ppid<span class="o">=</span><span class="w"> </span>-p<span class="w"> </span><span class="s2">"</span><span class="nv">$pid</span><span class="s2">"</span><span class="k">)</span>
<span class="w">    </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"</span><span class="nv">$line</span><span class="s2">"</span>
<span class="w">    </span><span class="nv">pid</span><span class="o">=</span><span class="si">${</span><span class="nv">line</span><span class="p">##* </span><span class="si">}</span>
<span class="w">  </span><span class="k">done</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">'$1 == "sshd-session" { printf "pid=%s,\n", $2 }'</span>
<span class="k">)</span>
<span class="k">if</span><span class="w"> </span><span class="o">[</span><span class="w"> </span>-z<span class="w"> </span><span class="s2">"</span><span class="nv">$pids</span><span class="s2">"</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"not an ssh session"</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span>
<span class="w">  </span><span class="nb">exit</span><span class="w"> </span><span class="m">1</span>
<span class="k">fi</span>
</code></pre></div>

<p>Ensuite, nous récupérons les ports en écoute associés à ces processus
<code>sshd-session</code><sup id="fnref:sudo"><a class="footnote-ref" href="#fn:sudo">8</a></sup> :</p>
<div class="language-bash codehilite"><pre><span/><code><span class="nv">ports</span><span class="o">=</span><span class="k">$(</span>sudo<span class="w"> </span>-n<span class="w"> </span>ss<span class="w"> </span>--listening<span class="w"> </span>--numeric<span class="w"> </span>--tcp<span class="w"> </span>--processes<span class="w"> </span>--no-header<span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-F<span class="w"> </span><span class="s2">"</span><span class="nv">$pids</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">'{ print $4 }'</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span>-F:<span class="w"> </span><span class="s1">'{ print $NF }'</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>sort<span class="w"> </span>-un<span class="k">)</span>
<span class="k">if</span><span class="w"> </span><span class="o">[</span><span class="w"> </span>-z<span class="w"> </span><span class="s2">"</span><span class="nv">$ports</span><span class="s2">"</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"no forwarded port, use ssh -R 0:localhost:PORT"</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span>
<span class="w">  </span><span class="nb">exit</span><span class="w"> </span><span class="m">1</span>
<span class="k">fi</span>
</code></pre></div>

<p>Enfin, nous affichons les URL et gardons la session ouverte :</p>
<div class="language-bash codehilite"><pre><span/><code><span class="nv">lifetime</span><span class="o">=</span><span class="m">86400</span>
<span class="nv">secret</span><span class="o">=</span><span class="s1">'ZuPerS3cr3!'</span>
<span class="nv">expires</span><span class="o">=</span><span class="k">$((</span><span class="w"> </span><span class="k">$(</span>date<span class="w"> </span>+%s<span class="k">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nv">lifetime</span><span class="w"> </span><span class="k">))</span>
<span class="k">for</span><span class="w"> </span>port<span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="nv">$ports</span><span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">  </span><span class="nv">token</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span><span class="w"> </span><span class="s1">'%s %s %s'</span><span class="w"> </span><span class="s2">"</span><span class="nv">$expires</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$port</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$secret</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
<span class="w">            </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>md5<span class="w"> </span>-binary<span class="w"> </span><span class="se">\</span>
<span class="w">            </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>base64<span class="w"> </span><span class="se">\</span>
<span class="w">            </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>+/<span class="w"> </span>-_<span class="w"> </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>-d<span class="w"> </span><span class="o">=</span><span class="k">)</span>
<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"https://</span><span class="nv">$token</span><span class="s2">--</span><span class="nv">$expires</span><span class="s2">@p</span><span class="nv">$port</span><span class="s2">.ssh.luffy.cx/"</span>
<span class="k">done</span>
sleep<span class="w"> </span>infinity
</code></pre></div>

<p>J’installe ce script sous le nom <code>http-over-ssh</code> sur le serveur et j’ajoute
cette entrée à mon <code>~/.ssh/config</code> :</p>
<div class="language-ssh-config codehilite"><pre><span/><code><span class="k">Host</span><span class="w"> </span>http-over-ssh
<span class="w">  </span><span class="na">Hostname</span><span class="w"> </span>web02.luffy.cx
<span class="w">  </span><span class="na">RemoteCommand</span><span class="w"> </span>http-over-ssh
<span class="w">  </span><span class="na">ControlPath</span><span class="w"> </span>none
</code></pre></div>

<p>Avec cette solution, je ne dépends que d’OpenSSH et de nginx, deux logiciels qui
tournent déjà sur ce serveur. Une commande toute simple me fournit un tunnel
auto-hébergé et une URL à partager. Pour l’essayer, récupérez le <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/tags/http-over-ssh.sh" title="nixops-take1/tags/http-over-ssh.sh">script
utilitaire complet</a>, qui contient quelques petites améliorations. Si
vous utilisez NixOS, comme toute personne de goût, jetez aussi un œil à mon
<a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/tags/http-over-ssh.nix" title="nixops-take1/tags/http-over-ssh.nix"><code>http-over-ssh.nix</code></a>. ❄️</p>
<div class="admonition">
<p class="admonition-title">Mise à jour (10.2026)</p>
<p>Cet article a été longuement discuté sur <a href="https://news.ycombinator.com/item?id=49958569">Hacker News</a>.</p>
</div>
<div class="admonition">
<p class="admonition-title">Mise à jour (10.2026)</p>
<p>Je suis depuis passé sur Caddy qui n’inclut pas de module pour tester la
validité d’une URL. À la place, Caddy interroge un service <a href="https://github.com/vincentbernat/nixops-take1/blob/master/packages/http-over-ssh-auth/main.go" title="nixops-take1/packages/http-over-ssh-auth/main.go at master">HTTP local écrit
en Go</a> via la directive <code>forward_auth</code>. Cela permet au passage de placer
l’empreinte dans le nom de domaine.</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:port">
<p>Comme le noyau l’alloue dans la plage des ports locaux, son entropie est
faible. De plus, sous Linux, nous perdons un bit supplémentaire, car le
noyau <a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/ipv4/inet_connection_sock.c?h=v7.2#n349" title="net/ipv4/inet_connection_sock.c">favorise les ports impairs</a> quand il choisit un
port libre au hasard.</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>sysctl<span class="w"> </span>-n<span class="w"> </span>net.ipv4.ip_local_port_range<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">'{print $1"—"$2" ≈ "log($2-$1+1)/log(2)" bits"}'</span>
<span class="go">32768—60999 ≈ 14.785 bits</span>
</code></pre></div>

<p><a class="footnote-backref" href="#fnref:port" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:md5">
<p>Le module s’appuie sur MD5. Sa sécurité est faible, mais suffisante pour cet
usage. <a class="footnote-backref" href="#fnref:md5" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:expiration">
<p>Une date d’expiration est nécessaire, car une session ultérieure peut
réutiliser le port et nous n’avons aucun moyen de détecter ce cas. <a class="footnote-backref" href="#fnref:expiration" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:comma">
<p>Le nom d’utilisateur pourrait contenir directement une virgule au lieu d’un
double tiret. Mais certaines applications reconnaissent mal l’URL, ce qui la
rend plus difficile à partager. <a class="footnote-backref" href="#fnref:comma" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:security">
<p>Cette configuration expose à quiconque connaît le secret n’importe quel port
TCP en écoute sur <code>127.0.0.1</code> ou <code>0.0.0.0</code>, en contournant la plupart des
règles du pare-feu. Vous pourriez la renforcer en limitant l’expression
rationnelle de <code>server_name</code> à la plage des ports éphémères. <a class="footnote-backref" href="#fnref:security" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:env">
<p>Une session SSH peut contenir plusieurs tunnels et le client peut en ajouter
ou en retirer à tout moment. Ce n’est pas le cas de la redirection
d’interface tunnel (<code>ssh -w</code>), qui dispose de sa propre variable
d’environnement <code>SSH_TUNNEL</code>. <a class="footnote-backref" href="#fnref:env" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:sshd-session">
<p>Depuis OpenSSH 9.8, <code>sshd-session</code> est le nom du programme auxiliaire qui
gère la session. Avec les versions antérieures, cherchez plutôt <code>sshd</code>. <a class="footnote-backref" href="#fnref:sshd-session" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:sudo">
<p><code>sudo</code> est nécessaire, car le processus <code>sshd-session</code> a abandonné ses
privilèges. Le noyau le marque alors comme non <em>dumpable</em> et son
<a href="https://manpages.debian.org/proc_pid.5.html" title="Page de manuel pour proc_pid(5)">répertoire <code>/proc/PID/fd</code> appartient à root</a>. Sans accès à ce
répertoire, <code>ss</code> ne peut pas savoir quel processus possède une socket. <a class="footnote-backref" href="#fnref:sudo" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Des statistiques sans robots avec GoatCounter auto-hébergé sur NixOS</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-goatcounter" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-goatcounter#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-09-20T19:44:50Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-goatcounter.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>En 2016, j’ai <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/4c3be65404031bf6638c1ea3d891a3ea58e0a0fc" title="layout: remove Google Analytics">retiré Google Analytics</a> de ce blog pour ne pas me
rendre complice de la plus grande machine à aspirer les données personnelles. À
la place, j’analysais les journaux de mes serveurs<sup id="fnref:scrub"><a class="footnote-ref" href="#fn:scrub">1</a></sup> avec <a href="https://goaccess.io/" title="GoAccess: Visual Web Log Analyzer">GoAccess</a>.
Depuis quelques années, les statistiques ne veulent plus rien dire, malgré mes
tentatives pour filtrer les robots : <a href="https://herman.bearblog.dev/the-great-scrape/" title="The Great Scrape">les robots pilleurs des IA</a>
gonflent le nombre de visiteurs à environ 2 000 par jour. J’ai fini par adopter
<a href="https://www.goatcounter.com/" title="GoatCounter: open source web analytics">GoatCounter</a>, une plateforme libre de mesure d’audience, respectueuse de la
vie privée. J’ai remplacé le client JavaScript pour filtrer les robots de façon
plus agressive et ajouté une solution de repli en CSS. Pour gagner en fiabilité,
j’ai écrit un proxy local, installé sur chacun des cinq serveurs web qui
hébergent ce blog. La suite de cet article détaille comment ces éléments
s’articulent et comment je déploie l’ensemble sur NixOS. ❄️</p>
<div class="toc">
<ul>
<li><a href="#pourquoi-goatcounter">Pourquoi GoatCounter ?</a></li>
<li><a href="#client-javascript-sur-mesure">Client JavaScript sur mesure</a></li>
<li><a href="#proxy-local">Proxy local</a></li>
<li><a href="#deploiement-sur-nixos">Déploiement sur NixOS</a><ul>
<li><a href="#deployer-des-applications-dans-des-conteneurs">Déployer des applications dans des conteneurs</a></li>
<li><a href="#serveur-goatcounter">Serveur GoatCounter</a></li>
<li><a href="#proxy-goatcounter">Proxy GoatCounter</a></li>
<li><a href="#sauvegarde-de-la-base-de-donnees-sqlite-avec-litestream">Sauvegarde de la base de données SQLite avec Litestream</a></li>
</ul>
</li>
</ul>
</div>
<h1 id="pourquoi-goatcounter">Pourquoi GoatCounter ?</h1>
<p>GoatCounter ne <a href="https://www.goatcounter.com/help/sessions#technical-details-11181" title="GoatCounter documentation: sessions">collecte aucune donnée personnelle</a> : au
lieu de stocker l’adresse IP du lecteur ou de s’appuyer sur des cookies, il crée
un identifiant de session valide 8 heures à partir de l’agent utilisateur et de
l’adresse IP. Ses fonctionnalités sont modestes, mais suffisantes pour un blog.
Pour un aperçu de l’interface, l’auteur de GoatCounter propose une <a href="https://stats.arp242.net" title="Analytics for www.arp242.net">instance
publique</a> pour <a href="https://www.arp242.net" title="Personal website of Martin Tournoij">son site</a>. Une version en ligne permet de
l’essayer avant de l’héberger soi-même. Avec un seul binaire et une base de
données SQLite, GoatCounter est l’une des solutions auto-hébergées les plus
légères. Parmi les alternatives respectueuses de la vie privée figurent, par
ordre croissant de complexité, <a href="https://umami.is/" title="Umami: Privacy-First Analytics Platform">Umami</a>, <a href="https://plausible.io/" title="Plausible Analytics: Simple, privacy-friendly Google Analytics alternative">Plausible</a> et <a href="https://rybbit.com/" title="Rybbit: Cookieless Google Analytics Replacement">Rybbit</a>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 889px"><span class="lf-media-inner" style="padding-bottom: 96.850%"><img alt="Tableau de bord de GoatCounter montrant les statistiques de mon blog sur la&#10;semaine passée, dont l'article sur le protocole Spanning Tree avec 1 224 vues,&#10;ainsi que les statistiques sur les navigateurs (52 % pour Chrome, 32 % pour&#10;Firefox)" src="https://media-bernat-ch.b-cdn.net/images/goatcounter@1x.a055a7216208d0.png" srcset="https://media-bernat-ch.b-cdn.net/images/goatcounter@1x.a055a7216208d0.png 1117w,https://media-bernat-ch.b-cdn.net/images/goatcounter@2x.12641dd59eef89.png 1778w" sizes="auto, (max-width: 889px) 100vw, 889px" width="889" height="861" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAH8AAAB7AQMAAABn4s1GAAAAA1BMVEX5+Pfi03mPAAAAGElEQVR42mNgGAWjYBSMglEwCkbBKMAJAAgrAAG9UB6fAAAAAElFTkSuQmCC)"/></span></div></figure>
<h1 id="client-javascript-sur-mesure">Client JavaScript sur mesure</h1>
<p>GoatCounter fournit un <a href="https://github.com/arp242/goatcounter/blob/main/public/count.js" title="GoatCounter's JavaScript client">petit client JavaScript</a> :
2 189 octets une fois minifié et compressé avec gzip. Il embarque des
fonctionnalités dont je ne me sers pas : un compteur de visites, le suivi des
clics, divers réglages, etc. Je le remplace par cette fonction pour enregistrer
une visite :</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">count</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">({</span><span class="w"> </span><span class="nx">event</span><span class="p">,</span><span class="w"> </span><span class="nx">title</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{})</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">URLSearchParams</span><span class="p">({</span>
<span class="w">    </span><span class="nx">p</span><span class="o">:</span><span class="w"> </span><span class="nx">event</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span>
<span class="w">    </span><span class="nx">t</span><span class="o">:</span><span class="w"> </span><span class="nx">title</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span>
<span class="w">    </span><span class="nx">r</span><span class="o">:</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span><span class="p">,</span>
<span class="w">    </span><span class="nx">q</span><span class="o">:</span><span class="w"> </span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">,</span>
<span class="w">    </span><span class="nx">s</span><span class="o">:</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">clientWidth</span><span class="p">,</span>
<span class="w">    </span><span class="nx">e</span><span class="o">:</span><span class="w"> </span><span class="o">!!</span><span class="nx">event</span><span class="p">,</span>
<span class="w">    </span><span class="nx">rnd</span><span class="o">:</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mf">36</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mf">2</span><span class="p">,</span><span class="w"> </span><span class="mf">7</span><span class="p">),</span>
<span class="w">  </span><span class="p">});</span>
<span class="w">  </span><span class="nx">fetch</span><span class="p">(</span><span class="sb">`/count?</span><span class="si">${</span><span class="nx">params</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">keepalive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}).</span><span class="k">catch</span><span class="p">(()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{});</span>
<span class="p">};</span>
</code></pre></div>

<p>Pour filtrer les robots<sup id="fnref:bot-filter"><a class="footnote-ref" href="#fn:bot-filter">2</a></sup>, je vais plus loin en exigeant une
interaction de l’utilisateur, une idée reprise de <a href="https://bearblog.dev/" title="Bear Blog">Bear Blog</a>.</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">let</span><span class="w"> </span><span class="nx">sendHit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">(</span><span class="nx">sendHit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{},</span><span class="w"> </span><span class="nx">count</span><span class="p">());</span>
<span class="p">[</span><span class="s2">"touchmove"</span><span class="p">,</span><span class="w"> </span><span class="s2">"mousemove"</span><span class="p">,</span><span class="w"> </span><span class="s2">"keydown"</span><span class="p">,</span><span class="w"> </span><span class="s2">"pointerdown"</span><span class="p">].</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">eventName</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span>
<span class="w">  </span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="nx">eventName</span><span class="p">,</span><span class="w"> </span><span class="nx">sendHit</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nx">once</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">    </span><span class="nx">passive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">  </span><span class="p">}),</span>
<span class="p">);</span>
</code></pre></div>

<p>Si un lecteur a désactivé JavaScript dans son navigateur, j’enregistre la visite
à l’aide d’une image CSS. La pseudo-classe <code>:hover</code> ne la charge qu’après une
interaction, une autre astuce <a href="https://herman.bearblog.dev/how-bear-does-analytics-with-css/" title="How Bear does analytics with CSS">piquée à Bear Blog</a>. Environ 2 % de mes
visiteurs entrent dans cette catégorie<sup id="fnref:referrer"><a class="footnote-ref" href="#fn:referrer">3</a></sup>.</p>
<div class="language-html codehilite"><pre><span/><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span> <span class="na">class</span><span class="o">=</span><span class="s">"nojs"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">head</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
<span class="w">      </span><span class="c1">// Le code JavaScript de ce blog requiert ES6</span>
<span class="w">      </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="s2">"noModule"</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nx">HTMLScriptElement</span><span class="p">.</span><span class="nx">prototype</span><span class="p">)</span>
<span class="w">        </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">remove</span><span class="p">(</span><span class="s2">"nojs"</span><span class="p">);</span>
<span class="w">    </span><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nt">head</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
  <span class="cm">&lt;!-- ... --&gt;</span>
    <span class="p">&lt;</span><span class="nt">style</span><span class="p">&gt;</span>
<span class="w">      </span><span class="p">.</span><span class="nc">nojs</span><span class="w"> </span><span class="nt">body</span><span class="p">:</span><span class="nd">hover</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">border-width</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">        </span><span class="k">border-image</span><span class="p">:</span><span class="w"> </span><span class="nb">url</span><span class="p">(</span><span class="s1">'/count?p=/en/blog/2026-kpi-goodhart&amp;t=Building...&amp;r=NoJS&amp;e=false'</span><span class="p">);</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">    </span><span class="p">&lt;/</span><span class="nt">style</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">html</span><span class="p">&gt;</span>
</code></pre></div>

<p>Là où GoAccess annonçait environ 2 000 visiteurs par jour, GoatCounter compte
moins de 200 humains<sup id="fnref:rss"><a class="footnote-ref" href="#fn:rss">4</a></sup>. Je suppose que ces pilleurs privilégient le moindre
effort : si le contenu est accessible sans obstacle, comme sur ce blog, ils ne
vont pas lancer une usine à gaz qui déclencherait une page vue. Même les robots
qui exécutent JavaScript, comme <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics" title="Google Search Central: Understand the JavaScript SEO basics">Googlebot avec son Chromium sans
interface</a>, <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/lazy-loading#:~:text=Google%20Search%20does%20not%20interact%20with%20your%20page" title="Google Search Central: Fix lazy-loaded content">n’interagissent pas avec la page</a>
et ne déclenchent jamais les événements que j’écoute. La « preuve d’humanité »
par l’interaction que j’utilise me semble pouvoir durer<sup id="fnref:css-test"><a class="footnote-ref" href="#fn:css-test">5</a></sup>.</p>
<h1 id="proxy-local">Proxy local</h1>
<p>Cinq serveurs <del>à travers le monde</del> <ins>en Europe et en Amérique du
Nord</ins> servent le contenu de ce site, mais GoatCounter ne tourne que sur
l’un d’eux. Pour ne pas perdre la trace des visiteurs quand GoatCounter est
indisponible, j’exécute un proxy local qui écoute sur le même point de
terminaison <code>/count</code>. Sur chaque serveur, il conserve les visites en mémoire,
avec un tampon assez grand pour tenir plusieurs jours en cas d’indisponibilité.
Il les envoie par lots au serveur amont via le <a href="https://www.goatcounter.com/help/api" title="GoatCounter documentation: API">point de terminaison authentifié
<code>/api/v0/count</code></a>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 836px"><span class="lf-media-inner" style="padding-bottom: 58.852%"><object width="836" height="492" type="image/svg+xml" data="https://media-bernat-ch.b-cdn.net/images/luffy-web-servers.be5e1b31615943.svg" class="lf-media">🖼 Serveurs sur une carte. web02 est à Paris, web03 à Helsinki, web04 à
Nuremberg, web05 à Ashburn, web06 à
Chicago.</object></span></div></figure>
<p>J’ai proposé le code du proxy dans la <a href="https://github.com/arp242/goatcounter/pull/909" title="PR #909: add a proxy to forward page counts"><em>pull request</em> #909</a>. Le
mainteneur de GoatCounter a refusé de maintenir cet amas de code pour un cas
d’usage aussi niche. Développant moi aussi des logiciels libres, j’adopte
souvent la même position pour mes propres projets : l’effort ponctuel d’un
contributeur peut se transformer en engagement sur le long terme pour le
mainteneur.</p>
<p>J’expose le point de terminaison du proxy sur le domaine de ce site pour
échapper aux bloqueurs de publicité. Cela donne l’impression que je ne respecte
pas le choix du lecteur, mais comme GoatCounter respecte la vie privée, je
trouve cela acceptable.</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">location</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">/count</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">access_log</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_pass</span><span class="w"> </span><span class="s">http://127.0.0.3:8087/count</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_pass_request_headers</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Accept-Language</span><span class="w"> </span><span class="nv">$http_accept_language</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">User-Agent</span><span class="w"> </span><span class="nv">$http_user_agent</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Real-Ip</span><span class="w"> </span><span class="nv">$remote_addr</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="deploiement-sur-nixos">Déploiement sur NixOS</h1>
<p>Mes serveurs web tournent sous <a href="https://nixos.org/" title="Nix &amp; NixOS: Declarative builds and deployments">NixOS</a>, une distribution Linux déclarative
avec une gestion de configuration intégrée. Je gère ce petit troupeau avec
<a href="https://colmena.cli.rs/" title="Colmena documentation">Colmena</a>, un outil de déploiement sans état pour NixOS. Ma configuration est
disponible sur <a href="https://github.com/vincentbernat/nixops-take1/" title="vincentbernat/nixops-take1: Playground for NixOps">GitHub</a>.</p>
<h2 id="deployer-des-applications-dans-des-conteneurs">Déployer des applications dans des conteneurs</h2>
<p>Pour une meilleure isolation, chaque application tourne dans un conteneur léger
et éphémère, propulsé par <a href="https://manpages.debian.org/systemd-nspawn.1.html" title="Page de manuel pour systemd-nspawn(1)">systemd-nspawn</a>. Chaque conteneur exécute une
instance allégée de NixOS. Un <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/container.nix" title="nixops-take1/modules/container.nix">module encapsule les options <code>containers</code> de
NixOS</a> pour éviter de répéter les mêmes options pour chaque
application<sup id="fnref:module"><a class="footnote-ref" href="#fn:module">6</a></sup>. Les conteneurs partagent la pile réseau de l’hôte :
l’isolation supplémentaire ne vaut pas la complexité accrue. Pour les alléger,
je désactive aussi quelques services non essentiels.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="c1"># Réglages configurables pour notre module</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
    <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Ephemeral containers sharing the host network."</span><span class="p">;</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">config</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>deferredModule<span class="p">;</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"NixOS configuration of the container."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="c1"># Traduit nos options en conteneurs NixOS</span>
  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
<span class="hll">        <span class="ss">ephemeral</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
</span>        <span class="ss">autoStart</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
<span class="hll">        <span class="ss">privateNetwork</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span>        <span class="ss">extraFlags</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"--resolv-conf=replace-host"</span> <span class="p">];</span>
        <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">          <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> container<span class="o">.</span>config <span class="p">];</span>
</span>          networking<span class="o">.</span>firewall<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
          system<span class="o">.</span><span class="ss">stateVersion</span> <span class="o">=</span> config<span class="o">.</span>system<span class="o">.</span>stateVersion<span class="p">;</span>
          systemd<span class="o">.</span><span class="ss">services</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">            console-getty<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span><span class="hll">            systemd-logind<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span><span class="hll">            systemd-oomd<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span>          <span class="p">};</span>
        <span class="p">};</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Pour configurer une instance GoatCounter tournant dans un conteneur et écoutant
sur <code>127.0.0.4:8088</code>, nous importons le module<sup id="fnref:import"><a class="footnote-ref" href="#fn:import">7</a></sup> et déclarons le
conteneur dans l’ensemble d’attributs <code>config.luffy.containers</code> :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs<span class="p">,</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span> <span class="p">{</span>
  <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> <span class="l">./modules/container.nix</span> <span class="p">];</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">address</span> <span class="o">=</span> <span class="s2">"127.0.0.4"</span><span class="p">;</span>
        <span class="ss">port</span> <span class="o">=</span> <span class="mi">8088</span><span class="p">;</span>
        <span class="ss">proxy</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Comme les conteneurs sont éphémères, il faut conserver les données persistantes
dans des répertoires de l’hôte. Nous ajoutons une option <code>mounts</code> et demandons
aux conteneurs de NixOS d’exposer les répertoires ainsi configurés via l’option
<code>bindMounts</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">mounts</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>listOf lib<span class="o">.</span>types<span class="o">.</span>str<span class="p">;</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">[</span> <span class="p">];</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Host directories mounted read-write at the same place."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
        <span class="ss">bindMounts</span> <span class="o">=</span>
          lib<span class="o">.</span>genAttrs container<span class="o">.</span>mounts <span class="p">(</span>path<span class="p">:</span> <span class="p">{</span> <span class="ss">hostPath</span> <span class="o">=</span> path<span class="p">;</span> <span class="ss">isReadOnly</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span> <span class="p">});</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Par exemple, pour conserver la base de données de GoatCounter dans le répertoire
<code>/var/db/goatcounter</code> de l’hôte, nous ajoutons ce répertoire à l’option <code>mounts</code>
et modifions la définition du service pour indiquer à GoatCounter où trouver la
base de données.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">databaseDirectory</span> <span class="o">=</span> <span class="s2">"/var/db/goatcounter"</span><span class="p">;</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">mounts</span> <span class="o">=</span> <span class="p">[</span> databaseDirectory <span class="p">];</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">extraArgs</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"-db=sqlite+</span><span class="si">${</span>databaseDirectory<span class="si">}</span><span class="s2">/db.sqlite"</span> <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Un conteneur peut aussi avoir besoin de secrets. Colmena peut <a href="https://colmena.cli.rs/unstable/features/keys.html" title="Colmena documentation: Secrets">envoyer des
secrets</a> sans les stocker dans <code>/nix</code>. Nous ajoutons une option
<code>keys</code> à nos conteneurs. Elle prend un ensemble d’attributs associant le nom de
chaque secret à la commande qui le fournit. Ensuite, le module déclare les
secrets requis à Colmena dans l’option <code>deployment.keys</code>, fait dépendre le
conteneur de la présence des secrets, puis les attache au conteneur.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">keys</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>listOf lib<span class="o">.</span>types<span class="o">.</span>str<span class="p">);</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Secrets, as a command to run locally. They are mounted in /etc."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="c1"># Colmena place chaque secret dans `/var/keys` et le rend accessible</span>
    <span class="c1"># au groupe "keys".</span>
    deployment<span class="o">.</span><span class="ss">keys</span> <span class="o">=</span> lib<span class="o">.</span>concatMapAttrs
      <span class="p">(</span>_<span class="p">:</span> container<span class="p">:</span> lib<span class="o">.</span>mapAttrs
        <span class="p">(</span>_<span class="p">:</span> keyCommand<span class="p">:</span> <span class="p">{</span>
          <span class="k">inherit</span> keyCommand<span class="p">;</span>
          <span class="ss">group</span> <span class="o">=</span> <span class="s2">"keys"</span><span class="p">;</span>
          <span class="ss">permissions</span> <span class="o">=</span> <span class="s2">"0640"</span><span class="p">;</span>
          <span class="ss">destDir</span> <span class="o">=</span> <span class="s2">"/var/keys"</span><span class="p">;</span>
        <span class="p">})</span>
        container<span class="o">.</span>keys<span class="p">)</span>
      cfg<span class="p">;</span>

    <span class="c1"># Le conteneur ne peut démarrer que si les secrets requis sont disponibles.</span>
    systemd<span class="o">.</span><span class="ss">services</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs'
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span>
        <span class="k">let</span>
          <span class="ss">units</span> <span class="o">=</span> <span class="nb">map</span> <span class="p">(</span>key<span class="p">:</span> <span class="s2">"</span><span class="si">${</span>key<span class="si">}</span><span class="s2">-key.service"</span><span class="p">)</span> <span class="p">(</span>lib<span class="o">.</span>attrNames container<span class="o">.</span>keys<span class="p">);</span>
        <span class="k">in</span>
        lib<span class="o">.</span>nameValuePair <span class="s2">"container@</span><span class="si">${</span>name<span class="si">}</span><span class="s2">"</span> <span class="p">{</span>
          <span class="ss">requires</span> <span class="o">=</span> units<span class="p">;</span>
          <span class="ss">after</span> <span class="o">=</span> units<span class="p">;</span>
        <span class="p">})</span>
      cfg<span class="p">;</span>

    <span class="c1"># Attache chaque secret dans le conteneur.</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
        <span class="ss">bindMounts</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs'
          <span class="p">(</span>key<span class="p">:</span> _<span class="p">:</span> lib<span class="o">.</span>nameValuePair <span class="s2">"/etc/</span><span class="si">${</span>key<span class="si">}</span><span class="s2">"</span> <span class="p">{</span>
            <span class="ss">hostPath</span> <span class="o">=</span> <span class="s2">"/var/keys/</span><span class="si">${</span>key<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
            <span class="ss">isReadOnly</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
          <span class="p">})</span>
          container<span class="o">.</span>keys<span class="p">;</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Par exemple, GoatCounter a besoin d’identifiants pour télécharger la base de
données GeoIP. Je fournis une commande locale pour récupérer le secret depuis
mon gestionnaire de mots de passe et je l’expose dans le conteneur via le
fichier d’environnement <code>/etc/goatcounter.env</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs<span class="p">,</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span> 
<span class="k">let</span>
  <span class="ss">keyCommand</span> <span class="o">=</span> variable<span class="p">:</span> <span class="p">[</span>
    <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>runtimeShell<span class="si">}</span><span class="s2">"</span>
    <span class="s2">"-c"</span>
    <span class="s2">"pass show personal/nixops/secrets | grep '^</span><span class="si">${</span>variable<span class="si">}</span><span class="s2">='"</span>
  <span class="p">];</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">    keys<span class="o">.</span><span class="s2">"goatcounter.env"</span> <span class="o">=</span> keyCommand <span class="s2">"GOATCOUNTER_GEODB"</span><span class="p">;</span>
</span>    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      systemd<span class="o">.</span>services<span class="o">.</span>goatcounter<span class="o">.</span><span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="serveur-goatcounter">Serveur GoatCounter</h2>
<p>Nixpkgs fournit déjà un paquet pour GoatCounter. En surchargeant les attributs
<code>src</code> et <code>vendorHash</code>, je réutilise sa définition pour ma version personnalisée
avec le proxy :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> goatcounter<span class="p">,</span> fetchFromGitHub <span class="p">}:</span>
goatcounter<span class="o">.</span>overrideAttrs <span class="p">(</span>_<span class="p">:</span> <span class="p">{</span>
  <span class="ss">src</span> <span class="o">=</span> fetchFromGitHub <span class="p">{</span>
    <span class="ss">owner</span> <span class="o">=</span> <span class="s2">"vincentbernat"</span><span class="p">;</span>
    <span class="ss">repo</span> <span class="o">=</span> <span class="s2">"goatcounter"</span><span class="p">;</span>
    <span class="ss">rev</span> <span class="o">=</span> <span class="s2">"feature/proxy"</span><span class="p">;</span>
    <span class="ss">hash</span> <span class="o">=</span> <span class="s2">"sha256-dJRlQlFu3tjcEgabT1LEbyFrasJlhmYu4L/T7EkoNcY="</span><span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">vendorHash</span> <span class="o">=</span> <span class="s2">"sha256-c9Q5OrbZR+q6pD3SgPPWe8JUzcZco1AVUKGaV61k5DE="</span><span class="p">;</span>
<span class="p">})</span>
</code></pre></div>

<p>J’ai écrit un <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/goatcounter.nix" title="nixops-take1/modules/goatcounter.nix">module NixOS</a> pour encapsuler GoatCounter : la
définition du conteneur, celle du service et les secrets. Le module accepte les
options suivantes : <code>package</code>, <code>serve.enable</code>, <code>serve.listenAddress</code>,
<code>serve.port</code> et <code>serve.databaseFile</code>. J’ai déjà détaillé la configuration du
conteneur dans la section précédente. Finalement, j’ai choisi de ne pas
réutiliser le module GoatCounter de NixOS : il est plutôt simple et le réécrire
permet de me protéger de changements futurs inattendus.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="p">;</span>
  <span class="ss">databaseDirectory</span> <span class="o">=</span> <span class="nb">builtins</span><span class="o">.</span><span class="nb">dirOf</span> cfg<span class="o">.</span>serve<span class="o">.</span>databaseFile<span class="p">;</span>
  <span class="ss">chown</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>coreutils<span class="si">}</span><span class="s2">/bin/chown -R"</span><span class="p">;</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    config<span class="o">.</span>systemd<span class="o">.</span>services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">description</span> <span class="o">=</span> <span class="s2">"GoatCounter Web Analytics"</span><span class="p">;</span>
      <span class="ss">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"multi-user.target"</span> <span class="p">];</span>
      <span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
        <span class="ss">DynamicUser</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
        <span class="ss">ExecStart</span> <span class="o">=</span> lib<span class="o">.</span>escapeShellArgs <span class="p">[</span>
          <span class="p">(</span>lib<span class="o">.</span>getExe cfg<span class="o">.</span>package<span class="p">)</span>
          <span class="s2">"serve"</span>
          <span class="s2">"-listen=</span><span class="si">${</span>cfg<span class="o">.</span>serve<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>serve<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-tls=none"</span>
          <span class="s2">"-db=sqlite+</span><span class="si">${</span>cfg<span class="o">.</span>serve<span class="o">.</span>databaseFile<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-automigrate"</span>
        <span class="p">];</span>
        <span class="c1"># Donne la base de données à l'utilisateur dynamique "goatcounter".</span>
        <span class="ss">ExecStartPre</span> <span class="o">=</span> <span class="s2">"+</span><span class="si">${</span>chown<span class="si">}</span><span class="s2"> goatcounter:goatcounter </span><span class="si">${</span>databaseDirectory<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
        <span class="ss">ReadWritePaths</span> <span class="o">=</span> databaseDirectory<span class="p">;</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Le bout de code suivant configure GoatCounter pour écouter sur
<code>127.0.0.4:8088</code> :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  luffy<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">serve</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="ss">listenAddress</span> <span class="o">=</span> <span class="s2">"127.0.0.4"</span><span class="p">;</span>
      <span class="ss">port</span> <span class="o">=</span> <span class="mi">8088</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>La dernière étape consiste à configurer nginx pour exposer GoatCounter sur
Internet. Je désactive le point de terminaison <code>/count</code>, car sa fonction est
assurée par le proxy local.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="o">.</span>serve<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  services<span class="o">.</span>nginx<span class="o">.</span>virtualHosts<span class="o">.</span><span class="s2">"goatcounter.luffy.cx"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">forceSSL</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
    <span class="ss">locations</span> <span class="o">=</span> <span class="p">{</span>
      <span class="s2">"/"</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">proxyPass</span> <span class="o">=</span> <span class="s2">"http://</span><span class="si">${</span>cfg<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
      <span class="p">};</span>
      <span class="s2">"= /count"</span><span class="o">.</span><span class="ss">extraConfig</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">        return 404;</span>
<span class="s s-Multiline">      ''</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="proxy-goatcounter">Proxy GoatCounter</h2>
<p>Le même <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/goatcounter.nix" title="nixops-take1/modules/goatcounter.nix">module NixOS</a> configure le proxy local, avec les
options suivantes : <code>proxy.enable</code>, <code>proxy.listenAddress</code>, <code>proxy.port</code> et
<code>proxy.site</code> (le site qui reçoit les lots de pages vues). Le proxy local n’a
aucune donnée persistante, mais il a besoin de la clé d’API pour s’authentifier
auprès de l’instance principale de GoatCounter : son conteneur utilise l’option
<code>keys</code>, mais pas l’option <code>mounts</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="p">;</span>
  <span class="ss">keyCommand</span> <span class="o">=</span> _<span class="p">:</span> <span class="p">[</span> <span class="s2">"…"</span> <span class="p">];</span>
<span class="k">in</span>
<span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter-proxy</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">    keys<span class="o">.</span><span class="s2">"goatcounter-proxy.env"</span> <span class="o">=</span> keyCommand <span class="s2">"GOATCOUNTER_API_KEY"</span><span class="p">;</span>
</span>    config<span class="o">.</span>systemd<span class="o">.</span>services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">description</span> <span class="o">=</span> <span class="s2">"GoatCounter Proxy."</span><span class="p">;</span>
      <span class="ss">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"multi-user.target"</span> <span class="p">];</span>
      <span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter-proxy.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
        <span class="ss">DynamicUser</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
        <span class="ss">ExecStart</span> <span class="o">=</span> lib<span class="o">.</span>escapeShellArgs <span class="p">[</span>
          <span class="p">(</span>lib<span class="o">.</span>getExe cfg<span class="o">.</span>package<span class="p">)</span>
          <span class="s2">"proxy"</span>
          <span class="s2">"-site=</span><span class="si">${</span>cfg<span class="o">.</span>proxy<span class="o">.</span>site<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-listen=</span><span class="si">${</span>cfg<span class="o">.</span>proxy<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>proxy<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-ratelimit=10/1"</span>  <span class="c1"># 10 requêtes par seconde par IP</span>
        <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Sur chaque serveur, j’active le proxy local avec le bout de code suivant. La
configuration nginx présentée plus haut expose le point de terminaison <code>/count</code>
sous le même domaine que mon blog.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  luffy<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">proxy</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="ss">site</span> <span class="o">=</span> <span class="s2">"goatcounter.luffy.cx"</span><span class="p">;</span>
      <span class="ss">listenAddress</span> <span class="o">=</span> <span class="s2">"127.0.0.3"</span><span class="p">;</span>
      <span class="ss">port</span> <span class="o">=</span> <span class="mi">8087</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="sauvegarde-de-la-base-de-donnees-sqlite-avec-litestream">Sauvegarde de la base de données SQLite avec Litestream</h2>
<p><a href="https://litestream.io/" title="Litestream: Streaming SQLite Replication">Litestream</a> est un outil de réplication en continu pour les bases de données
SQLite. Il compresse les changements placés dans le journal d’écriture
anticipée (<abbr title="Write-ahead log">WAL</abbr>) situé à côté de la base de données et les envoie vers une
destination distante. J’encapsule sa configuration dans un <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/litestream.nix" title="nixops-take1/modules/litestream.nix">module
NixOS</a>, qui prend un ensemble d’attributs <code>databases</code> associant
un nom au chemin de la base de données à sauvegarder.</p>
<p>Litestream tourne lui aussi dans un conteneur. Je monte les bases de données à
répliquer, ainsi que les secrets pour pousser les sauvegardes vers une <a href="https://www.hetzner.com/storage/storage-box/" title="Hetzner Storage Box"><em>storage
box</em> de Hetzner</a> via <abbr title="Secure File Transfer Protocol">SFTP</abbr> :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>litestream<span class="p">;</span>
  <span class="ss">databaseDirs</span> <span class="o">=</span> lib<span class="o">.</span>unique <span class="p">(</span><span class="nb">map</span> <span class="nb">builtins</span><span class="o">.</span><span class="nb">dirOf</span> <span class="p">(</span><span class="nb">builtins</span><span class="o">.</span>attrValues cfg<span class="o">.</span>databases<span class="p">));</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="ss">config</span> <span class="o">=</span> lib<span class="o">.</span>mkIf <span class="p">(</span>cfg<span class="o">.</span>databases <span class="o">!=</span> <span class="p">{</span> <span class="p">})</span> <span class="p">{</span>
    luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">mounts</span> <span class="o">=</span> databaseDirs<span class="p">;</span>
      keys<span class="o">.</span><span class="s2">"litestream.env"</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>runtimeShell<span class="si">}</span><span class="s2">"</span>
        <span class="s2">"-c"</span>
        <span class="s2">"pass show personal/nixops/secrets | grep '^SQLITE_BACKUP_'"</span>
      <span class="p">];</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Dans le conteneur, je configure Litestream à travers les options
<code>services.litestream</code> de NixOS :</p>
<ul>
<li>des instantanés complets chaque jour, conservés 15 jours ;</li>
<li>trois <a href="https://fly.io/blog/litestream-v050-is-here/" title="Litestream v0.5.0 is Here">niveaux de compactage</a> pour les transactions :
  5 minutes, 30 minutes et 3 heures ;</li>
<li>la reprise automatique<sup id="fnref:auto-recover"><a class="footnote-ref" href="#fn:auto-recover">8</a></sup> ;</li>
<li>un réplica stocké dans un répertoire portant le nom de l’hôte ;</li>
<li>des identifiants lus depuis <code>/etc/litestream.env</code> et exposés par expansion de variables.</li>
</ul>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>litestream<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      <span class="c1"># Les bases de données appartiennent à des utilisateurs dynamiques, dont</span>
      <span class="c1"># l'UID n'est pas connu ici : Litestream tourne donc en root.</span>
      systemd<span class="o">.</span>services<span class="o">.</span>litestream<span class="o">.</span><span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">User</span> <span class="o">=</span> lib<span class="o">.</span>mkForce <span class="s2">"root"</span><span class="p">;</span>
        <span class="ss">Group</span> <span class="o">=</span> lib<span class="o">.</span>mkForce <span class="s2">"root"</span><span class="p">;</span>
      <span class="p">};</span>
      <span class="c1"># Utilise le service NixOS.</span>
      services<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">environmentFile</span> <span class="o">=</span> <span class="s2">"/etc/litestream.env"</span><span class="p">;</span>
        <span class="ss">settings</span> <span class="o">=</span> <span class="p">{</span>
          <span class="ss">auto-recover</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
          <span class="ss">snapshot</span> <span class="o">=</span> <span class="p">{</span>
            <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"24h"</span><span class="p">;</span>
            <span class="ss">retention</span> <span class="o">=</span> <span class="s2">"360h"</span><span class="p">;</span>
          <span class="p">};</span>
          <span class="ss">levels</span> <span class="o">=</span> <span class="p">[</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"5m"</span><span class="p">;</span> <span class="p">}</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"30m"</span><span class="p">;</span> <span class="p">}</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"3h"</span><span class="p">;</span> <span class="p">}</span>
          <span class="p">];</span>
          <span class="ss">dbs</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrsToList
            <span class="p">(</span>name<span class="p">:</span> path<span class="p">:</span> <span class="p">{</span>
              <span class="k">inherit</span> path<span class="p">;</span>
              <span class="ss">replica</span> <span class="o">=</span> <span class="p">{</span>
                <span class="ss">type</span> <span class="o">=</span> <span class="s2">"sftp"</span><span class="p">;</span>
                <span class="ss">host</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_HOST}"</span><span class="p">;</span>
                <span class="ss">user</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_USER}"</span><span class="p">;</span>
                <span class="ss">password</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_PASSWORD}"</span><span class="p">;</span>
                <span class="ss">host-key</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_HOSTKEY}"</span><span class="p">;</span>
                <span class="ss">path</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>config<span class="o">.</span>networking<span class="o">.</span>hostName<span class="si">}</span><span class="s2">/</span><span class="si">${</span>name<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
              <span class="p">};</span>
            <span class="p">})</span>
            cfg<span class="o">.</span>databases<span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Pour sauvegarder la base de données de GoatCounter, je déclare un attribut
<code>goatcounter</code> dans <code>luffy.litestream.databases</code> avec pour valeur le chemin de la
base de données :</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="o">.</span>serve<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  luffy<span class="o">.</span>litestream<span class="o">.</span>databases<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> cfg<span class="o">.</span>databaseFile<span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Sur le serveur <abbr title="Secure File Transfer Protocol">SFTP</abbr>, nous pouvons inspecter le travail de Litestream, avec les
transactions compactées et les instantanés complets :</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">❯ </span>ls<span class="w"> </span>web02/goatcounter/ltx
<span class="go">web02/goatcounter/ltx/0</span>
<span class="go">web02/goatcounter/ltx/1</span>
<span class="go">web02/goatcounter/ltx/2</span>
<span class="go">web02/goatcounter/ltx/3</span>
<span class="go">web02/goatcounter/ltx/9</span>
<span class="gp">❯ </span>ls<span class="w"> </span>-lh<span class="w"> </span>web02/goatcounter/ltx/1
<span class="go">29.1K Sep  5 01:25 0000000000003f2a-0000000000003f2b.ltx</span>
<span class="go">72.4K Sep  5 02:03 0000000000003f2c-0000000000003f2d.ltx</span>
<span class="go">63.3K Sep  5 02:24 0000000000003f2e-0000000000003f2f.ltx</span>
<span class="go">[…]</span>
<span class="gp">❯ </span>ls<span class="w"> </span>-lh<span class="w"> </span>web02/goatcounter/ltx/9
<span class="go"> 8.5M Sep  5 02:00 0000000000000001-0000000000003f2b.ltx</span>
<span class="go"> 8.5M Sep  6 02:03 0000000000000001-0000000000004008.ltx</span>
<span class="go"> 8.6M Sep  7 02:03 0000000000000001-00000000000043a8.ltx</span>
<span class="go">[…]</span>
</code></pre></div>

<p>Nous pouvons restaurer la base de données depuis la sauvegarde avec quelques
commandes shell. D’abord, nous arrêtons les conteneurs. Ensuite, nous mettons de
côté la base de données endommagée, invoquons <code>litestream restore</code> depuis le bon
environnement et redémarrons les conteneurs<sup id="fnref:slow"><a class="footnote-ref" href="#fn:slow">9</a></sup>.</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp"># </span>systemctl<span class="w"> </span>stop<span class="w"> </span>container@goatcounter<span class="w"> </span>container@litestream
<span class="gp"># </span>mv<span class="w"> </span>/var/db/goatcounter/db.sqlite<span class="o">{</span>,.old<span class="o">}</span>
<span class="gp"># </span><span class="o">(</span><span class="w"> </span>.<span class="w"> </span>/etc/nixos-containers/litestream.conf<span class="w"> </span><span class="p">;</span><span class="w"> </span>
<span class="gp">&gt; </span><span class="w">  </span><span class="nb">set</span><span class="w"> </span>-a<span class="w"> </span><span class="p">;</span><span class="w"> </span>.<span class="w"> </span>/var/keys/litestream.env<span class="w"> </span><span class="p">;</span><span class="w"> </span><span class="nb">set</span><span class="w"> </span>+a<span class="w"> </span><span class="p">;</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="nv">$SYSTEM_PATH</span>/sw/bin/litestream<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">    </span>restore<span class="w"> </span>-config<span class="w"> </span><span class="nv">$SYSTEM_PATH</span>/etc/litestream.yml<span class="w"> </span>/var/db/goatcounter/db.sqlite<span class="o">)</span>
<span class="gp"># </span>ls<span class="w"> </span>-lh<span class="w"> </span>/var/db/goatcounter/db.sqlite
<span class="go">-rw-r--r-- 1 root root 20M Sep 20 07:33 /var/db/goatcounter/db.sqlite</span>
<span class="gp"># </span>systemctl<span class="w"> </span>start<span class="w"> </span>container@goatcounter<span class="w"> </span>container@litestream
</code></pre></div>

<hr/>
<p>Dix ans après <a href="/fr/blog/2018-blog-respect-vie-privee" title="Un blog plus respectueux de la vie privée">avoir retiré Google Analytics</a>, les
statistiques en JavaScript sont de retour sur ce blog, mais sans stocker de
cookies ni d’adresses IP, et sans se reposer sur un tiers. J’écris d’abord pour
moi, notamment parce que cela me permet de creuser un sujet et de m’y référer
des années plus tard. Cependant, en savoir un peu plus sur mes lecteurs humains
est un bonus appréciable, y compris sur ceux qui n’activent pas JavaScript. 🐐</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:scrub">
<p>Nginx brouille les adresses IP avant de les enregistrer, grâce au <a href="https://github.com/masonicboom/ipscrub" title="ipscrub: IP address anonymizer module for nginx">module
<code>ipscrub</code></a>. <a class="footnote-backref" href="#fnref:scrub" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:bot-filter">
<p>GoatCounter filtre déjà certains robots grâce à leur agent utilisateur ou à
leur adresse IP. Mais les robots pilleurs mentent sur leur agent utilisateur
et se cachent derrière des <a href="https://jacob.gold/posts/residential-proxies-are-a-national-security-threat/" title="Residential Proxies Are a National Security Threat">proxys résidentiels</a>. <a class="footnote-backref" href="#fnref:bot-filter" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:referrer">
<p>Sans JavaScript, impossible d’envoyer la véritable origine. J’indique
« NoJS » à la place. <a class="footnote-backref" href="#fnref:referrer" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:rss">
<p>Il me manque les humains lisant le flux RSS. Je ne suis pas à l’aise avec
l’idée d’ajouter un <a href="https://www.goatcounter.com/help/pixel" title="GoatCounter documentation: tracking pixel without JavaScript">pixel transparent</a>, et les robots le
chargeraient sans doute, ce qui fausserait les statistiques. Je me passe de
compter ces lecteurs. <a class="footnote-backref" href="#fnref:rss" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:css-test">
<p>En février 2025, j’ai testé une autre approche reposant uniquement sur CSS
sans détecter d’interaction : chaque page chargeait une image vide comme
arrière-plan. Sur un mois, elle a enregistré jusqu’à dix fois moins de pages
vues que les journaux de nginx, même après avoir retiré les robots qui
s’identifiaient comme tels. Par rapport à cette expérience, 18 mois plus
tard, GoatCounter compte environ trois fois moins de vues. Comme les
périodes diffèrent, la comparaison n’est pas rigoureuse, mais elle donne une
idée de l’efficacité de cette preuve d’humanité. <a class="footnote-backref" href="#fnref:css-test" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:module">
<p>Un <a href="https://nixos.org/manual/nixos/stable/#sec-writing-modules" title="NixOS Manual: Writing NixOS Modules">module NixOS</a> est une fonction qui reçoit la configuration
de l’ensemble du système dans <code>config</code> et renvoie trois attributs :</p>
<ul>
<li><code>imports</code> liste d’autres modules à importer ;</li>
<li><code>options</code> déclare les réglages configurables par l’utilisateur ;</li>
<li><code>config</code> attribue des valeurs aux options déclarées par n’importe quel module, comme <code>containers</code> de NixOS.</li>
</ul>
<p>Si un module ne déclare aucune option, il est possible de renvoyer directement les attributs
contenus dans <code>config</code>. Si un module n’a besoin d’aucun argument, vous pouvez le
définir comme un simple ensemble d’attributs. <a class="footnote-backref" href="#fnref:module" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:import">
<p>La plupart du temps, un import explicite est inutile. NixOS <a href="https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/module-list.nix" title="nixpkgs/nixos/modules/module-list.nix">importe
automatiquement</a> les modules livrés avec Nixpkgs. Pour mes
propres modules, <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/default.nix" title="nixops-take1/modules/default.nix">un peu de code</a> les importe aussi
automatiquement. <a class="footnote-backref" href="#fnref:import" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:auto-recover">
<p>Litestream <a href="https://litestream.io/reference/config/#auto-recover" title="Litestream: auto-recover option">déconseille l’option <code>auto-recover</code></a>, car elle
peut entraîner une perte de données. Mais je ne surveille pas assez
attentivement mes serveurs et je préfère des sauvegardes toujours
fonctionnelles à un faible risque de perdre les derniers enregistrements. <a class="footnote-backref" href="#fnref:auto-recover" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
<li id="fn:slow">
<p>Chez moi, le processus est lent : environ 20 minutes pour une base de
données de 20 Mio. Vous pouvez tester en restaurant vers une copie avec
l’option <code>-o</code>, mais il faut tout de même arrêter le conteneur Litestream. <a class="footnote-backref" href="#fnref:slow" title="Jump back to footnote 9 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Notes de marge avec le positionnement par ancre CSS</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-css-notes-de-marge" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-css-notes-de-marge#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-09-07T06:49:58Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-css-notes-de-marge.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>Je fais un usage intensif des notes de marge<sup id="fnref:phd"><a class="footnote-ref" href="#fn:phd">1</a></sup> : elles gardent le contenu
optionnel à côté du texte au lieu de renvoyer le lecteur en bas de page. <a href="https://edwardtufte.github.io/tufte-css/" title="Tufte CSS">Tufte
CSS</a> les affiche sans JavaScript, mais n’accepte qu’un seul paragraphe. Le
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning" title="CSS anchor positioning">positionnement par ancre CSS</a>, désormais pris en charge
par les navigateurs récents<sup id="fnref:anchor-support"><a class="footnote-ref" href="#fn:anchor-support">2</a></sup>, est une alternative élégante. Les
notes peuvent contenir plusieurs blocs, toujours sans JavaScript, et se
positionnent sous le paragraphe qui les appelle sur les fenêtres étroites et les
navigateurs plus anciens.</p>
<p>En 2023, Eric Meyer a présenté cette technique dans « <a href="https://meyerweb.com/eric/thoughts/2023/09/12/nuclear-anchored-sidenotes/" title="Nuclear Anchored Sidenotes">Nuclear Anchored
Sidenotes</a> ». Le principal avantage par rapport aux autres solutions est que
les notes peuvent se trouver n’importe où dans le document HTML. Il est possible
de les placer après le paragraphe qui les appelle, comme des éléments de bloc
ordinaires, afin que les navigateurs en mode texte, les lecteurs d’écran, les
agrégateurs de flux et le mode lecture les affichent correctement :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 465px"><span class="lf-media-inner" style="padding-bottom: 129.032%"><img alt="Les notes de marge affichées dans Lynx apparaissent après le paragraphe qui&#10;les appelle." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/lynx@1x.fbdd281f455d28.png" srcset="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/lynx@1x.fbdd281f455d28.png 585w,https://media-bernat-ch.b-cdn.net/images/css-sidenotes/lynx@2x.358853d7892aaf.png 930w" sizes="auto, (max-width: 465px) 100vw, 465px" width="465" height="600" class="lf-media"/></span></div><figcaption>Rendu dans Lynx, un navigateur en mode texte</figcaption></figure>
<p>Lorsque la fenêtre est trop étroite ou que le navigateur ne prend pas en charge
le positionnement par ancre, il est possible de leur appliquer un style qui
permet au lecteur de les sauter ou d’y jeter un œil sans perdre sa place dans le
texte :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 375px"><span class="lf-media-inner" style="padding-bottom: 177.867%"><img alt="Les notes de marge affichées sur une fenêtre étroite apparaissent avec une&#10;typographie distincte après le paragraphe qui les&#10;appelle." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/narrow@1x.e343d33285dbf3.png" srcset="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/narrow@1x.e343d33285dbf3.png 471w,https://media-bernat-ch.b-cdn.net/images/css-sidenotes/narrow@2x.29fc379ed9b688.png 750w" sizes="auto, (max-width: 375px) 100vw, 375px" width="375" height="667" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXcAAAKbAQMAAAA0YFjZAAAAA1BMVEX++fBg48voAAAANklEQVR42u3BgQAAAADDoPlTX+AIVQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8A30QAAGQR2y6AAAAAElFTkSuQmCC)"/></span></div><figcaption>Rendu sous le paragraphe sur une fenêtre étroite</figcaption></figure>
<p>Dès que la fenêtre est assez large, elles apparaissent dans la marge, à la même
hauteur que l’appel de note correspondant, sauf si elles entrent en collision
avec une note précédente, comme dans l’exemple ci-dessous<sup id="fnref:runt"><a class="footnote-ref" href="#fn:runt">3</a></sup> :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 1427px"><span class="lf-media-inner" style="padding-bottom: 39.944%"><img alt="Les notes de marge affichées sur une fenêtre large apparaissent dans la marge.&#10;Il y en a deux. La première est alignée verticalement avec l'appel de note&#10;correspondant, tandis que la seconde est affichée plus bas, car elle entrerait&#10;sinon en collision avec la première." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/large@1x.8745117fd4dbc8.png" srcset="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/large@1x.8745117fd4dbc8.png 1794w,https://media-bernat-ch.b-cdn.net/images/css-sidenotes/large@2x.63cdd3f7a8d04d.png 2854w" sizes="auto, (max-width: 972px) 100vw, 900px" width="1427" height="570" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABZMAAAI6AQMAAABo8EQbAAAAA1BMVEX++O/08PdcAAAAeklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgykNcAAfcP/P8AAAAASUVORK5CYII=)"/></span></div><figcaption>Rendu dans la marge sur une fenêtre large</figcaption></figure>
<p>Le principe de l’ancrage CSS est de positionner un élément par rapport à un
autre élément, l’ancre. Pour les notes de marge, l’ancre est l’appel de note.
J’utilise le balisage suivant, avec un attribut de données pour indiquer le nom
de l’ancre :</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">sup</span> <span class="na">id</span><span class="o">=</span><span class="s">"fnref:YYY"</span> <span class="na">data-anchor</span><span class="o">=</span><span class="s">"--lf-sn-YYY"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">"#sidenote-YYY"</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
</code></pre></div>

<p>La note correspondante est un élément <code>&lt;aside&gt;</code> portant le même attribut de
données pour le nom de l’ancre. Elle est placée après le paragraphe contenant
l’appel de note :</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">aside</span> <span class="na">role</span><span class="o">=</span><span class="s">"note"</span> <span class="na">id</span><span class="o">=</span><span class="s">"sidenote-YYY"</span> <span class="na">data-anchor</span><span class="o">=</span><span class="s">"--lf-sn-YYY"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">sup</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Un premier paragraphe.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Un second paragraphe.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">aside</span><span class="p">&gt;</span>
</code></pre></div>

<p>Sur une fenêtre étroite ou lorsque le navigateur est trop ancien pour l’ancrage
CSS, la note reçoit une couleur atténuée et reste sous son paragraphe :</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">aside</span><span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">"note"</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-block</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="mh">#444</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Sur une fenêtre plus large et lorsque le navigateur est assez récent, la note
est déplacée dans la marge de droite :</p>
<div class="language-css codehilite"><pre><span/><code><span class="p">@</span><span class="k">supports</span><span class="w"> </span><span class="o">(</span><span class="nt">anchor-name</span><span class="o">:</span><span class="w"> </span><span class="nt">attr</span><span class="o">(</span><span class="nt">data-anchor</span><span class="w"> </span><span class="nt">type</span><span class="o">(&lt;</span><span class="nt">custom-ident</span><span class="o">&gt;)))</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="p">@</span><span class="k">media</span><span class="w"> </span><span class="o">(</span><span class="nt">min-width</span><span class="o">:</span><span class="w"> </span><span class="nt">72rem</span><span class="o">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">main</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">relative</span><span class="p">;</span>
<span class="w">      </span><span class="nt">sup</span><span class="o">[</span><span class="nt">data-anchor</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="hll"><span class="w">        </span><span class="k">anchor-name</span><span class="p">:</span><span class="w"> </span><span class="nb">attr</span><span class="p">(</span><span class="n">data-anchor</span><span class="w"> </span><span class="nf">type</span><span class="p">(</span><span class="o">&lt;</span><span class="n">custom-ident</span><span class="o">&gt;</span><span class="p">));</span>
</span><span class="w">        </span><span class="c">/* → anchor-name: --lf-sn-YYY */</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">      </span><span class="nt">aside</span><span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">"note"</span><span class="o">][</span><span class="nt">data-anchor</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="hll"><span class="w">        </span><span class="k">anchor-name</span><span class="p">:</span><span class="w"> </span><span class="nv">--lf-sidenote</span><span class="p">;</span>
</span><span class="w">        </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">absolute</span><span class="p">;</span>
<span class="hll"><span class="w">        </span><span class="k">position-anchor</span><span class="p">:</span><span class="w"> </span><span class="nb">attr</span><span class="p">(</span><span class="n">data-anchor</span><span class="w"> </span><span class="nf">type</span><span class="p">(</span><span class="o">&lt;</span><span class="n">custom-ident</span><span class="o">&gt;</span><span class="p">));</span>
</span><span class="w">        </span><span class="c">/* → position-anchor: --lf-sn-YYY */</span>
<span class="hll"><span class="w">        </span><span class="k">top</span><span class="p">:</span><span class="w"> </span><span class="nf">max</span><span class="p">(</span><span class="nf">anchor</span><span class="p">(</span><span class="kc">top</span><span class="p">),</span><span class="w"> </span><span class="nf">anchor</span><span class="p">(</span><span class="nv">--lf-sidenote</span><span class="w"> </span><span class="kc">bottom</span><span class="p">,</span><span class="w"> </span><span class="mi">-1</span><span class="kt">rlh</span><span class="p">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">);</span>
</span><span class="w">        </span><span class="k">left</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
<span class="w">        </span><span class="k">margin</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="mi">2</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">        </span><span class="k">width</span><span class="p">:</span><span class="w"> </span><span class="mi">18</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">        </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="kc">inherit</span><span class="p">;</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p><code>attr()</code> extrait le nom de l’ancre de l’appel de note depuis l’attribut
<code>data-anchor</code>. Elle renvoie une chaîne de caractères, sauf si une unité CSS ou
un type est précisé, comme ici : le navigateur analyse l’attribut de données
comme un <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/custom-ident" title="&lt;custom-ident&gt; on MDN">identifiant personnalisé</a>, que <code>anchor-name</code> valide
comme un <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/dashed-ident" title="&lt;dashed-ident&gt; on MDN">identifiant à tirets</a>, c’est-à-dire un identifiant
personnalisé commençant par deux tirets<sup id="fnref:attr-support"><a class="footnote-ref" href="#fn:attr-support">4</a></sup>.</p>
<p>La note elle-même est positionnée de manière absolue au-delà du bord droit du
bloc principal. Elle choisit l’appel de note correspondant comme ancre grâce à
<code>position-anchor</code>, dont la valeur provient de l’attribut <code>data-anchor</code>. Chaque
note est elle-même une ancre nommée <code>--lf-sidenote</code>. Elle sert à empêcher la
note suivante d’entrer en collision avec celle-ci.</p>
<p>La <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor" title="anchor() CSS function on MDN">fonction CSS <code>anchor()</code></a> permet de positionner le bord
supérieur de la note par rapport à son ancre : <code>anchor(top)</code> aligne le bord
supérieur de la note avec le bord supérieur de l’appel de note. Elle accepte
aussi une autre ancre en paramètre : <code>anchor(--lf-sidenote bottom)</code> alignerait
le bord supérieur de la note avec le bord inférieur de l’ancre précédente la
plus proche nommée <code>--lf-sidenote</code>, c’est-à-dire la note
précédente<sup id="fnref:reference"><a class="footnote-ref" href="#fn:reference">5</a></sup>. Comme <code>attr()</code>, <code>anchor()</code> accepte une valeur de repli
en second paramètre et l’utilise lorsque l’ancre nommée n’existe pas.</p>
<p>La propriété <code>top</code> gère trois cas, illustrés dans le schéma suivant :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 913px"><span class="lf-media-inner" style="padding-bottom: 72.289%"><img alt="Schéma de trois notes de marge ancrées à leurs appels de note. La première est&#10;alignée avec le haut de son propre appel de note, car aucune note ne la précède.&#10;La deuxième chevaucherait la première : elle prend donc le bas de la première&#10;note comme ancre et se place une ligne en dessous. La troisième arrive assez bas&#10;dans la page pour s'aligner de nouveau avec son propre appel de&#10;note." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/anchors.2e7e20fe1f5450.svg" width="913" height="660" class="lf-media"/></span></div><figcaption>Les trois cas pour la position verticale d'une note</figcaption></figure>
<ol>
<li>Le bord supérieur de la première note s’aligne avec le bord supérieur de son
   appel de note : en l’absence de note précédente, <code>anchor(--lf-sidenote
   bottom, -1rlh) + 1rlh</code> vaut 0 et <code>max()</code> renvoie <code>anchor(top)</code>.</li>
<li>Lorsque l’appel de note d’une note ultérieure se trouve au-dessus du bas de
   la note précédente, augmenté d’un espace vertical, la note passe sous
   la précédente pour éviter une collision. <code>max()</code> renvoie <code>anchor(--lf-sidenote
   bottom) + 1rlh</code>.</li>
<li>Sinon, <code>max()</code> renvoie <code>anchor(top)</code> et le bord supérieur de la note s’aligne
   avec le bord supérieur de l’appel de note.</li>
</ol>
<hr/>
<p>Jetez un œil à la <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/content/media/css/luffy1.sidenotes.css" title="Stylesheet for the sidenotes of this blog">feuille de style complète</a>, qui adapte aussi
l’appel de note à l’emplacement de la note : une flèche « ↓ » lorsque la note se
trouve sous le paragraphe, une flèche « → » lorsqu’elle part dans la marge.
« <a href="https://gwern.net/sidenote" title="Sidenotes In Web Design">Sidenotes In Web Design</a> », de Gwern, recense d’autres implémentations
et leurs compromis.</p>
<p><a href="https://danluu.com/bug-blind/" title="Bug blindness">Certains blogueurs</a> se fixent pour objectif d’écrire un billet en 30
minutes. Je comptais publier trois articles liés au web ce week-end. À la place,
j’ai passé un temps fou sur n’importe quoi : une quinzaine de changements sur le
système de construction du site, une contribution pour <a href="https://github.com/pygments/pygments/pull/3294" title="CSS: support nested rule">mettre à jour la
coloration CSS des sélecteurs imbriqués dans Pygments</a> et une
petite correction de l’article de MDN sur la <a href="https://github.com/mdn/content/pull/45532" title="Fix wording for return value of anchor() function">fonction CSS <code>anchor()</code></a>.
L’illustration SVG a demandé un peu moins d’une heure et l’article lui-même
quelques heures. La fonction <code>attr()</code> est arrivée après que je me suis demandé :
« n’y-a-t-il pas plus élégant que de placer le nom de l’ancre dans un style en
ligne ? » Mais bon, je trouve que ça en vaut malgré tout la peine ! 🎨</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:phd">
<p>Mon directeur de thèse m’a toujours dit que c’était une mauvaise pratique. <a class="footnote-backref" href="#fnref:phd" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:anchor-support">
<p>Les premières briques du positionnement par ancre sont <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/anchor-name#browser_compatibility" title="Browser compatibility for anchor-name on MDN">prises en
charge</a> à partir de Chrome 125 (mai 2024), Firefox 147 (janvier
2026) et Safari 26 (septembre 2025).</p>
<p>Avant Safari 26.5, les notes peuvent se chevaucher à cause d’un <a href="https://bugs.webkit.org/show_bug.cgi?id=315704" title="[anchor-position-1] anchor referencing an anchor of the same name does not resolve to the previous one.">bug</a> dans
la gestion des chaînes de dépendances. Il est possible de <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/1e6299da5c95dfce6549dfb20da2cea7c9e58664/content/media/js/luffy1.sidenotes.js">détecter cette
situation avec un peu de JavaScript</a>. Ce n’est toutefois pas nécessaire pour la solution décrite ici,
car elle repose sur une fonctionnalité plus récente. <a class="footnote-backref" href="#fnref:anchor-support" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:runt">
<p>Si vous avez remarqué la ligne creuse à la fin de la première note, je
partage votre peine et regrette amèrement que Firefox n’implémente pas
<code>text-wrap: pretty</code>. <a class="footnote-backref" href="#fnref:runt" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:attr-support">
<p>La fonction <code>attr()</code> typée est <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/attr#browser_compatibility" title="Browser compatibility for attr() on MDN">prise en charge</a> à partir de
Chrome 133 (février 2025), Firefox 155 (septembre 2026) et Safari 27
(septembre 2026). Consultez <a href="https://una.im/advanced-attr" title="New capabilities for attr()">l’article d’Una Kravets</a> pour plus de détails. Pour prendre en charge davantage de navigateurs,
il est possible de placer le nom de l’ancre et l’ancre de positionnement
directement dans le HTML :</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">sup</span> <span class="na">id</span><span class="o">=</span><span class="s">"…"</span> <span class="na">style</span><span class="o">=</span><span class="s">"anchor-name: --lf-sn-…"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">"#sidenote-…"</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
</code></pre></div>

<p>« <a href="https://blog.master.dev/managing-anchor-associations-with-data-attributes-and-advanced-attr/" title="Managing Anchor Associations With Data Attributes and Advanced attr()">Managing Anchor Associations With Data Attributes and Advanced
<code>attr()</code></a> », de Daniel Schwarz, explore plus
en détail les ancres CSS et la fonction <code>attr()</code> typée. <a class="footnote-backref" href="#fnref:attr-support" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:reference">
<p>La règle exacte pour l’<a href="https://drafts.csswg.org/css-anchor-position-1/#target-anchor-element" title="CSS Anchor Positioning: target anchor element">élément d’ancrage cible</a> est
plus complexe : « si un ancêtre de la note satisfait les conditions
suivantes, renvoyer celui qui est le plus proche de la note. Sinon, renvoyer
le dernier élément dans l’ordre de l’arbre qui satisfait ces conditions ».
L’une de ces conditions est que « le candidat est un <a href="https://drafts.csswg.org/css-anchor-position-1/#acceptable-anchor-element" title="CSS Anchor Positioning: acceptable anchor element">élément d’ancrage
acceptable</a> pour la note », ce qui exige que « le
candidat soit disposé strictement avant la note », la clause pertinente
étant que « le candidat n’est pas positionné de manière absolue ou apparaît
plus tôt que la note dans l’ordre de l’arbre à plat ». <a class="footnote-backref" href="#fnref:reference" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Tenir un blog bilingue avec l'aide d'une IA</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-blog-ia" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-blog-ia#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-06-09T20:15:13Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-blog-ia.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>L’<abbr title="Intelligence artificielle">IA</abbr> inonde le web de contenus insipides. Les articles sur <a href="https://www.linkedin.com/">LinkedIn</a> avec une
image générée par <abbr title="Intelligence artificielle">IA</abbr>, remplis d’émojis, publiés par des soi-disant experts sur
un sujet auquel ils ne portent pas assez d’intérêt pour écrire eux-mêmes, sont
les pires spécimens. Bien que je sois très contrarié de cette situation, je
m’appuie sur des <abbr title="Large Language Models">LLMs</abbr> pour la <strong>grammaire</strong>, les <strong>révisions linguistiques</strong> et
la <strong>traduction</strong>. Je ne trouve pas cela incohérent.</p>
<p>Ma langue maternelle est le français mais j’écris la plupart des articles en
anglais et en français. Quand j’ai commencé à écrire ce blog en 2011, je
rédigeais d’abord en <a href="/fr/blog/2011-migration-vers-github" title="Migration de Trac vers GitHub">français</a> avant de traduire en <a href="/en/blog/2011-migrating-to-github" title="Migrating from Trac to GitHub">anglais</a>.
J’ai fini par comprendre qu’il était <a href="https://sci-hub.fr/10.1016/j.jslw.2009.06.003" title="L1 use during L2 writing: An empirical study of a complex phenomenon">préférable de le faire dans l’autre
sens</a> afin de rendre le texte plus naturel
et idiomatique. J’essaie d’écrire un anglais « correct » mais cela n’a jamais
été mon point fort<sup id="fnref:book"><a class="footnote-ref" href="#fn:book">1</a></sup>. Par exemple, j’ai toujours eu des difficultés sur
l’accord des temps. J’apprends les règles et je les oublie immédiatement. Je ne
me vois pas non plus <a href="https://mtlynch.io/editor/" title="How I Hired a Freelance Editor for My Blog">embaucher un éditeur</a> pour quelque chose
que je considère comme un loisir.</p>
<p>À titre d’exemple, j’ai gardé l’historique des modifications successives lors de
l’écriture de « <em><a href="/en/blog/2026-akvorado-rib-sharding" title="Scaling Akvorado BMP RIB with sharding">Scaling Akvorado BMP RIB with sharding</a></em> » :</p>
<ol>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/aad263c20c7b021b1069952d1487374ff559d3b3" title="article: Akvorado BMP RIB with sharding">première ébauche</a> rédigée à l’aide d’un dictionnaire de synonymes<sup id="fnref:kagi"><a class="footnote-ref" href="#fn:kagi">2</a></sup> ;</li>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/11231af4be15160c1dd48e45c9b4dc7c042cf191" title="content: copyediting of Akvorado BMP RIB article">version modifiée</a> par la compétence « <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/.claude/skills/edit/SKILL.md">révision linguistique</a> » ;</li>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/0435ae3a8450132abb89507e856a012831457e49" title="article: Akvorado BMP RIB in French">traduction en français</a> générée par la compétence
   « <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/.claude/skills/translate/SKILL.md">traduction</a> » ;</li>
<li>la <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/c0629b1d9fe450335fcd30c071fb44301615dd15" title="content: human proofread of the French translation">version révisée par un humain</a> avec quelques modifications
   sur la version anglaise.</li>
</ol>
<p>Je sais que les <abbr title="Intelligence artificielle">IA</abbr> peuvent <a href="https://sites.google.com/view/llmwritingdistortion/home" title="How LLMs Distort Our Written Language">dénaturer la voix de l’auteur pendant
l’édition</a>, mais les corrections
apportées lors de la seconde étape sont mineures. Les instructions sont « d’appliquer de légères modifications de style » tout en demandant d’éviter les
formes passives, les phrases trop longues, les verbes peu expressifs et les mots
inutiles. Elles définissent également l’audience cible : technique avec un
niveau d’anglais B2.</p>
<p>Dans l’extrait suivant, j’ai utilisé « <em>long time</em> » au lieu de « <em>long-standing</em> ». Outre le trait d’union manquant, le premier s’applique à des
personnes (<em>a <a href="https://dictionary.cambridge.org/us/dictionary/english/long-time" title="Definition of long-time in the Cambridge Dictionary">long-time</a> friend</em>), tandis que le second concerne des
situations (<em>a <a href="https://dictionary.cambridge.org/us/dictionary/english/long-standing" title="Definition of long-standing in the Cambridge Dictionary">long-standing</a> agreement</em>). J’ai mis pas mal de temps à
comprendre la seconde modification : l’<abbr title="Intelligence artificielle">IA</abbr> préfère <a href="https://dictionary.cambridge.org/us/grammar/british-grammar/relative-clauses-defining-and-non-defining" title="Relative clauses: defining and non-defining">cette forme
grammaticale</a> pour introduire la définition de « <em>RIB
sharding</em> ».</p>
<blockquote>
<p>As the Internet routing table contains more than 1 million routes, Akvorado
needs to scale to tens of millions of routes. This has been a <del>long
time</del> <ins>long-standing</ins> challenge, but I expect this issue is now
fixed by using RIB sharding, a method <del>to split</del> <ins>that
splits</ins> the routing database into several parts to enable concurrent
updates.</p>
</blockquote>
<p>Dans la modification ci-dessous, l’<abbr title="Intelligence artificielle">IA</abbr> utilise le mot « <em>device</em> » plutôt que le
mot « <em>équipement</em> » car ce dernier est indénombrable. Je le sais mais je tombe
régulièrement dans ce piège.</p>
<blockquote>
<p>When Akvorado does not find a route from a specific device, it falls back to a
route sent by another <del>equipment</del> <ins>device</ins>.</p>
</blockquote>
<p>J’encourage l’<abbr title="Intelligence artificielle">IA</abbr> à utiliser des verbes descriptifs et elle s’exécute en
remplaçant une périphrase par un verbe précis :</p>
<blockquote>
<p>The benchmarks demonstrate it <del>has better performance than</del>
<ins>outperforms</ins> other <del>packages, both</del> <ins>packages</ins> for
lookups, insertions, and memory usage.</p>
</blockquote>
<p>Elle corrige aussi les erreurs de grammaire. Dans le passage suivant, « <em>list of
routes</em> » est au singulier. De plus, « <em>stored</em> » représente un état et il ne
faut pas le faire suivre de « <em>into</em> » qui indique un changement.</p>
<blockquote>
<p>The list of routes for each prefix <del>are</del> <ins>is</ins> not stored
directly <del>into</del> <ins>in</ins> the prefix tree.</p>
</blockquote>
<p>Voici un dernier exemple : le verbe « <em><a href="https://dictionary.cambridge.org/dictionary/english/require" title="Definition of require in the Cambridge Dictionary">require</a></em> » doit être suivi d’un nom
ou d’un objet suivi d’un infinitif. Il n’est pas possible de le faire suivre
directement par un infinitif.</p>
<blockquote>
<p>An alternative would be to have one prefix tree for each peer but it would
require <del>to configure</del> <ins>configuring</ins> all routers to export
their routes.</p>
</blockquote>
<p>Même si je lis beaucoup d’anglais<sup id="fnref:monkey"><a class="footnote-ref" href="#fn:monkey">3</a></sup>, je bute toujours sur ces règles de
grammaire. Le français est une langue relativement simple une fois les bases
acquises alors que l’anglais est rempli d’irrégularités.</p>
<hr/>
<p>J’indique dans chaque pied de page si une <abbr title="Intelligence artificielle">IA</abbr> a été utilisée pour modifier le
contenu. Il y a 3 niveaux :</p>
<ul>
<li>🧠: pas d’<abbr title="Intelligence artificielle">IA</abbr> ou pratiquement pas (par exemple, des corrections grammaticales)</li>
<li>✨: amélioré par l’<abbr title="Intelligence artificielle">IA</abbr> (par exemple, des révisions linguistiques)</li>
<li>🤖: généré par <abbr title="Intelligence artificielle">IA</abbr> (par exemple, traduit depuis une autre langue)</li>
</ul>
<p>Survolez avec la souris ou tapotez sur l’icône pour révéler le nom de l’<abbr title="Intelligence artificielle">IA</abbr> et
son rôle dans la modification du document.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 264px"><span class="lf-media-inner" style="padding-bottom: 45.833%"><img alt="Capture d'écran montrant le pied de page avec l'émoji&#10;« étincelles »" src="https://media-bernat-ch.b-cdn.net/images/ai-usage@1x.7529ae9860ad5e.jpg" srcset="https://media-bernat-ch.b-cdn.net/images/ai-usage@1x.7529ae9860ad5e.jpg 332w,https://media-bernat-ch.b-cdn.net/images/ai-usage@2x.3da5b6d120841a.jpg 528w" sizes="auto, (max-width: 264px) 100vw, 264px" width="264" height="121" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhEAAADyAQMAAADJHwVUAAAAA1BMVEUiIiLuYaiQAAAAJklEQVR42u3BAQ0AAADCoPdPbQ8HFAAAAAAAAAAAAAAAAAAAAPBiQEgAAWwH/fsAAAAASUVORK5CYII=)"/></span></div><figcaption>Exemple d'utilisation de transparence sur l'usage de l'IA : Claude Sonnet 4.5 a édité cet article.</figcaption></figure>
<p>Le graphique ci-dessous représente par année les outils utilisés pour modifier
chaque article. J’ai récemment demandé à une <abbr title="Intelligence artificielle">IA</abbr> des <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/.claude/skills/grammar/SKILL.md">corrections
grammaticales</a> sur les anciens articles, aussi bien en
<a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/7baf2c8f18b57e351cc25e7e62f4aa6611362c8c" title="content: fix English grammar">anglais</a> qu’en <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/0713a8800321da710c81ed10a731da5da34e686d" title="content: fix French grammar">français</a>. Depuis 2018, les
articles sont traduits en français à l’aide de <a href="https://www.deepl.com">DeepL</a> puis ensuite d’une <abbr title="Intelligence artificielle">IA</abbr>.
Depuis 2024, je m’appuie sur une <abbr title="Intelligence artificielle">IA</abbr> pour les révisions linguistiques des
articles en anglais.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 733px"><span class="lf-media-inner" style="padding-bottom: 68.349%"><object width="733" height="501" type="image/svg+xml" data="https://media-bernat-ch.b-cdn.net/images/ai-usage.21635bee7503a4.svg" class="lf-media">🖼 Graphique montrant l'utilisation de l'IA année par année. Chaque niveau a sa
propre teinte.</object></span></div><figcaption>Utilisation de l'IA année par année. Passez la souris sur une bande pour plus de détails.</figcaption></figure>
<hr/>
<p>Si vous êtes fermement opposé à l’utilisation des <abbr title="Large Language Model">LLM</abbr> dans l’écriture, j’espère
que vous acceptez mon point de vue plus nuancé comme une contrepartie pour des
articles en français, l’<abbr title="Intelligence artificielle">IA</abbr> mâchant énormément le travail de traduction. En ce
qui concerne l’anglais, la littérature sur le sujet s’accorde sur l’importance
de choisir le bon mot pour capturer l’attention du lecteur jusqu’à la fin.</p>
<blockquote>
<p>[…] Bien écrire consiste à maîtriser un certain nombre de choses
fondamentales, à savoir le vocabulaire, la grammaire et le style, et à remplir
le troisième compartiment de sa boîte à outils avec les bons instruments.</p>
<p>― <em>Stephen King</em>, On Writing</p>
</blockquote>
<div class="admonition">
<p class="admonition-title">Note</p>
<p>Contrairement à d’autres articles récents, je n’ai pas utilisé d’<abbr title="Intelligence artificielle">IA</abbr>, ni pour
traduire en français, ni pour relire la <a href="/en/blog/2026-blogging-llm" title="Blogging with LLMs as a non-native speaker">version anglaise</a>. Pour cette
dernière, une personne anonyme a accepté de corriger ma copie.</p>
</div>
<div class="admonition">
<p class="admonition-title">Mise à jour (06.2026)</p>
<p>Voir la <a href="https://lobste.rs/s/tdvu7a/blogging_with_llm_assistant">discussion associée à cet article sur Lobsters</a>, ainsi que
l’article « <a href="https://writethatblog.substack.com/p/dev-reaction-to-ai-blog-posts" title="Report: How developers react to AI-scented blog posts">How developers react to AI-scented blog posts</a> » de Cynthia
Dunlop, l’une des coautrices de « <a href="https://www.manning.com/books/writing-for-developers" title="“Writing for Developers” by Piotr Sarna and Cynthia Dunlop">Writing for Developers</a> ».</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:book">
<p>J’ai récemment lu « <a href="https://www.manning.com/books/writing-for-developers" title="“Writing for Developers” by Piotr Sarna and Cynthia Dunlop">Writing for Developers</a> » du début à la fin et
je l’ai trouvé motivant. <a href="https://mtlynch.io/about/">Michael Lynch</a> est en train d’écrire
« <a href="https://refactoringenglish.com/" title="“Refactoring English: Effective Writing for Software Developers” by Michael Lynch">Refactoring English</a> » sur le même sujet et je me suis inscrit à
l’accès anticipé. <a class="footnote-backref" href="#fnref:book" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:kagi">
<p>Je suis très satisfait des outils d’aide à l’écriture proposés par
<a href="https://www.kagi.com">Kagi</a>. L’<a href="https://translate.kagi.com">outil de traduction</a> et le
<a href="https://translate.kagi.com/dictionary">dictionnaire</a> me sont très utiles pour trouver le bon mot. Je
recours aussi à l’<a href="https://help.kagi.com/kagi/ai/kagi-research.html" title="Kagi Research Assistants">assistant de recherche de Kagi</a> lors de la phase de documentation. <a class="footnote-backref" href="#fnref:kagi" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:monkey">
<p>Dès le CM2, je jouais à <em>Monkey Island 2</em> en anglais sans l’avoir
appris à l’école. J’utilisais un dictionnaire et je restais perplexe à la
vue des verbes irréguliers. <a class="footnote-backref" href="#fnref:monkey" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">CSS &amp; rythme vertical pour le texte, les images et les tableaux</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2026-css-rythme-vertical" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2026-css-rythme-vertical#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-04-22T19:48:10Z</updated>
    <id>http://www.luffy.cx/fr/blog/2026-css-rythme-vertical.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>Le rythme vertical structure les lignes selon un espacement régulier de haut en
bas. Il permet de guider l’œil de manière fluide et prévisible. Grâce à l’unité
CSS <code>rlh</code>, ce rythme est simple à mettre en place pour le texte<sup id="fnref:pawel"><a class="footnote-ref" href="#fn:pawel">1</a></sup>. Mais
les illustrations et les tableaux peuvent perturber la mise en page. Le
typographe amateur en moi aspire à suivre la sagesse de Bringhurst :</p>
<blockquote>
<p>Les titres, intertitres, citations, notes de bas de page, illustrations,
légendes et autres intrusions dans le texte créent des syncopes et des
variations par rapport au rythme de base d’un interlignage régulier. Ces
variations peuvent et doivent animer la page, mais le texte principal doit
également reprendre, après chaque variation, précisément la mesure et la
phase.</p>
<p>― <em>Robert Bringhurst</em>, <a href="https://en.wikipedia.org/wiki/The_Elements_of_Typographic_Style" title="The Elements of Typographic Style sur Wikipédia">The Elements of Typographic Style</a></p>
</blockquote>
<h1 id="pour-le-texte">Pour le texte</h1>
<p>Trois facteurs régissent le rythme vertical : la <strong>taille de police</strong>, la
<strong>hauteur de ligne</strong> et la <strong>marge ou le remplissage</strong>. Posons la base avec une
police de 18 pixels et une hauteur de ligne de 1,5 :</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">html</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">112.5</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mf">1.5</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h1</span><span class="o">,</span><span class="w"> </span><span class="nt">h2</span><span class="o">,</span><span class="w"> </span><span class="nt">h3</span><span class="o">,</span><span class="w"> </span><span class="nt">h4</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">html</span><span class="o">,</span><span class="w"> </span><span class="nt">body</span><span class="o">,</span>
<span class="nt">h1</span><span class="o">,</span><span class="w"> </span><span class="nt">h2</span><span class="o">,</span><span class="w"> </span><span class="nt">h3</span><span class="o">,</span><span class="w"> </span><span class="nt">h4</span><span class="o">,</span>
<span class="nt">p</span><span class="o">,</span><span class="w"> </span><span class="nt">blockquote</span><span class="o">,</span>
<span class="nt">dl</span><span class="o">,</span><span class="w"> </span><span class="nt">dt</span><span class="o">,</span><span class="w"> </span><span class="nt">dd</span><span class="o">,</span><span class="w"> </span><span class="nt">ol</span><span class="o">,</span><span class="w"> </span><span class="nt">ul</span><span class="o">,</span><span class="w"> </span><span class="nt">li</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">padding</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Le <a href="https://www.w3.org/TR/css-values-4/" title="CSS Values and Units Module Level 4, W3C Working Draft">CSS Values and Units Module Level 4</a> définit l’unité <code>rlh</code>, égale à la
hauteur de ligne de l’élément racine. Tous les navigateurs la gèrent <a href="https://webstatus.dev/features/rlh" title="L'unité rlh sur Web Platform Status">depuis
2023</a><sup id="fnref:postcss"><a class="footnote-ref" href="#fn:postcss">2</a></sup>. Utilisez-la pour insérer des espaces verticaux ou
corriger la hauteur de ligne quand la taille de police change<sup id="fnref:calc"><a class="footnote-ref" href="#fn:calc">3</a></sup> :</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">h1</span><span class="o">,</span><span class="w"> </span><span class="nt">h2</span><span class="o">,</span><span class="w"> </span><span class="nt">h3</span><span class="o">,</span><span class="w"> </span><span class="nt">h4</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-top</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">margin-bottom</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h1</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">2.5</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h2</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">1.5</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h3</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">1.25</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">p</span><span class="o">,</span><span class="w"> </span><span class="nt">blockquote</span><span class="o">,</span><span class="w"> </span><span class="nt">pre</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-top</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">aside</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">0.875</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Vérifions le résultat en superposant une grille<sup id="fnref:grid"><a class="footnote-ref" href="#fn:grid">4</a></sup> au-dessus du texte :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 680px"><span class="lf-media-inner" style="padding-bottom: 104.853%"><img alt="Capture d'écran de mon site avec une grille en superposition et chaque ligne&#10;de texte alignée sur la grille" src="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/text@1x.86066b981d3272.png" srcset="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/text@1x.86066b981d3272.png 855w,https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/text@2x.2efca27c1a0b61.png 1360w" sizes="auto, (max-width: 680px) 100vw, 680px" width="680" height="713" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAqgAAALJAQMAAABC3kPgAAAAA1BMVEX59O2zBM/5AAAAUklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+DHvhgABu8TltwAAAABJRU5ErkJggg==)"/></span></div><figcaption>L'unité CSS <code>rlh</code> fonctionne bien pour régler les espaces verticaux du texte. Vous pouvez afficher la grille avec <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd>.</figcaption></figure>
<p>Si un sous-élément utilise une police aux métriques intrinsèques plus hautes, il
peut étirer la boîte de la ligne au-delà de la hauteur configurée<sup id="fnref:line-height"><a class="footnote-ref" href="#fn:line-height">5</a></sup>.
Une astuce consiste à ramener la hauteur de ligne à 1. Les glyphes débordent
mais ne poussent pas la ligne plus haut.</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">code</span><span class="o">,</span><span class="w"> </span><span class="nt">kbd</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="pour-les-images-adaptatives">Pour les images adaptatives</h1>
<p>Les images adaptatives, dont la taille s’ajuste à la largeur de l’affichage,
sont difficiles à aligner sur la grille car leur hauteur nous est inconnue. Le
<a href="https://www.w3.org/TR/css-rhythm-1/" title="CSS Rhythmic Sizing Module Level 1">CSS Rhythmic Sizing Module Level 1</a> introduit la propriété <code>block-step</code> pour
ajuster la hauteur d’un élément à un multiple d’une unité de pas. Cependant, la
plupart des navigateurs ne la gèrent pas encore.</p>
<p>Avec JavaScript, nous pouvons ajouter un remplissage autour de l’image pour
qu’elle ne perturbe pas le rythme vertical :</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">targets</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s2">".lf-media-outer"</span><span class="p">);</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">adjust</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nx">el</span><span class="p">,</span><span class="w"> </span><span class="nx">height</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">rlh</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">parseFloat</span><span class="p">(</span><span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">).</span><span class="nx">lineHeight</span><span class="p">);</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">padding</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">ceil</span><span class="p">(</span><span class="nx">height</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="nx">rlh</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="nx">rlh</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nx">height</span><span class="p">;</span>
<span class="w">  </span><span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">padding</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sb">`</span><span class="si">${</span><span class="nx">padding</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mf">2</span><span class="si">}</span><span class="sb">px 0`</span><span class="p">;</span>
<span class="p">};</span>

<span class="nx">targets</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">el</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nx">adjust</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">clientHeight</span><span class="p">));</span>
</code></pre></div>

<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 680px"><span class="lf-media-inner" style="padding-bottom: 104.853%"><img alt="Capture d'écran de mon site avec une grille en superposition et une image qui&#10;ne rompt pas le rythme vertical. Un remplissage supplémentaire est visible avant&#10;et après l'image. La hauteur de l'image avec son remplissage est de&#10;216." src="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/images@1x.fbe2d0c8a1faed.png" srcset="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/images@1x.fbe2d0c8a1faed.png 855w,https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/images@2x.cb1dff66666113.png 1360w" sizes="auto, (max-width: 680px) 100vw, 680px" width="680" height="713" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAqgAAALJAQMAAABC3kPgAAAAA1BMVEX38usGo+BAAAAAUklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+DHvhgABu8TltwAAAABJRU5ErkJggg==)"/></span></div><figcaption>L'image s'aligne sur la grille grâce au remplissage calculé par JavaScript. 216 est divisible par 27, notre hauteur de ligne dans cet exemple.</figcaption></figure>
<p>Comme l’image est adaptative, sa hauteur peut varier. Il faut appeler la
fonction <code>adjust()</code> dans un observateur :</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">ro</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">ResizeObserver</span><span class="p">((</span><span class="nx">entries</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">entry</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="nx">entries</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">const</span><span class="w"> </span><span class="nx">height</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">entry</span><span class="p">.</span><span class="nx">contentBoxSize</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="nx">blockSize</span><span class="p">;</span>
<span class="w">    </span><span class="nx">adjust</span><span class="p">(</span><span class="nx">entry</span><span class="p">.</span><span class="nx">target</span><span class="p">,</span><span class="w"> </span><span class="nx">height</span><span class="p">);</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">});</span>
<span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">target</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="nx">targets</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nx">ro</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">target</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="pour-les-tableaux">Pour les tableaux</h1>
<p>Les cellules d’un tableau pourraient fixer leur hauteur à <code>1rlh</code> mais
paraîtraient à l’étroit. Passer à <code>2rlh</code> gaspille trop d’espace. À la place,
nous utilisons l’<a href="https://markboulton.co.uk/journal/incremental-leading/" title="Incremental leading">interligne incrémental</a> : une ligne sur
cinq est alignée sur la grille.</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">table</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">border-spacing</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="kt">px</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">border-collapse</span><span class="p">:</span><span class="w"> </span><span class="kc">separate</span><span class="p">;</span>
<span class="w">  </span><span class="nt">th</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">padding</span><span class="p">:</span><span class="w"> </span><span class="mf">0.4</span><span class="kt">rlh</span><span class="w"> </span><span class="mi">1</span><span class="kt">em</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="w">  </span><span class="nt">td</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">padding</span><span class="p">:</span><span class="w"> </span><span class="mf">0.2</span><span class="kt">rlh</span><span class="w"> </span><span class="mf">0.5</span><span class="kt">em</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Pour aligner les éléments après le tableau, il faut ajouter un peu de
remplissage. Nous pouvons soit réutiliser le code JavaScript des images, soit
utiliser quelques lignes de CSS qui comptent les lignes régulières et calculent
le remplissage vertical manquant :</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w">   </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.2</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">1</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.8</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">2</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.4</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">3</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">4</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.6</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>

<p>Une cellule d’en-tête a deux fois le remplissage d’une cellule régulière. Avec
deux lignes régulières, le remplissage total vaut 2×2×0,2+2×0,4=1,6. Il faut
ajouter <code>0.4rlh</code> pour atteindre <code>2rlh</code> de remplissage vertical supplémentaire
autour du tableau.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 680px"><span class="lf-media-inner" style="padding-bottom: 86.765%"><img alt="Capture d'écran de mon site avec une grille en superposition et un tableau&#10;suivant le rythme vertical. Un remplissage supplémentaire est visible après le&#10;tableau. La hauteur du tableau avec son remplissage est de&#10;405." src="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/tables@1x.04a0681e41828c.png" srcset="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/tables@1x.04a0681e41828c.png 855w,https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/tables@2x.8f92851640c630.png 1360w" sizes="auto, (max-width: 680px) 100vw, 680px" width="680" height="590" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEQAAAA7AQMAAAD4qb7lAAAAA1BMVEWx3O2Q0L0rAAAAD0lEQVR42mNgGAWjgFoAAAJOAAFGvjzoAAAAAElFTkSuQmCC)"/></span></div><figcaption>Une ligne sur cinq est alignée sur la grille. Un remplissage supplémentaire est ajouté après le tableau pour ne pas rompre le rythme vertical. 405 est divisible par 27, notre hauteur de ligne dans cet exemple.</figcaption></figure>
<hr/>
<p>Rien de tout cela n’est indispensable, mais une fois l’œil entraîné, il devient
difficile de ne plus le remarquer. En attendant que les navigateurs implémentent
le <a href="https://www.w3.org/TR/css-rhythm-1/" title="CSS Rhythmic Sizing Module Level 1">CSS Rhythmic Sizing</a>, un mélange de
bidouilles CSS et d’un peu de JavaScript suffit. Le texte principal reprend
désormais après chaque intrusion « précisément la mesure et la phase ». 🎼</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:pawel">
<p>Voir « <a href="https://pawelgrzybek.com/vertical-rhythm-using-css-lh-and-rlh-units/" title="Vertical rhythm using CSS lh and rlh units">Vertical rhythm using CSS <code>lh</code> and <code>rlh</code> units</a> » par
Paweł Grzybek. Précédemment, une bonne référence était « <a href="http://lamb.cc/typograph/" title="Scale &amp; Rhythm">Scale &amp;
Rhythm</a> » de Iain Lamb. <a class="footnote-backref" href="#fnref:pawel" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:postcss">
<p>Pour une meilleure compatibilité, remplacez <code>2rlh</code> par
<code>calc(var(--line-height) * 2rem)</code> et définissez la propriété <code>--line-height</code>
dans la pseudo-classe <code>:root</code>. J’ai écrit un <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/95f29793faad50a94fe4199a1774d59d409d3755/extensions/css.js#L69-L79">petit greffon
PostCSS</a> à cet effet. <a class="footnote-backref" href="#fnref:postcss" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:calc">
<p>Il aurait été plus élégant de calculer la hauteur de ligne avec
<code>calc(round(up, calc(2.4rem / 1rlh), 0) * 1rlh)</code>. Cependant, l’arithmétique
typée n’est <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1264520" title="Support mixed number, unit value, and percent for calc() parser including type checking">pas encore implémentée par Firefox</a>. De plus, les navigateurs ne gèrent <code>round()</code> que <a href="https://webstatus.dev/features/round-mod-rem" title="La fonction round() sur Web Platform Status">depuis 2024</a>. J’ai donc codé un <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/95f29793faad50a94fe4199a1774d59d409d3755/extensions/css.js#L31-L67">autre greffon PostCSS</a> pour cela. <a class="footnote-backref" href="#fnref:calc" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:grid">
<p>Ce code CSS définit une grille qui suit la hauteur de ligne :</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">body</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">relative</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">body</span><span class="p">::</span><span class="nd">after</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">content</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">;</span>
<span class="w">  </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">absolute</span><span class="p">;</span>
<span class="w">  </span><span class="k">inset</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">z-index</span><span class="p">:</span><span class="w"> </span><span class="mi">9999</span><span class="p">;</span>
<span class="w">  </span><span class="k">background</span><span class="p">:</span><span class="w"> </span><span class="nb">linear-gradient</span><span class="p">(</span><span class="mi">180</span><span class="kt">deg</span><span class="p">,</span><span class="w"> </span><span class="mh">#c8e1ff</span><span class="mi">99</span><span class="w"> </span><span class="mi">1</span><span class="kt">px</span><span class="p">,</span><span class="w"> </span><span class="kc">transparent</span><span class="w"> </span><span class="mi">1</span><span class="kt">px</span><span class="p">);</span>
<span class="w">  </span><span class="k">background-size</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="kt">px</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">pointer-events</span><span class="p">:</span><span class="w"> </span><span class="kc">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p><a class="footnote-backref" href="#fnref:grid" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:line-height">
<p>Voir « <a href="https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align">Deep dive CSS: font metrics, line-height and vertical-align</a> » par Vincent De Oliveira. <a class="footnote-backref" href="#fnref:line-height" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Correction des décalages visuels causés par les polices web</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/fr/blog/2024-cls-webfonts" rel="alternate"/>
    <link href="https://vincent.bernat.ch/fr/blog/2024-cls-webfonts#isso-thread" rel="replies" type="text/html"/>
    <updated>2024-08-31T11:07:13Z</updated>
    <id>http://www.luffy.cx/fr/blog/2024-cls-webfonts.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>En 2020, Google a introduit les <a href="https://web.dev/articles/defining-core-web-vitals-thresholds" title="Defining the Core Web Vitals metrics thresholds">métriques Core Web Vitals</a> pour mesurer
certains aspects de l’expérience utilisateur sur le web. Ce blog a constamment
obtenu de bons scores pour deux de ces métriques : <a href="https://web.dev/articles/lcp" title="Largest Contentful Paint (LCP)">Largest Contentful Paint</a>
et <a href="https://web.dev/articles/inp" title="Interaction to Next Paint (INP)">Interaction to Next Paint</a>. Cependant, l’optimisation de la troisième
métrique, <a href="https://web.dev/articles/cls" title="Cumulative Layout Shift (CLS)">Cumulative Layout Shift</a>, qui mesure les changements de mise en
page inattendus, a été plus difficile. Soyons honnêtes : optimiser pour cette
métrique n’est pas vraiment utile pour un site comme celui-ci. Mais obtenir un
meilleur score est toujours une bonne distraction. 💯</p>
<p>Pour éviter le « flash de texte invisible » lors de l’utilisation de polices
web, les développeurs sont encouragés à placer la propriété <code>font-display</code> sur
<code>swap</code> dans les règles <code>@font-face</code>. Cette méthode permet aux navigateurs
d’afficher initialement le texte en utilisant une police de substitution, puis
de la remplacer par la police finale lorsqu’elle est chargée. Bien que cela
améliore le score <abbr title="Large Contentful Paint">LCP</abbr>, cela provoque des décalages de mise en page si les
polices de substitution n’ont pas des métriques compatibles. Ces décalages
affectent négativement le score <abbr title="Cumulative Layout Shift">CLS</abbr>. <abbr title="Cascading Style Sheets">CSS</abbr> fournit des propriétés pour résoudre ce
problème en adaptant les métriques des polices de substitution :
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/size-adjust"><code>size-adjust</code></a>, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/ascent-override"><code>ascent-override</code></a>,
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/descent-override"><code>descent-override</code></a> et
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/line-gap-override"><code>line-gap-override</code></a>.</p>
<p>Deux articles complets expliquent chaque propriété et leurs méthodes de calcul
en détail : <a href="https://www.aleksandrhovhannisyan.com/blog/perfect-font-fallbacks/">Creating Perfect Font Fallbacks in <abbr title="Cascading Style Sheets">CSS</abbr></a> et <a href="https://developer.chrome.com/blog/font-fallbacks/">Improved font
fallbacks</a>.</p>
<div class="toc">
<ul>
<li><a href="#outil-interactif">Outil interactif</a><ul>
<li><a href="#mode-demploi">Mode d’emploi</a></li>
<li><a href="#outil">Outil</a></li>
<li><a href="#resultats">Résultats</a></li>
</ul>
</li>
<li><a href="#a-propos-des-polices-personnalisees">À propos des polices personnalisées</a></li>
</ul>
</div>
<h1 id="outil-interactif">Outil interactif</h1>
<p>Au lieu de calculer chaque propriété à partir des métriques moyennes des
polices, j’ai créé un outil pour régler interactivement les métriques des
polices de substitution<sup id="fnref:others"><a class="footnote-ref" href="#fn:others">1</a></sup>.</p>
<h2 id="mode-demploi">Mode d’emploi</h2>
<ol>
<li>
<p>Chargez votre police personnalisée.</p>
</li>
<li>
<p>Sélectionnez une police de substitution.</p>
</li>
<li>
<p>Ajustez la propriété <code>size-adjust</code> pour faire correspondre la largeur de
   votre police personnalisée avec celle de la police de substitution. Avec une
   police proportionnelle, il n’est souvent pas possible d’obtenir une correspondance
   parfaite.</p>
</li>
<li>
<p>Affinez la propriété <code>ascent-override</code>. Essayez d’aligner le point final du
   dernier paragraphe tout en surveillant la ligne de base de la police. Pour un
   ajustement plus précis, désactivez l’option « <label for="lf1-support-line-height">Définir la hauteur de ligne</label> ».</p>
</li>
<li>
<p>Modifiez la propriété <code>descent-override</code>. L’objectif est de faire
   correspondre les deux boîtes. Vous devrez peut-être alterner entre cette
   propriété et la précédente pour obtenir des résultats optimaux.</p>
</li>
<li>
<p>Si nécessaire, ajustez la propriété <code>line-gap-override</code>. Cette étape n’est
   généralement pas requise.</p>
</li>
</ol>
<p>Le processus doit être répété pour chaque police de substitution. Certaines
plateformes peuvent ne pas inclure certaines polices. Notamment, Android n’a pas
la plupart des polices présentes dans d’autres systèmes d’exploitation. Il
remplace <em>Georgia</em> par <em>Noto Serif</em>, qui n’est pas compatible au niveau des
métriques.</p>
<h2 id="outil">Outil</h2>

<p class="when-rss">Cet outil n’est pas disponible depuis le flux Atom.</p>



<h2 id="resultats">Résultats</h2>
<p>Pour la police utilisée pour le texte de ce blog, j’obtiens la définition <abbr title="Cascading Style Sheets">CSS</abbr>
suivante :</p>
<div class="language-css codehilite"><pre><span/><code><span class="p">@</span><span class="k">font-face</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-family</span><span class="p">:</span><span class="w"> </span><span class="n">Merriweather</span><span class="p">;</span>
<span class="w">  </span><span class="k">font-style</span><span class="p">:</span><span class="w"> </span><span class="kc">normal</span><span class="p">;</span>
<span class="w">  </span><span class="k">font-weight</span><span class="p">:</span><span class="w"> </span><span class="mi">400</span><span class="p">;</span>
<span class="w">  </span><span class="n">src</span><span class="p">:</span><span class="w"> </span><span class="nb">url</span><span class="p">(</span><span class="s2">"../fonts/merriweather.woff2"</span><span class="p">)</span><span class="w"> </span><span class="nf">format</span><span class="p">(</span><span class="s2">"woff2"</span><span class="p">);</span>
<span class="w">  </span><span class="n">font-display</span><span class="p">:</span><span class="w"> </span><span class="n">swap</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">@</span><span class="k">font-face</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-family</span><span class="p">:</span><span class="w"> </span><span class="s2">"Fallback for Merriweather"</span><span class="p">;</span>
<span class="w">  </span><span class="n">src</span><span class="p">:</span><span class="w"> </span><span class="nf">local</span><span class="p">(</span><span class="s2">"Noto Serif"</span><span class="p">),</span><span class="w"> </span><span class="nf">local</span><span class="p">(</span><span class="s2">"Droid Serif"</span><span class="p">);</span>
<span class="w">  </span><span class="n">size-adjust</span><span class="p">:</span><span class="w"> </span><span class="mf">98.3</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">ascent-override</span><span class="p">:</span><span class="w"> </span><span class="mi">99</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">descent-override</span><span class="p">:</span><span class="w"> </span><span class="mi">27</span><span class="kt">%</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">@</span><span class="k">font-face</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-family</span><span class="p">:</span><span class="w"> </span><span class="s2">"Fallback for Merriweather"</span><span class="p">;</span>
<span class="w">  </span><span class="n">src</span><span class="p">:</span><span class="w"> </span><span class="nf">local</span><span class="p">(</span><span class="s2">"Georgia"</span><span class="p">);</span>
<span class="w">  </span><span class="n">size-adjust</span><span class="p">:</span><span class="w"> </span><span class="mi">106</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">ascent-override</span><span class="p">:</span><span class="w"> </span><span class="mf">90.4</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">descent-override</span><span class="p">:</span><span class="w"> </span><span class="mf">27.3</span><span class="kt">%</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">font-family</span><span class="o">:</span><span class="w"> </span><span class="nt">Merriweather</span><span class="o">,</span><span class="w"> </span><span class="s2">"Fallback for Merriweather"</span><span class="o">,</span><span class="w"> </span><span class="nt">serif</span><span class="o">;</span>
</code></pre></div>

<div class="admonition">
<p class="admonition-title">Mise à jour (10.2026)</p>
<p>J’ai remplacé Merriweather par <a href="https://fonts.google.com/specimen/Literata" title="Google Fonts: Literata">Literata</a> pour le corps du texte.</p>
</div>
<p>Après un mois, la métrique <abbr title="Cumulative Layout Shift">CLS</abbr> s’est bien améliorée à 0 :</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 959px"><span class="lf-media-inner" style="padding-bottom: 48.488%"><img alt="Scores Core Web Vitals pour vincent.bernat.ch montrant les 6 métriques dans le&#10;vert. Notamment, la métrique CLS à 0." src="https://media-bernat-ch.b-cdn.net/images/core-web-vitals.62966045af7dbc.svg" width="959" height="465" class="lf-media"/></span></div><figcaption>Scores Core Web Vitals pour vincent.bernat.ch</figcaption></figure>
<h1 id="a-propos-des-polices-personnalisees">À propos des polices personnalisées</h1>
<p>L’utilisation de <a href="https://www.cssfontstack.com/" title="CSS Font Stack">polices systèmes</a> ou d’un <a href="https://modernfontstacks.com/" title="System font stack CSS organized by typeface classification for every modern OS">ensemble de
polices</a> est souvent plus simple. Cependant, je préfère les
polices web personnalisées. <a href="https://ebensorkin.wordpress.com/" title="Merriweather Font">Merriweather</a> et <a href="https://typeof.net/Iosevka/" title="Iosevka Font">Iosevka</a>, qui sont utilisées
dans ce blog, améliorent l’expérience de lecture. Une approche alternative
serait d’utiliser Georgia comme police principale. Malheureusement, la plupart
des polices à chasse fixe par défaut sont laides.</p>
<p>De plus, les paragraphes qui combinent des polices à chasse variable et fixe
peuvent créer une perturbation visuelle. Cela se produit en raison des
différences des métriques verticales ou des graisses. Pour résoudre ce problème,
j’<a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/199addb942c68cbeb04c3c89a5fdee9da896785f/flake.nix#L233-L236">ajuste les métriques d’Iosevka</a> et sa graisse pour
les aligner avec les caractéristiques de Merriweather.</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:others">
<p>Des outils similaires existent déjà, comme le <a href="https://screenspan.net/fallback" title="Reduce Cumulative Layout Shift (CLS) by adjusting web fonts and system font fallbacks using special @font-face descriptors">Fallback Font
Generator</a>, mais il lui manque quelques fonctionnalités, comme la
possibilité de fournir la police de secours ou d’avoir des décimales pour
les propriétés <abbr title="Cascading Style Sheets">CSS</abbr>. Et pas de code source. <a class="footnote-backref" href="#fnref:others" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
</feed>