# 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/(?.*)$ { 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 %}