Proxy web frontal (Apache ou Nginx)

serve-web écoute uniquement en loopback (web.bind: "127.0.0.1:8080" par défaut) et sert tout sur ce port : l'interface web, les pages publiques (/p/...), la page d'aide d'une liste (/l/<id>/help, annoncée par List-Help), robots.txt, l'API REST (/api/v1/...) et les sondes (/healthz, /readyz). Il ne s'expose jamais directement : un proxy frontal termine le TLS et relaie les requêtes vers le loopback.

Le TLS au frontal est obligatoire, pas décoratif : serve-web marque ses cookies Secure (sauf --insecure, réservé au développement local). Servie en HTTP nu, l'interface serait inutilisable : le navigateur ne renverrait jamais le cookie de session. difuzio envoie lui-même Strict-Transport-Security (HSTS) et ses en-têtes de sécurité (CSP, X-Frame-Options, ...) : inutile de les dupliquer au frontal.

Le vhost porte le nom du base_url du domaine : les liens contenus dans les mails (confirmation, désabonnement) pointent dessus. Avec plusieurs domaines, dupliquer le vhost pour chaque base_url ; tous relaient vers le même backend.

Deux façons de servir l'interface

difuzio propose deux interfaces, qui se déploient différemment :

  • L'interface rendue par le serveur, historique, servie directement par serve-web. Elle ne demande aucun fichier statique : les vhosts ci-dessous suffisent tels quels.
  • La console d'administration (SPA), construite depuis webui/. C'est un jeu de fichiers statiques à déployer, l'API restant proxifiée vers le backend. Le fait de servir les deux sur la MÊME origine est ce qui permet au cookie de session de fonctionner sans CORS.

Pour la console, construire puis déployer le résultat :

make webui-install     # une seule fois, ou après une mise à jour des dépendances
make webui-build       # produit webui/dist/
rsync -a --delete webui/dist/ root@serveur:/var/www/difuzio-console/

Le vhost doit alors router dans cet ordre : les préfixes du backend (/api/, /p/, /l/, /robots.txt, /healthz, /readyz) vers serve-web, puis tout le reste vers les fichiers statiques, avec un repli sur index.html pour les routes internes de la console. L'ordre compte : /p/ et /l/ servent les liens contenus dans les mails (désabonnement, abonnement, archives, page d'aide annoncée par List-Help), ils ne doivent jamais être avalés par le repli de la SPA.

Les sections Nginx et Apache ci-dessous donnent d'abord la configuration minimale (interface serveur seule), puis le bloc à ajouter pour la console.

Nginx

/etc/nginx/sites-available/difuzio.conf :

# difuzio - vhost frontal ; un bloc server par base_url de domaine.
server {
    listen 80;
    listen [::]:80;
    server_name lists.example.org;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name lists.example.org;

    ssl_certificate     /etc/letsencrypt/live/lists.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/lists.example.org/privkey.pem;

    # L'API limite ses corps JSON à 1 MiB ; 2m laisse de la marge aux formulaires.
    client_max_body_size 2m;

    access_log /var/log/nginx/difuzio.access.log;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Host  $host;
        # web.write_timeout (60 s par défaut) doit rester sous ces valeurs.
        proxy_read_timeout 90s;
        proxy_send_timeout 90s;
    }
}

Activation :

ln -s /etc/nginx/sites-available/difuzio.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Servir aussi la console d'administration

Remplacer le location / ci-dessus par les trois blocs suivants. Les préfixes du backend sont déclarés AVANT le repli SPA, sinon /p/ et /api/ seraient avalés par try_files.

# 1. Le backend garde ses préfixes.
location ~ ^/(api|p|l|healthz|readyz|robots\.txt) {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_read_timeout 90s;
    proxy_send_timeout 90s;
}

# 2. Les fichiers de la console, avec repli sur index.html pour ses routes
#    internes (/domains, /audit, ...) qui n'existent pas sur le disque.
root /var/www/difuzio-console;
location / {
    try_files $uri $uri/ /index.html;
}

# 3. Les assets portent une empreinte dans leur nom : cache long et sûr.
#    index.html, lui, ne doit jamais être mis en cache, sinon un navigateur
#    continue de charger les anciens assets après une mise à jour.
#    Le `=404` est explicite à dessein : un asset absent doit donner un 404
#    franc, jamais index.html (voir Points d'attention).
location /assets/ {
    try_files $uri =404;
    expires 1y;
    add_header Cache-Control "public, immutable";
}
location = /index.html {
    add_header Cache-Control "no-store";
}

Apache

/etc/apache2/sites-available/difuzio.conf :

# difuzio - vhost frontal ; un bloc VirtualHost par base_url de domaine.
<VirtualHost *:80>
    ServerName lists.example.org
    Redirect permanent / https://lists.example.org/
</VirtualHost>

<VirtualHost *:443>
    ServerName lists.example.org

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/lists.example.org/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/lists.example.org/privkey.pem

    # Transmettre le Host d'origine au backend.
    ProxyPreserveHost On
    ProxyPass        / http://127.0.0.1:8080/ timeout=90
    ProxyPassReverse / http://127.0.0.1:8080/

    # mod_proxy_http ajoute X-Forwarded-For et X-Forwarded-Host lui-même.
    RequestHeader set X-Forwarded-Proto "https"

    # L'API limite ses corps JSON à 1 MiB ; 2 MiB de marge au frontal.
    LimitRequestBody 2097152

    ErrorLog  ${APACHE_LOG_DIR}/difuzio-error.log
    CustomLog ${APACHE_LOG_DIR}/difuzio-access.log combined
</VirtualHost>

Activation :

a2enmod proxy proxy_http ssl headers
a2ensite difuzio
apache2ctl configtest && systemctl reload apache2

Servir aussi la console d'administration

Remplacer le ProxyPass / ... global par un proxy limité aux préfixes du backend, puis servir les fichiers statiques. ProxyPass ... ! exclut explicitement le reste de l'arborescence du proxy, et FallbackResource assure le repli sur index.html pour les routes internes de la console. Aucun module supplémentaire à activer : FallbackResource vient de mod_dir, actif par défaut.

# 1. Le backend garde ses préfixes (déclarés AVANT l'exclusion).
ProxyPass        /api        http://127.0.0.1:8080/api        timeout=90
ProxyPassReverse /api        http://127.0.0.1:8080/api
ProxyPass        /p          http://127.0.0.1:8080/p          timeout=90
ProxyPassReverse /p          http://127.0.0.1:8080/p
ProxyPass        /l          http://127.0.0.1:8080/l          timeout=90
ProxyPassReverse /l          http://127.0.0.1:8080/l
ProxyPass        /robots.txt http://127.0.0.1:8080/robots.txt
ProxyPass        /healthz    http://127.0.0.1:8080/healthz
ProxyPass        /readyz     http://127.0.0.1:8080/readyz

# 2. Tout le reste sort du proxy et vient du disque. Le repli sur
#    index.html couvre les routes internes de la console (/domains,
#    /audit, ...) qui n'existent pas sur le disque ; il n'agit que si
#    aucun fichier ne correspond à l'URL demandée.
ProxyPass / !
DocumentRoot /var/www/difuzio-console
<Directory /var/www/difuzio-console>
    Require all granted
    Options -Indexes
    FallbackResource /index.html
</Directory>

# 3. Un asset absent doit donner un 404 franc, jamais index.html : sinon le
#    navigateur reçoit du HTML là où il attend du JavaScript et refuse de
#    charger la console (voir Points d'attention).
<Directory /var/www/difuzio-console/assets>
    FallbackResource disabled
</Directory>

# 4. Cache : long sur les assets empreintés, jamais sur index.html.
<LocationMatch "^/assets/">
    Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
<Location "/index.html">
    Header set Cache-Control "no-store"
</Location>

Ne PAS remplacer FallbackResource par la recette mod_rewrite que l'on trouve couramment pour les SPA :

# PIÈGE - ne fonctionne pas en contexte VirtualHost.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]

En contexte serveur ou VirtualHost, mod_rewrite s'exécute pendant la phase de traduction d'URL, avant le mappage vers le système de fichiers : %{REQUEST_FILENAME} y vaut alors la même chose que %{REQUEST_URI}. Le test -f porte donc sur /assets/index-XXXX.js à la racine du système de fichiers, qui n'existe jamais, et la condition est vraie pour TOUTES les requêtes : le serveur renvoie index.html même pour les fichiers réellement présents. Si l'on tient à mod_rewrite, préfixer explicitement par la racine documentaire (RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f), ou placer les règles dans le bloc <Directory>, où %{REQUEST_FILENAME} est résolu.

Points d'attention

  • Certificats : les chemins ci-dessus supposent certbot (certbot certonly --webroot ou --nginx/--apache) ; tout autre client ACME convient, seuls les chemins changent.

  • Le POST /p/unsubscribe-oneclick (désabonnement en un clic, RFC 8058) doit passer sans authentification ni challenge : si un WAF ou une protection bot est actif au frontal, l'exempter explicitement (voir Installation).

  • Timeouts : les valeurs proxy (90 s ci-dessus) doivent rester au-dessus de web.write_timeout (60 s par défaut) pour ne pas couper une réponse avant le backend.

  • Corps de requête : l'API refuse tout corps JSON au-delà de 1 MiB ; limiter à 2 MiB au frontal protège le backend sans gêner l'usage normal.

  • Console blanche et erreur de type MIME (le chargement du module a été bloqué en raison d'un type MIME interdit (text/html)) : le frontal renvoie index.html à la place du fichier .js ou .css demandé. Le type annoncé est correct, c'est le contenu qui est faux. Le diagnostic tient en une commande, à comparer avec la taille réelle de l'asset sur le disque :

      curl -sI https://lists.example.org/assets/index-XXXX.js

    Un Content-Length de quelques centaines d'octets sur un fichier qui en pèse des centaines de milliers signe un repli SPA qui avale les assets : soit webui/dist/assets/ n'a pas été déployé (rsync sans récursion, mauvaise racine documentaire, droits de lecture manquants pour l'utilisateur du serveur web), soit la règle de repli se déclenche à tort - voir le piège mod_rewrite de la section Apache. Un asset manquant doit répondre 404, et ce 404 distingue immédiatement les deux cas.

  • /metrics n'est pas sur ce port : il vit sur metrics.bind, surchargé par instance de worker par le template systemd (127.0.0.1:909N pour worker@N), et ne se proxifie pas publiquement (voir Exploitation).

  • Les sondes /healthz et /readyz se consultent en local (curl http://127.0.0.1:8080/readyz) : la supervision n'a pas besoin de passer par le frontal.

  • Adresse cliente : difuzio résout l'IP cliente depuis X-Forwarded-For quand la connexion vient d'un proxy déclaré dans web.trusted_proxies (le défaut couvre le loopback, donc les vhosts ci-dessus fonctionnent tels quels). Si le frontal est sur une autre machine, ajouter son adresse à la liste ; voir Configuration.