Paillasse/ansible/roles/portail/templates/nginx-portail.conf.j2
Adam Belkacemi e549f03e1b ajoute le dossier WebDAV de l'élève (bloc C)
Dossier personnel de l'élève monté comme lecteur réseau : rclone
sert le dossier local, l'authentification est déléguée au portail
via auth_request Nginx (mêmes identifiants que la connexion), sans
nouveau système de comptes. Nettoyé par la purge RGPD comme les
autres données d'élève.

Corrige au passage un vrai bug d'isolation trouvé en testant avec
deux comptes jetables : rewrite injectait la variable posée par
auth_request_set, mais rewrite s'exécute dans une phase Nginx
antérieure à cette variable, donc toujours vide - un élève pouvait
lire le fichier d'un autre en devinant son nom. Corrigé par une
location à regex nommée + proxy_pass. Détails : ADR 0008.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 13:04:40 +02:00

221 lines
8.3 KiB
Django/Jinja

# Géré par Ansible (rôle portail) — ne pas modifier à la main.
{# Bloc A (ADR 0006) : quand le relais TURN est actif, le port public 443
est pris par le routeur Nginx par nom (stream, nginx.conf) — ces vhost
HTTPS n'écoutent alors plus qu'en interne, sur 127.0.0.1:8443, où ce
routeur leur transmet tout ce qui n'est pas pour coturn. #}
{%- macro listen443(default_server=False) -%}
{%- if portail_turn_actif | default(false) %}
listen 127.0.0.1:8443 ssl http2{{ ' default_server' if default_server }};
{%- else %}
listen 443 ssl http2{{ ' default_server' if default_server }};
listen [::]:443 ssl http2{{ ' default_server' if default_server }};
{%- endif -%}
{% endmacro -%}
{% macro bloc_portail() %}
root {{ portail_dossier_app }}/public;
index index.php;
client_max_body_size 20m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Relaie la console série (étape 9) en WebSocket vers le proxy Go. Le
# jeton après /console/ est à usage court (5 min), jamais l'URL Nova.
location /console/ {
proxy_pass http://127.0.0.1:8081/console/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php{{ portail_php_version }}-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Bloc C : dossier de l'élève en WebDAV (rclone en local, voir
# 09-webdav.yml). "internal" : jamais appelable depuis l'extérieur,
# seulement par la sous-requête auth_request ci-dessous.
# $request_uri d'une sous-requête auth_request reflète la requête
# d'origine ("/webdav/…", pas "/interne/webdav-auth") : constaté le
# 2026-10-14 (404 systématique côté Laravel, qui routait donc sur le
# mauvais chemin). D'où REQUEST_URI écrit en dur ci-dessous — l'URI de
# cette location est fixe, il n'y a rien d'autre qu'elle ne pourrait
# valoir.
location = /interne/webdav-auth {
internal;
fastcgi_pass unix:/run/php/php{{ portail_php_version }}-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME {{ portail_dossier_app }}/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param REQUEST_URI /interne/webdav-auth;
fastcgi_param REQUEST_METHOD GET;
}
# "rewrite" s'exécute dans une phase antérieure à auth_request_set (le
# rewrite verrait donc toujours $eleve_dossier vide, et chaque élève
# atterrirait à la racine commune du dossier — constaté le 2026-10-14,
# un élève pouvait lire les fichiers d'un autre). proxy_pass avec le nom
# capturé par la regex de la location, lui, est évalué après coup.
location ~ ^/webdav/(?<chemin_webdav>.*)$ {
auth_request /interne/webdav-auth;
auth_request_set $eleve_dossier $upstream_http_x_eleve_dossier;
error_page 401 = @webdav_non_authentifie;
client_max_body_size 100m;
proxy_pass http://127.0.0.1:{{ portail_port_webdav }}/$eleve_dossier/$chemin_webdav;
proxy_http_version 1.1;
}
# Sans cela, un explorateur de fichiers (Windows/macOS) ne propose
# jamais de ressaisir les identifiants après un échec.
location @webdav_non_authentifie {
add_header WWW-Authenticate 'Basic realm="Syllabus WebDAV"' always;
return 401;
}
location ~ /\.(?!well-known) {
deny all;
}
access_log /var/log/nginx/portail-access.log;
error_log /var/log/nginx/portail-error.log;
{% endmacro %}
{% if portail_https_actif %}
# HTTP : tout part en HTTPS. Les noms connus gardent leur nom ; une requête
# sur l'IP ou un nom inconnu est envoyée vers le domaine principal.
server {
listen 80;
listen [::]:80;
server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %}{% if portail_visio_actif | default(false) %} {{ portail_domaine_visio }}{% endif %}{% if portail_turn_actif | default(false) %} {{ portail_domaine_turn }}{% endif %};
server_tokens off;
# Dans une location (pas au niveau server) : la location exacte que
# certbot ajoute pour le défi ACME au renouvellement reste prioritaire.
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
server_tokens off;
location / {
return 301 https://{{ portail_domaine }}$request_uri;
}
}
# www → domaine principal (une seule adresse canonique).
server {
{{ listen443() }}
server_name {{ portail_domaines_alias | join(' ') }};
server_tokens off;
include snippets/syllabus-tls.conf;
include snippets/syllabus-entetes.conf;
return 301 https://{{ portail_domaine }}$request_uri;
}
server {
{{ listen443(default_server=True) }}
server_name {{ portail_domaine }};
server_tokens off;
include snippets/syllabus-tls.conf;
include snippets/syllabus-entetes.conf;
{{ bloc_portail() }}
}
{% if portail_statut_actif %}
# Page de statut publique d'Uptime Kuma, et RIEN d'autre : l'administration
# (tableau de bord, /socket.io, /metrics…) reste accessible par tunnel SSH
# uniquement. Chemins relevés dans server/routers/status-page-router.js.
server {
{{ listen443() }}
server_name {{ portail_domaine_statut }};
server_tokens off;
include snippets/syllabus-tls.conf;
include snippets/syllabus-entetes.conf;
location = / {
return 302 /status/{{ portail_statut_slug }};
}
location ~ ^/(status/[a-z0-9-]+$|api/status-page/|assets/|upload/|icon\.svg$|favicon\.ico$|apple-touch-icon[a-z0-9-]*\.png$|robots\.txt$) {
limit_except GET HEAD {
deny all;
}
proxy_pass http://127.0.0.1:{{ portail_port_uptime_kuma }};
# Kuma 1.23 ne répond jamais pour un identifiant de page inconnu
# (constaté le 2026-10-09) : ne pas faire attendre le visiteur.
proxy_read_timeout 15s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
location / {
return 404;
}
access_log /var/log/nginx/statut-access.log;
error_log /var/log/nginx/statut-error.log;
}
{% endif %}
{% if portail_visio_actif | default(false) %}
# Visio (Jitsi Meet, rôle visio). Pas l'extrait d'en-têtes commun : son
# X-Frame-Options et sa Permissions-Policy interdiraient l'intégration dans
# le portail et la caméra. Intégration permise au seul portail.
server {
{{ listen443() }}
server_name {{ portail_domaine_visio }};
server_tokens off;
include snippets/syllabus-tls.conf;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors https://{{ portail_domaine }}" always;
# Pas de page d'accueil Jitsi : on n'entre dans une salle que depuis le portail.
location = / {
return 302 https://{{ portail_domaine }}/;
}
location /xmpp-websocket {
proxy_pass http://127.0.0.1:{{ portail_port_visio }};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}
location / {
proxy_pass http://127.0.0.1:{{ portail_port_visio }};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
access_log /var/log/nginx/visio-access.log;
error_log /var/log/nginx/visio-error.log;
}
{% endif %}
{% else %}
# Avant le premier certificat : HTTP seul, avec les noms du domaine pour que
# certbot (authentificateur nginx) trouve le bon bloc.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }} _;
server_tokens off;
{{ bloc_portail() }}
}
{% endif %}