passe le portail en HTTPS sur syllabuslab.fr, avec en-têtes de sécurité et CSP
Certificat Let's Encrypt obtenu et renouvelé par certbot (authentificateur nginx), expiration surveillée par Prometheus. TLS selon Mozilla v6.0 intermediate, HSTS, CSP stricte avec nonce sur l'application et adaptée sur la vitrine, xterm.js servi par le portail. Page de statut prévue sur status.syllabuslab.fr, administration de Grafana et Kuma par tunnel seul. Corrige aussi une faille XSS dans la page de classe, la page /cours en erreur depuis l'étape 8, et NodeSource relancé à chaque déploiement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa004c0736
commit
618cd7efbe
30 changed files with 987 additions and 24 deletions
|
|
@ -6,3 +6,13 @@ portail_mysql_user: portail
|
|||
portail_dossier_docker: /etc/portail
|
||||
portail_dossier_app: /var/www/portail
|
||||
portail_php_version: "8.3"
|
||||
|
||||
# Étape 12 : HTTPS
|
||||
portail_domaine: syllabuslab.fr
|
||||
portail_domaines_alias: [www.syllabuslab.fr]
|
||||
# Page de statut publique d'Uptime Kuma : activée seulement quand ce nom
|
||||
# résout vers le serveur (le rôle le vérifie à chaque passage).
|
||||
portail_domaine_statut: status.syllabuslab.fr
|
||||
portail_statut_slug: syllabus
|
||||
portail_ip_publique: 163.172.4.195
|
||||
portail_port_uptime_kuma: 3001
|
||||
|
|
|
|||
|
|
@ -82,7 +82,9 @@
|
|||
- name: ajouter le dépôt NodeSource
|
||||
ansible.builtin.shell:
|
||||
cmd: curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
creates: /etc/apt/sources.list.d/nodesource.list
|
||||
# Le script crée un fichier deb822 (.sources) : avec l'ancien nom
|
||||
# (.list), il était relancé en root à chaque déploiement.
|
||||
creates: /etc/apt/sources.list.d/nodesource.sources
|
||||
|
||||
- name: installer nodejs
|
||||
ansible.builtin.apt:
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@
|
|||
- { cle: "LABS_DOSSIER", valeur: "/opt/labs" }
|
||||
# > timeout des superviseurs Horizon (300 s, config/horizon.php)
|
||||
- { cle: "REDIS_QUEUE_RETRY_AFTER", valeur: "330" }
|
||||
# Étape 12 : HTTPS. Cookies de session envoyés en HTTPS seulement.
|
||||
- { cle: "APP_URL", valeur: "https://{{ portail_domaine }}" }
|
||||
- { cle: "SESSION_SECURE_COOKIE", valeur: "true" }
|
||||
no_log: true
|
||||
register: portail_env_mis_a_jour
|
||||
# Horizon charge la configuration une seule fois au démarrage : sans
|
||||
|
|
|
|||
|
|
@ -1,9 +1,50 @@
|
|||
---
|
||||
- name: déployer le vhost Nginx du portail
|
||||
# Étape 12 : HTTPS par Let's Encrypt. certbot du dépôt Debian (2.1, mis à jour
|
||||
# avec le reste du système par unattended-upgrades), authentificateur nginx
|
||||
# en mode « certonly » : il ne modifie jamais nos fichiers de configuration,
|
||||
# Ansible reste seul maître du vhost.
|
||||
- name: installer Nginx et certbot
|
||||
ansible.builtin.apt:
|
||||
name: [nginx, certbot, python3-certbot-nginx]
|
||||
state: present
|
||||
|
||||
- name: déployer les extraits TLS et en-têtes de sécurité
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "/etc/nginx/snippets/{{ item }}"
|
||||
mode: "0644"
|
||||
loop:
|
||||
- syllabus-tls.conf
|
||||
- syllabus-entetes.conf
|
||||
notify: recharger nginx
|
||||
|
||||
# La page de statut n'est servie que si son nom pointe déjà vers ce serveur :
|
||||
# sinon Let's Encrypt refuserait tout le certificat.
|
||||
- name: vérifier le DNS du sous-domaine de statut
|
||||
ansible.builtin.command: getent ahostsv4 {{ portail_domaine_statut }}
|
||||
register: portail_dns_statut
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: noter si la page de statut peut être activée
|
||||
ansible.builtin.set_fact:
|
||||
portail_statut_actif: "{{ portail_ip_publique in portail_dns_statut.stdout }}"
|
||||
portail_noms_certificat: >-
|
||||
{{ [portail_domaine] + portail_domaines_alias
|
||||
+ ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else []) }}
|
||||
|
||||
- name: vérifier si le certificat existe déjà
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/letsencrypt/live/{{ portail_domaine }}/fullchain.pem"
|
||||
register: portail_certificat_avant
|
||||
|
||||
- name: déployer le vhost (HTTP seul tant qu'aucun certificat n'existe)
|
||||
ansible.builtin.template:
|
||||
src: nginx-portail.conf.j2
|
||||
dest: /etc/nginx/sites-available/portail.conf
|
||||
mode: "0644"
|
||||
vars:
|
||||
portail_https_actif: "{{ portail_certificat_avant.stat.exists }}"
|
||||
notify: recharger nginx
|
||||
|
||||
- name: activer le vhost
|
||||
|
|
@ -27,3 +68,57 @@
|
|||
loop:
|
||||
- "php{{ portail_php_version }}-fpm"
|
||||
- nginx
|
||||
|
||||
- name: appliquer la configuration Nginx avant le défi Let's Encrypt
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
# --keep-until-expiring : ne fait rien si le certificat est valide et couvre
|
||||
# déjà ces noms. --expand : ajoute un nom (ex. statut) au même certificat.
|
||||
# Pas d'e-mail : Let's Encrypt n'envoie plus d'avis d'expiration depuis 2025,
|
||||
# et le renouvellement est surveillé par Prometheus (voir supervision).
|
||||
- name: obtenir ou étendre le certificat Let's Encrypt
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --nginx --non-interactive --agree-tos
|
||||
--register-unsafely-without-email
|
||||
--cert-name {{ portail_domaine }}
|
||||
{% for nom in portail_noms_certificat %}-d {{ nom }} {% endfor %}
|
||||
--expand --keep-until-expiring
|
||||
--deploy-hook "systemctl reload nginx"
|
||||
register: portail_certbot
|
||||
changed_when: "'Successfully received certificate' in portail_certbot.stdout"
|
||||
|
||||
- name: passer le vhost en HTTPS
|
||||
ansible.builtin.template:
|
||||
src: nginx-portail.conf.j2
|
||||
dest: /etc/nginx/sites-available/portail.conf
|
||||
mode: "0644"
|
||||
vars:
|
||||
portail_https_actif: true
|
||||
notify: recharger nginx
|
||||
|
||||
- name: vérifier que le renouvellement automatique est planifié
|
||||
ansible.builtin.systemd:
|
||||
name: certbot.timer
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# Pas d'e-mail Let's Encrypt : c'est Prometheus qui prévient si le
|
||||
# renouvellement ne se fait pas (règle CertificatExpireBientot).
|
||||
- name: déployer le script de mesure de l'expiration du certificat
|
||||
ansible.builtin.template:
|
||||
src: syllabus-expiration-certificat.sh.j2
|
||||
dest: /usr/local/sbin/syllabus-expiration-certificat
|
||||
mode: "0755"
|
||||
|
||||
- name: mesurer l'expiration chaque jour
|
||||
ansible.builtin.cron:
|
||||
name: "expiration du certificat HTTPS (métrique Prometheus)"
|
||||
user: root
|
||||
hour: "6"
|
||||
minute: "17"
|
||||
job: /usr/local/sbin/syllabus-expiration-certificat
|
||||
|
||||
- name: mesurer l'expiration maintenant
|
||||
ansible.builtin.command: /usr/local/sbin/syllabus-expiration-certificat
|
||||
changed_when: false
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Géré par Ansible (rôle portail) — ne pas modifier à la main.
|
||||
{% macro bloc_portail() %}
|
||||
root {{ portail_dossier_app }}/public;
|
||||
index index.php;
|
||||
|
||||
|
|
@ -36,4 +33,95 @@ server {
|
|||
|
||||
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 %};
|
||||
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 {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
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 {
|
||||
listen 443 ssl http2 default_server;
|
||||
listen [::]:443 ssl http2 default_server;
|
||||
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 {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
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 }};
|
||||
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 %}
|
||||
{% 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 %}
|
||||
|
|
|
|||
10
ansible/roles/portail/templates/syllabus-entetes.conf.j2
Normal file
10
ansible/roles/portail/templates/syllabus-entetes.conf.j2
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Géré par Ansible (rôle portail). En-têtes communs à tous les sites HTTPS.
|
||||
# La Content-Security-Policy du portail est posée par Laravel
|
||||
# (App\Http\Middleware\PolitiqueSecuriteContenu) : elle dépend de la page.
|
||||
# Attention : un add_header dans un bloc location masque ceux du server.
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh
|
||||
# Géré par Ansible (rôle portail). Publie la date d'expiration du certificat
|
||||
# HTTPS pour Prometheus (collecteur textfile de node_exporter) : alerte
|
||||
# CertificatExpireBientot si le renouvellement automatique échoue.
|
||||
set -eu
|
||||
CERT=/etc/letsencrypt/live/{{ portail_domaine }}/fullchain.pem
|
||||
DOSSIER=/var/lib/node_exporter/textfile
|
||||
FIN=$(date -d "$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2)" +%s)
|
||||
cat > "$DOSSIER/certificat.prom.tmp" <<METRIQUE
|
||||
# HELP syllabus_certificat_expiration_timestamp Date d'expiration du certificat HTTPS (secondes Unix).
|
||||
# TYPE syllabus_certificat_expiration_timestamp gauge
|
||||
syllabus_certificat_expiration_timestamp{domaine="{{ portail_domaine }}"} $FIN
|
||||
METRIQUE
|
||||
mv "$DOSSIER/certificat.prom.tmp" "$DOSSIER/certificat.prom"
|
||||
14
ansible/roles/portail/templates/syllabus-tls.conf.j2
Normal file
14
ansible/roles/portail/templates/syllabus-tls.conf.j2
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Géré par Ansible (rôle portail). Mozilla SSL Configuration Guidelines v6.0,
|
||||
# profil « intermediate » (vieux postes et Chromebooks compris), adapté :
|
||||
# - pas de X25519MLKEM768 (OpenSSL 3.5 requis, Debian 12 a la 3.0) ;
|
||||
# - pas d'OCSP stapling (Let's Encrypt ne fournit plus d'OCSP depuis 2025) ;
|
||||
# - suites ECDHE uniquement : aucun paramètre Diffie-Hellman à générer.
|
||||
ssl_certificate /etc/letsencrypt/live/{{ portail_domaine }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ portail_domaine }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ecdh_curve X25519:prime256v1:secp384r1;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SyllabusTLS:10m;
|
||||
ssl_session_tickets off;
|
||||
61
docs/adr/0004-https-csp-et-exposition.md
Normal file
61
docs/adr/0004-https-csp-et-exposition.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# ADR 0004 — HTTPS, CSP en deux niveaux, et ce qui est exposé sur Internet
|
||||
|
||||
- **Date** : 2026-10-09
|
||||
- **Statut** : accepté
|
||||
|
||||
## Contexte
|
||||
|
||||
Étape 12 : le portail passe sur `syllabuslab.fr` en HTTPS. Il faut un
|
||||
certificat qui se renouvelle seul, des en-têtes de sécurité, une CSP
|
||||
« adaptée à xterm.js », Grafana et Uptime Kuma derrière authentification,
|
||||
et une page de statut publique.
|
||||
|
||||
## Décisions
|
||||
|
||||
1. **certbot du dépôt Debian, `certonly` avec l'authentificateur nginx.**
|
||||
Il obtient et renouvelle le certificat sans jamais modifier nos fichiers :
|
||||
Ansible reste seul maître du vhost. Pas d'e-mail ACME (Let's Encrypt
|
||||
n'envoie plus d'avis d'expiration) : c'est Prometheus qui alerte
|
||||
(`CertificatExpireBientot`, moins de 14 jours).
|
||||
2. **TLS : Mozilla v6.0 « intermediate »** (TLS 1.2 et 1.3, suites ECDHE),
|
||||
pour les vieux postes des établissements. Sans X25519MLKEM768 (OpenSSL
|
||||
3.0 sur Debian 12) ni OCSP stapling (abandonné par Let's Encrypt).
|
||||
HSTS 2 ans avec `includeSubDomains`, sans `preload` (réversible).
|
||||
3. **CSP en deux niveaux, posée par Laravel** (elle dépend de la page) :
|
||||
- *application* (connexion, inscription, toute page authentifiée) :
|
||||
`script-src 'self' 'nonce-…'`. Les scripts en ligne portent le nonce
|
||||
de la requête ; xterm.js est servi par le portail (Vite) et non plus
|
||||
par jsDelivr ; les gestionnaires `onclick`/`onsubmit` sont remplacés
|
||||
par des écouteurs. Une injection de script serait bloquée ;
|
||||
- *vitrine* (pages reprises du design) : scripts en ligne et CDN
|
||||
autorisés. Ces pages contiennent 52 scripts en ligne et le CDN
|
||||
d'exécution de Tailwind ; elles n'affichent aucune donnée d'utilisateur.
|
||||
Les violations sont remontées à `/csp-rapport` (journal Laravel).
|
||||
4. **Administration de Grafana et d'Uptime Kuma : tunnel SSH seulement**
|
||||
(choix d'Adam). Il faut la clé SSH, puis le mot de passe de l'outil.
|
||||
Rien de plus n'est exposé sur Internet.
|
||||
5. **Page de statut sur `status.syllabuslab.fr`** : Nginx ne laisse passer
|
||||
que la page de statut, son API en lecture (GET/HEAD) et ses fichiers
|
||||
statiques. Tableau de bord, `/socket.io` (connexion admin) et `/metrics`
|
||||
renvoient 404. Le rôle n'active ce site que si le nom résout vers le
|
||||
serveur, et ajoute alors le nom au certificat (`--expand`).
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **CSP stricte partout** : demanderait de réécrire les ~50 pages du design
|
||||
(scripts en ligne, Tailwind par CDN). Reporté (voir `docs/dette.md`).
|
||||
- **Grafana et Kuma publics derrière mot de passe** : pratique, mais exposés
|
||||
à la force brute pour aucun besoin actuel.
|
||||
- **certbot en snap** (recommandation du projet certbot) : une deuxième
|
||||
chaîne de mises à jour à surveiller ; le paquet Debian suit
|
||||
unattended-upgrades comme le reste.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Toute nouvelle page de l'application qui ajoute un script doit lui donner
|
||||
`nonce="{{ Vite::cspNonce() }}"`, sinon le navigateur le bloque (et le
|
||||
signale dans le journal).
|
||||
- `Permissions-Policy` interdit caméra et micro : à ouvrir pour la visio
|
||||
(étape 15).
|
||||
- Les tests et scripts doivent passer par `https://syllabuslab.fr` : le
|
||||
cookie de session est `secure`.
|
||||
21
docs/dette.md
Normal file
21
docs/dette.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Dette technique
|
||||
|
||||
Ce qui est connu, accepté pour l'instant, et à traiter. Une ligne par sujet ;
|
||||
retirer la ligne (et le noter dans le journal) quand c'est fait.
|
||||
|
||||
| Sujet | Pourquoi c'est une dette | Quand | Origine |
|
||||
|---|---|---|---|
|
||||
| **Image Uptime Kuma non figée** (`louislam/uptime-kuma:1`) | Une version différente peut arriver à n'importe quel redéploiement, sans test. Figer un tag précis (voire le digest) dans une variable du rôle `supervision`, et documenter la mise à jour par Ansible (changer la variable, relancer le rôle, vérifier) — jamais par le bouton de l'interface. | Avant le pilote | Demande d'Adam, 2026-10-09 |
|
||||
| Larastan (PHPStan niveau 6) absent | Exigé par le CLAUDE.md depuis l'étape 8, jamais installé. | Bloc B | Étape 8 |
|
||||
| Temps d'ouverture d'un lab : p95 non mesuré | Deux mesures seulement (4,06 et 4,35 s, critère < 5 s). Il faut une série. | Étape 13 | Étape 11 |
|
||||
| Capacité : 15 VM au pire cas | Une classe de 30 sur le lab 08 demande 90 VM. Mesurer en charge, décider du surengagement mémoire (KSM). | Étape 13 | Étape 11 |
|
||||
| Vitrine : Tailwind par CDN d'exécution (`cdn.tailwindcss.com`, 19 pages) | Non destiné à la production selon Tailwind, lent, et oblige la vitrine à tolérer les scripts en ligne dans sa CSP. Compiler ces styles avec Vite. | Avant ouverture publique | Étape 12 (design) |
|
||||
| Vitrine : 52 scripts en ligne, Google Fonts, jsDelivr (démos xterm, CodeMirror) | CSP vitrine avec `'unsafe-inline'` ; CDN parfois filtrés par les réseaux scolaires. Auto-héberger polices et bibliothèques, passer les scripts en fichiers. | Avant ouverture publique | Étape 12 |
|
||||
| xterm.js 5.5.0 (6.0.0 disponible) | Version majeure à évaluer séparément. | Bloc B | Étape 12 |
|
||||
| NodeSource installé par `curl … \| bash` | Script distant exécuté en root au premier déploiement. Déclarer le dépôt (deb822) avec une clé vérifiée. | Bloc B | Étape 8 |
|
||||
| Aucune vérification dans un vrai navigateur | Extension Chrome non connectée (étapes 8 et 12). La CSP n'est contrôlée que par ses rapports. | Dès que possible | Étapes 8 et 12 |
|
||||
| Pas de sonde hors du serveur | Si le serveur tombe, personne n'est prévenu. | À décider avec Adam | Étape 7 |
|
||||
| Kolla installé depuis une branche, pas un tag | Reproductibilité. | Montée de version | ADR 0001 |
|
||||
| Correction séquentielle (~5 s pour le lab 08) | Acceptable ; à paralléliser si la charge l'exige. | Étape 13 | Étape 11 |
|
||||
| Tâches Ansible toujours « modifiées » (composer, npm, build, cache) | Brouille la lecture de l'idempotence. | Bloc B | Étape 8 |
|
||||
| `Permissions-Policy` ferme caméra et micro | À ouvrir pour la visio. | Étape 15 | Étape 12 |
|
||||
|
|
@ -462,3 +462,58 @@ Le stock chaud transforme une attente de 30 s en une attente de 4 s en faisant l
|
|||
**Vérifié** : `ss -tlnp` → `LISTEN 127.0.0.1:3001 users:(("node",...))` et rien d'autre ; `curl http://127.0.0.1:3001/` → 302 (redirection vers la page d'accueil de Kuma) ; port 3001 fermé sur l'IP publique depuis le Mac ; tables nftables inchangées ; second passage du playbook `changed=0`.
|
||||
|
||||
**Comprendre** : « healthy » veut dire que le conteneur répond *à lui-même*, pas qu'il est joignable de l'extérieur. Le seul test qui prouve qu'un service est accessible, c'est de s'y connecter par le chemin réel (ici : écoute sur l'hôte, puis tunnel). En réseau hôte, le conteneur partage la pile réseau du serveur : l'adresse d'écoute devient la seule barrière, d'où la vérification automatique ajoutée au rôle.
|
||||
|
||||
## 2026-10-09 — Étape 12 : domaine, HTTPS et sécurité
|
||||
|
||||
**Fait**
|
||||
|
||||
- DNS vérifié : `syllabuslab.fr` et `www` → `163.172.4.195`, pas d'AAAA ni de CAA.
|
||||
- **Certificat Let's Encrypt** (certbot 2.1 du dépôt Debian, `certonly` avec l'authentificateur nginx, sans e-mail) : `syllabuslab.fr` + `www`, valable jusqu'au 2027-01-07. Le rôle `portail` sert d'abord le site en HTTP avec les bons noms, obtient le certificat, puis passe le vhost en HTTPS ; aux passages suivants, certbot ne fait rien tant que le certificat est valide. Renouvellement par `certbot.timer`, rechargement de Nginx en crochet.
|
||||
- **Alerte d'expiration** : sans e-mail Let's Encrypt, c'est Prometheus qui prévient (métrique publiée chaque jour par le collecteur textfile, règles `CertificatExpireBientot` à moins de 14 jours et `CertificatNonMesure`).
|
||||
- **TLS** : recommandations Mozilla v6.0 « intermediate », lues à la source (TLS 1.2/1.3, suites ECDHE), sans `X25519MLKEM768` (OpenSSL 3.0) ni OCSP stapling (abandonné par Let's Encrypt). HTTP/2.
|
||||
- **Redirections** : HTTP → HTTPS, `www` → domaine principal, IP → domaine.
|
||||
- **En-têtes** (Nginx) : HSTS 2 ans `includeSubDomains`, `nosniff`, `X-Frame-Options DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `server_tokens off`. Cookie de session `secure; httponly; samesite=lax`, `APP_URL` en HTTPS.
|
||||
- **CSP en deux niveaux** (Laravel, ADR 0004) : stricte avec nonce sur l'application, adaptée sur la vitrine. xterm.js est maintenant servi par le portail (Vite) au lieu de jsDelivr. Point de collecte `/csp-rapport`.
|
||||
- **Exposition** (choix d'Adam) : Grafana et Uptime Kuma restent accessibles par tunnel SSH seulement. Page de statut publique prévue sur `status.syllabuslab.fr`, filtrée par Nginx (seuls la page de statut et son API en lecture passent). Elle s'activera toute seule au passage du rôle quand le DNS existera.
|
||||
- `docs/dette.md` créé : la dette éparpillée dans le journal est regroupée, avec la demande d'Adam (figer l'image Uptime Kuma et la mettre à jour par Ansible).
|
||||
- 5 nouveaux tests Pest (23 au total) : CSP stricte et adaptée, nonce sur chaque script, rapport sans CSRF, toutes les pages publiques en 200.
|
||||
|
||||
**Problèmes rencontrés et solution**
|
||||
|
||||
1. **Faille XSS dans la page de classe** (étape 8) : `onsubmit="return confirm('… {{ $eleve->prenom }} …')"`. Blade échappe l'apostrophe en `'`, que le navigateur décode avant d'exécuter le JavaScript : un prénom importé par CSV comme `x');alert(1);//` s'exécutait chez le prof. Corrigé (attribut `data-confirmer` + écouteur), et la CSP stricte bloquerait désormais ce genre de script.
|
||||
2. **Page `/cours` en erreur 500 depuis l'étape 8** : la maquette `design/` elle-même a perdu la boucle et les données des cursus lors de sa refonte (3 `@foreach` pour 4 `@endforeach`), et `config/cours.php` n'avait pas été copié. Rétabli d'après `design/_backup-ludique` (sans toucher à `design/`). Le journal de l'étape 8 affirmait que « toutes les pages vitrine fonctionnent » : faux pour celle-ci. Un test ouvre maintenant toutes les pages publiques ; vérifié qu'il échoue sur l'ancienne version.
|
||||
3. **NodeSource relancé en root à chaque déploiement** : la condition `creates` visait `nodesource.list`, alors que le script crée `nodesource.sources`. Corrigé ; remplacement par un dépôt déclaratif noté en dette.
|
||||
4. Le scan depuis le Mac montrait 21, 554 et 1723 ouverts : c'est la box d'Adam (même résultat vers `192.0.2.1`, adresse inexistante). Côté serveur, rien n'écoute et nftables n'accepte que 22/80/443.
|
||||
5. Vérification dans un vrai navigateur impossible (extension Chrome non connectée) : la CSP est surveillée par ses rapports ; parcours manuel demandé à Adam.
|
||||
6. Ma commande de contrôle des en-têtes a affiché la valeur d'un cookie de session anonyme (ouvert par `curl`, sans connexion, donc sans valeur) ; le filtre de masquage visait un mauvais nom de cookie.
|
||||
|
||||
**Critère vérifié**
|
||||
|
||||
```
|
||||
subject=CN = syllabuslab.fr ; issuer=Let's Encrypt (YE2) ; notAfter=Jan 7 07:21:22 2027 GMT
|
||||
https://syllabuslab.fr -> HTTP 200, TLS vérifié: 0 (0 = OK), HTTP/2
|
||||
http://163.172.4.195/ -> 301 https://syllabuslab.fr/ ; https://www.syllabuslab.fr/cours -> 301 https://syllabuslab.fr/cours
|
||||
TLS 1.1 : refusé ; TLSv1.3, TLS_AES_256_GCM_SHA384
|
||||
certbot renew --dry-run : all simulated renewals succeeded
|
||||
Prometheus : CertificatExpireBientot:ok, CertificatNonMesure:ok ; jours restants : 90.0
|
||||
```
|
||||
|
||||
Parcours complet par `https://syllabuslab.fr`, consoles en `wss://` :
|
||||
|
||||
```
|
||||
1. Admission → invite de connexion : 4.35 s (OK, critère < 5 s)
|
||||
2. Sans routage activé : « Pas encore réussi » 6/10, avec les messages du lab
|
||||
3. Routage activé : « Réussi » 10/10
|
||||
```
|
||||
|
||||
- Ports : depuis Internet, seuls 22, 80 et 443 répondent pour le serveur ; les ports internes (Grafana, Prometheus, Kuma, API OpenStack, proxy console, MariaDB, Redis) sont tous filtrés.
|
||||
- VM d'élèves : aucune IP flottante, aucun routeur Neutron dans le projet ; depuis `pc-a`, Internet et l'hôte sont injoignables (« Network is unreachable »).
|
||||
|
||||
**Comprendre**
|
||||
|
||||
HTTPS ne se résume pas au cadenas : le certificat prouve l'identité du site, HSTS interdit au navigateur de revenir en HTTP, et la CSP décide quels scripts ont le droit de s'exécuter. Cette dernière est la vraie protection contre l'injection de code : même si un prénom piégé arrivait dans une page, le navigateur refuserait de l'exécuter faute du nonce, ce jeton aléatoire que seul le serveur connaît pour chaque page. La faille trouvée dans la page de classe montre pourquoi : l'échappement de Blade protège le HTML, mais pas du JavaScript placé dans un attribut — deux langages imbriqués, deux règles d'échappement. Enfin, le renouvellement automatique n'est fiable que s'il est surveillé : d'où l'alerte Prometheus, puisque plus personne n'enverra d'e-mail avant l'expiration.
|
||||
|
||||
**Suite**
|
||||
|
||||
- Adam : enregistrement DNS `status`, page de statut `syllabus` dans Kuma, sonde du portail passée en `https://syllabuslab.fr`, parcours manuel dans un navigateur (pour la CSP).
|
||||
- Étape 13 : tests de capacité et de résistance.
|
||||
|
|
|
|||
60
docs/runbooks/https.md
Normal file
60
docs/runbooks/https.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Runbook — HTTPS, en-têtes de sécurité, page de statut (étape 12)
|
||||
|
||||
## Où sont les choses
|
||||
|
||||
| Élément | Emplacement |
|
||||
|---|---|
|
||||
| Certificat | `/etc/letsencrypt/live/syllabuslab.fr/` (un seul certificat : `syllabuslab.fr`, `www`, et `status` quand son DNS existe) |
|
||||
| Renouvellement | `certbot.timer` (deux fois par jour), authentificateur nginx, puis `systemctl reload nginx` |
|
||||
| Alerte d'expiration | métrique `syllabus_certificat_expiration_timestamp` (script `/usr/local/sbin/syllabus-expiration-certificat`, cron quotidien), règles `CertificatExpireBientot` et `CertificatNonMesure` (`kolla/config/prometheus/alertes.rules`) |
|
||||
| Vhost | `ansible/roles/portail/templates/nginx-portail.conf.j2` → `/etc/nginx/sites-available/portail.conf` |
|
||||
| TLS et en-têtes | `/etc/nginx/snippets/syllabus-tls.conf`, `syllabus-entetes.conf` (rôle `portail`) |
|
||||
| CSP | `portail/app/Http/Middleware/PolitiqueSecuriteContenu.php` ; violations dans `storage/logs/laravel.log` (« Violation CSP ») |
|
||||
| Décisions | [ADR 0004](../adr/0004-https-csp-et-exposition.md) |
|
||||
|
||||
Tout est posé par `ansible-playbook portail.yml`.
|
||||
|
||||
## Vérifier
|
||||
|
||||
```
|
||||
curl -sSI https://syllabuslab.fr/connexion | grep -iE "strict-transport|content-security|x-frame"
|
||||
echo | openssl s_client -connect syllabuslab.fr:443 -servername syllabuslab.fr 2>/dev/null | openssl x509 -noout -dates -ext subjectAltName
|
||||
ssh paillasse "sudo certbot renew --dry-run" # renouvellement simulé (environnement de test Let's Encrypt)
|
||||
ssh paillasse "sudo grep -c 'Violation CSP' /var/www/portail/storage/logs/laravel.log"
|
||||
```
|
||||
|
||||
## Activer la page de statut
|
||||
|
||||
1. Ajouter chez OVH : `status.syllabuslab.fr` A `163.172.4.195`.
|
||||
2. Vérifier qu'il est publié : `dig +short A status.syllabuslab.fr @ns111.ovh.net`.
|
||||
3. Dans Uptime Kuma (par tunnel), créer une page de statut avec l'identifiant (slug) `syllabus` et y mettre les sondes à publier.
|
||||
4. `cd ansible && script -q /dev/null ansible-playbook portail.yml -i inventory.ini` : le rôle détecte le nom, l'ajoute au certificat et active le site.
|
||||
5. Vérifier : `https://status.syllabuslab.fr` affiche la page ; `https://status.syllabuslab.fr/dashboard` et `/metrics` répondent 404.
|
||||
|
||||
## Si le certificat va expirer (alerte)
|
||||
|
||||
```
|
||||
ssh paillasse "systemctl status certbot.timer; sudo journalctl -u certbot --since -3d | tail -30"
|
||||
ssh paillasse "sudo certbot renew"
|
||||
```
|
||||
|
||||
Causes probables : port 80 inaccessible depuis Internet, DNS modifié,
|
||||
configuration Nginx invalide (`sudo nginx -t`). Limite Let's Encrypt :
|
||||
5 échecs de validation par heure et par nom — corriger avant de réessayer.
|
||||
|
||||
## Ajouter un script à une page de l'application
|
||||
|
||||
La CSP stricte bloque tout script sans le nonce de la requête :
|
||||
|
||||
```
|
||||
<script nonce="{{ Vite::cspNonce() }}">…</script>
|
||||
```
|
||||
|
||||
Pas de `onclick="…"` : utiliser `addEventListener` dans un script avec nonce.
|
||||
Pas de CDN : passer par Vite (`resources/js/`).
|
||||
|
||||
## Pièges (évités ou rencontrés)
|
||||
|
||||
- Évité par conception : une redirection `return 301` au niveau `server` s'exécute avant toute `location`, elle risquerait de court-circuiter le défi ACME que certbot insère au renouvellement. Les redirections sont dans `location /` ; `certbot renew --dry-run` réussit.
|
||||
- `X25519MLKEM768` (recommandé par Mozilla v6.0) demande OpenSSL 3.5 ; Debian 12 a OpenSSL 3.0 : retiré par précaution (non essayé). À réintroduire au passage à Debian 13.
|
||||
- Depuis Internet, un scan peut montrer 21, 554 et 1723 ouverts : c'est la box du réseau qui scanne (même résultat vers `192.0.2.1`, adresse inexistante). Côté serveur : `sudo ss -tlnp` et `sudo nft list chain inet filter input`.
|
||||
|
|
@ -28,6 +28,24 @@ groups:
|
|||
summary: "Un agent OpenStack est arrete"
|
||||
description: "Un agent Nova ou Neutron signale un etat different de UP depuis plus de 2 minutes."
|
||||
|
||||
- alert: CertificatExpireBientot
|
||||
expr: (syllabus_certificat_expiration_timestamp - time()) < 14 * 86400
|
||||
for: 1h
|
||||
labels:
|
||||
severite: critique
|
||||
annotations:
|
||||
summary: "Le certificat HTTPS de {{ $labels.domaine }} expire dans moins de 14 jours"
|
||||
description: "Le renouvellement automatique (certbot.timer) a echoue : voir docs/runbooks/https.md."
|
||||
|
||||
- alert: CertificatNonMesure
|
||||
expr: absent(syllabus_certificat_expiration_timestamp)
|
||||
for: 2d
|
||||
labels:
|
||||
severite: avertissement
|
||||
annotations:
|
||||
summary: "L'expiration du certificat HTTPS n'est plus mesuree"
|
||||
description: "Le fichier certificat.prom du collecteur textfile n'est plus produit : verifier le cron syllabus-expiration-certificat."
|
||||
|
||||
- alert: SauvegardeAncienne
|
||||
expr: (time() - syllabus_derniere_sauvegarde_reussie_timestamp) > 26 * 3600
|
||||
for: 10m
|
||||
|
|
|
|||
31
portail/app/Http/Controllers/RapportCspController.php
Normal file
31
portail/app/Http/Controllers/RapportCspController.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Reçoit les violations de CSP envoyées par les navigateurs : c'est le seul
|
||||
* moyen de savoir qu'une page casse chez un élève. Seuls quelques champs
|
||||
* sont gardés, tronqués, sans la chaîne de requête des URL.
|
||||
*/
|
||||
class RapportCspController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$rapport = json_decode($request->getContent(), true)['csp-report'] ?? null;
|
||||
|
||||
if (is_array($rapport)) {
|
||||
Log::warning('Violation CSP', [
|
||||
'page' => Str::limit(strtok((string) ($rapport['document-uri'] ?? ''), '?'), 200),
|
||||
'directive' => Str::limit((string) ($rapport['violated-directive'] ?? $rapport['effective-directive'] ?? ''), 100),
|
||||
'bloque' => Str::limit(strtok((string) ($rapport['blocked-uri'] ?? ''), '?'), 200),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
87
portail/app/Http/Middleware/PolitiqueSecuriteContenu.php
Normal file
87
portail/app/Http/Middleware/PolitiqueSecuriteContenu.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Content-Security-Policy, en deux niveaux (étape 12) :
|
||||
*
|
||||
* - application (connexion, inscription, toute page authentifiée) : seuls
|
||||
* nos scripts et ceux qui portent le nonce de la requête s'exécutent. Une
|
||||
* injection de script dans un nom d'élève, par exemple, serait bloquée
|
||||
* par le navigateur ;
|
||||
* - vitrine (pages reprises du design) : scripts en ligne et CDN autorisés,
|
||||
* faute de pouvoir les réécrire ici. Ces pages n'affichent aucune donnée
|
||||
* d'utilisateur.
|
||||
*
|
||||
* Les violations sont envoyées à /csp-rapport (journal Laravel).
|
||||
* Les autres en-têtes (HSTS, nosniff…) sont posés par Nginx.
|
||||
*/
|
||||
class PolitiqueSecuriteContenu
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
Vite::useCspNonce();
|
||||
|
||||
$reponse = $next($request);
|
||||
|
||||
if (! $reponse->headers->has('Content-Security-Policy')) {
|
||||
$reponse->headers->set('Content-Security-Policy', $this->politique($request));
|
||||
}
|
||||
|
||||
return $reponse;
|
||||
}
|
||||
|
||||
private function politique(Request $request): string
|
||||
{
|
||||
$communes = [
|
||||
'default-src' => "'self'",
|
||||
'img-src' => "'self' data:",
|
||||
'font-src' => "'self' https://fonts.gstatic.com",
|
||||
'object-src' => "'none'",
|
||||
'base-uri' => "'self'",
|
||||
'form-action' => "'self'",
|
||||
'frame-ancestors' => "'none'",
|
||||
'report-uri' => '/csp-rapport',
|
||||
];
|
||||
|
||||
$directives = $this->estApplication($request)
|
||||
? [
|
||||
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'",
|
||||
// Attributs style= présents dans le gabarit commun : sans
|
||||
// risque d'exécution de code, tolérés.
|
||||
'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
'connect-src' => "'self' wss://{$request->getHost()}",
|
||||
]
|
||||
: [
|
||||
'script-src' => "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com",
|
||||
'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
|
||||
'connect-src' => "'self'",
|
||||
'frame-src' => 'https://www.youtube-nocookie.com',
|
||||
];
|
||||
|
||||
return collect($communes + $directives)
|
||||
->map(fn (string $valeur, string $directive) => "{$directive} {$valeur}")
|
||||
->implode('; ');
|
||||
}
|
||||
|
||||
private function estApplication(Request $request): bool
|
||||
{
|
||||
$route = $request->route();
|
||||
|
||||
if ($route === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($route->getName(), ['login', 'register'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return collect($route->gatherMiddleware())
|
||||
->contains(fn ($middleware) => is_string($middleware) && str_starts_with($middleware, 'auth:'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\PolitiqueSecuriteContenu;
|
||||
use App\Http\Middleware\VerifieSessionUnique;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
|
|
@ -16,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
$middleware->alias([
|
||||
'session.unique' => VerifieSessionUnique::class,
|
||||
]);
|
||||
$middleware->web(append: [PolitiqueSecuriteContenu::class]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
|
|
|||
264
portail/config/cours.php
Normal file
264
portail/config/cours.php
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<?php
|
||||
|
||||
// Catalogue des cours gratuits, inspiré des référentiels de l'Éducation nationale.
|
||||
// types : CM (cours magistral), TD (travaux dirigés), TP (travaux pratiques sur VM), QCM.
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'nsi',
|
||||
'nom' => 'Lycée NSI',
|
||||
'sous_titre' => 'Première et Terminale · spécialité NSI',
|
||||
'couleur' => 'bg-[#FFD23F]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Première · Représentation des données',
|
||||
'cours' => [
|
||||
['titre' => 'Entiers naturels : binaire et hexadécimal', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Entiers relatifs et complément à deux', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Nombres flottants et leurs pièges', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Codage des caractères : ASCII, Unicode, UTF-8', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Première · Architectures et systèmes',
|
||||
'cours' => [
|
||||
['titre' => 'Modèle de von Neumann', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Système d’exploitation et commandes Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Droits et permissions des fichiers', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Réseau : TCP/IP et protocole du bit alterné', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Première · Web et données',
|
||||
'cours' => [
|
||||
['titre' => 'HTTP, formulaires GET et POST', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Traitement de données en tables CSV', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Terminale · Architectures, réseaux, sécurité',
|
||||
'cours' => [
|
||||
['titre' => 'Processus et ordonnancement', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Routage : protocoles RIP et OSPF', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Chiffrement symétrique, asymétrique et HTTPS', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Terminale · Données et structures',
|
||||
'cours' => [
|
||||
['titre' => 'Bases de données relationnelles et SQL', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Piles, files et listes chaînées', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Arbres binaires et graphes', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'sio',
|
||||
'nom' => 'BTS SIO SISR',
|
||||
'sous_titre' => 'Services informatiques aux organisations · option SISR',
|
||||
'couleur' => 'bg-[#7CF2C0]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Bloc 1 · Support et mise à disposition de services',
|
||||
'cours' => [
|
||||
['titre' => 'Gérer le parc avec GLPI', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Traiter un incident : ticket, diagnostic, résolution', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Installer et configurer un poste Debian', 'types' => ['TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Présence en ligne : nom de domaine et DNS public', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Bloc 2 · Administration des systèmes et des réseaux',
|
||||
'cours' => [
|
||||
['titre' => 'Adressage IPv4 et sous-réseaux', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'VLAN et commutation', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Routage inter-VLAN', 'types' => ['TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Serveur DHCP sous Debian', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Serveur DNS avec BIND 9', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Annuaire Samba AD et stratégies de groupe', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Supervision avec Zabbix', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Sauvegarde et restauration', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Virtualisation et conteneurs', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Bloc 3 · Cybersécurité des services',
|
||||
'cours' => [
|
||||
['titre' => 'RGPD et protection des données', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Authentification et mots de passe', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Pare-feu avec nftables', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'VPN avec WireGuard', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Journalisation et détection d’intrusion', 'types' => ['TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'ciel',
|
||||
'nom' => 'BTS CIEL',
|
||||
'sous_titre' => 'Cybersécurité, informatique et réseaux, électronique · option IR',
|
||||
'couleur' => 'bg-[#C4B5FD]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Réseaux',
|
||||
'cours' => [
|
||||
['titre' => 'Numération et codage de l’information', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Modèle OSI et encapsulation', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Ethernet, câblage et commutation', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Adressage IPv4 et IPv6', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Services réseau : SSH, HTTP, FTP', 'types' => ['TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Capturer et analyser des trames avec tcpdump', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Informatique',
|
||||
'cours' => [
|
||||
['titre' => 'Scripts Bash', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Python pour l’administration système', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Objets connectés et protocole MQTT', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Cybersécurité',
|
||||
'cours' => [
|
||||
['titre' => 'Analyse de risques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Durcir un serveur Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Détecter une attaque dans les journaux', 'types' => ['TP', 'QCM'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'but',
|
||||
'nom' => 'BUT R&T',
|
||||
'sous_titre' => 'Réseaux et télécommunications · parcours Cybersécurité et DevCloud',
|
||||
'couleur' => 'bg-[#FF6FB5]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Semestres 1 et 2',
|
||||
'cours' => [
|
||||
['titre' => 'Initiation aux réseaux informatiques', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Fondamentaux des systèmes Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Architecture des systèmes numériques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Transmission et supports physiques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Bases de la programmation', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Semestres 3 et 4',
|
||||
'cours' => [
|
||||
['titre' => 'Routage dynamique OSPF', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Services réseaux : DNS, mail, web', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Réseaux d’opérateurs : BGP et MPLS', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Supervision et métrologie', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Automatiser avec Ansible', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Parcours Cybersécurité et DevCloud',
|
||||
'cours' => [
|
||||
['titre' => 'Cryptographie appliquée : PKI et certificats', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Conteneurs Docker et orchestration', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Auditer la sécurité d’un réseau', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'tssr',
|
||||
'nom' => 'Titre pro TSSR',
|
||||
'sous_titre' => 'Technicien supérieur systèmes et réseaux · niveau 5',
|
||||
'couleur' => 'bg-white',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'CCP 1 · Assister les utilisateurs',
|
||||
'cours' => [
|
||||
['titre' => 'Mettre en service un poste client', 'types' => ['TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Diagnostiquer une panne réseau', 'types' => ['TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Rédiger une procédure utilisateur', 'types' => ['CM', 'TD'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'CCP 2 · Infrastructure centralisée',
|
||||
'cours' => [
|
||||
['titre' => 'Déployer un domaine Samba AD', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Partages de fichiers et ACL', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Sauvegardes avec restic', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Superviser une infrastructure', 'types' => ['TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'CCP 3 · Infrastructure distribuée',
|
||||
'cours' => [
|
||||
['titre' => 'Déployer des services dans le cloud', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Docker Compose en production', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Automatiser l’administration en Bash', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'VPN site à site', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'prog',
|
||||
'nom' => 'Programmation',
|
||||
'sous_titre' => 'Python, web, SQL, Bash, C · exercices dans l’IDE en ligne',
|
||||
'couleur' => 'bg-[#FF6FB5]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Python · les bases',
|
||||
'cours' => [
|
||||
['titre' => 'Variables et types', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Conditions : if, elif, else', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Boucles for et while', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Fonctions et paramètres', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Listes et tuples', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Chaînes de caractères', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-voyelles'],
|
||||
['titre' => 'Dictionnaires', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-voyelles'],
|
||||
['titre' => 'Lire et écrire des fichiers CSV', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-somme'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Python · aller plus loin',
|
||||
'cours' => [
|
||||
['titre' => 'Récursivité', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Programmation orientée objet', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 4, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Algorithmes de tri', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Recherche dichotomique', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Tester son code avec assert', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Sockets : un client réseau en Python', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Web',
|
||||
'cours' => [
|
||||
['titre' => 'HTML : structurer une page', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'CSS : flexbox et grid', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'JavaScript et le DOM', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'Formulaires et validation', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'API REST et JSON avec fetch', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'PHP côté serveur', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Bases de données',
|
||||
'cours' => [
|
||||
['titre' => 'SELECT, WHERE, ORDER BY', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'],
|
||||
['titre' => 'Agrégats et GROUP BY', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-group'],
|
||||
['titre' => 'Jointures', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'sql-select'],
|
||||
['titre' => 'Créer des tables et des contraintes', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'],
|
||||
['titre' => 'Injection SQL : comprendre et s’en protéger', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Système et outils',
|
||||
'cours' => [
|
||||
['titre' => 'Scripts Bash avancés', 'types' => ['CM', 'IDE', 'TP', 'QCM'], 'heures' => 3, 'exercice' => 'bash-tp'],
|
||||
['titre' => 'Expressions régulières', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'bash-tp'],
|
||||
['titre' => 'Git : versionner son code', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Langage C : pointeurs et mémoire', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
19
portail/package-lock.json
generated
19
portail/package-lock.json
generated
|
|
@ -4,6 +4,10 @@
|
|||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
|
|
@ -660,6 +664,21 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -14,5 +14,9 @@
|
|||
},
|
||||
"optionalDependencies": {
|
||||
"@laravel/multiplex": "^0.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
portail/resources/js/console.js
Normal file
9
portail/resources/js/console.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// xterm.js servi par le portail lui-même (étape 12) plutôt que par un CDN :
|
||||
// la CSP stricte des pages de l'application n'autorise que nos propres
|
||||
// scripts, et des réseaux scolaires filtrent les CDN.
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
window.Terminal = Terminal;
|
||||
window.FitAddon = { FitAddon };
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
document.querySelectorAll('[data-etab]').forEach(function (s) {
|
||||
var box = s.parentNode.querySelector('[data-etab-autre]');
|
||||
function maj() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
@section('contenu')
|
||||
<section style="padding:112px 20px 96px;background:#fff"><div style="max-width:900px;margin:0 auto"><p style="margin:0;font-size:15px;font-weight:700;color:#0b57d0;text-align:center">Gratuit, sans compte</p><h1 style="margin:20px 0 0;font-size:clamp(40px,6vw,72px);line-height:1.04;font-weight:800;letter-spacing:-0.03em;color:#0b1220;text-align:center;text-wrap:balance">99 cours gratuits, calés sur vos référentiels.</h1><p style="margin:24px auto 0;max-width:660px;font-size:20px;line-height:1.6;font-weight:500;color:#3c4043;text-align:center;text-wrap:pretty">Chaque cours suit le découpage de l’Éducation nationale : un CM pour comprendre, un TD pour s’exercer sur papier, un TP sur une vraie VM, un QCM corrigé et expliqué.</p><p style="margin:28px 0 0;text-align:center;font-size:15px;font-weight:700;color:#5f6368">99 cours · 45 TD · 59 TP sur VM · 98 QCM corrigés</p><div style="margin-top:44px;display:flex;flex-direction:column;align-items:center;gap:14px"><a href="#nsi" style="display:inline-flex;align-items:center;height:56px;padding:0 32px;border-radius:999px;background:#0b57d0;color:#fff;font-size:17px;font-weight:700;text-decoration:none">Choisir mon cursus</a></div><nav aria-label="Cursus" style="margin-top:28px;display:flex;flex-wrap:wrap;justify-content:center;gap:8px 24px"><a href="#nsi" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Lycée NSI</a><a href="#sio" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BTS SIO SISR</a><a href="#ciel" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BTS CIEL</a><a href="#but" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BUT R&T</a><a href="#tssr" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Titre pro TSSR</a><a href="#prog" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Programmation</a></nav></div></section>
|
||||
{{-- Boucle et données absentes de la maquette design/ (perdues lors de sa
|
||||
refonte) : rétablies d'après design/_backup-ludique, étape 12. --}}
|
||||
@php
|
||||
$cursus = config('cours');
|
||||
$fond = ['CM' => 'bg-white', 'TD' => 'bg-[#eef0fd]', 'TP' => 'bg-[#e6f4ea]', 'QCM' => 'bg-[#fef7e0]', 'IDE' => 'bg-[#13233A] text-white'];
|
||||
@endphp
|
||||
@foreach ($cursus as $c)
|
||||
<section id="{{ $c['id'] }}" class="scroll-mt-24" style="border-top:1px solid #e3e5e8" aria-labelledby="t-{{ $c['id'] }}">
|
||||
<div class="mx-auto max-w-7xl px-5 py-16 lg:px-10">
|
||||
<div class="flex flex-wrap items-end gap-x-8 gap-y-3">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
<span class="font-mono text-sm font-semibold">({{ session('nombre_importes') }} élève(s) importé(s))</span>
|
||||
@endif
|
||||
</h2>
|
||||
<button type="button" onclick="window.print()" class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
|
||||
<button type="button" data-imprimer class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
|
||||
</div>
|
||||
<p class="mt-2 font-semibold print:hidden">Le code secret ne sera plus jamais affiché après cette page : imprime ou note-le maintenant.</p>
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 print:grid-cols-1">
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
<td class="px-4 py-3 font-mono text-sm">{{ $eleve->identifiant }}</td>
|
||||
<td class="px-4 py-3 font-semibold">{{ $eleve->derniere_connexion_a?->diffForHumans() ?? 'jamais' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<form action="{{ route('mes-classes.eleves.regenererCode', [$classe, $eleve]) }}" method="post" onsubmit="return confirm('Régénérer le code de {{ $eleve->prenom }} ? L’ancien code ne fonctionnera plus.');">
|
||||
<form action="{{ route('mes-classes.eleves.regenererCode', [$classe, $eleve]) }}" method="post" data-confirmer="Régénérer le code de {{ $eleve->prenom }} ? L’ancien code ne fonctionnera plus.">
|
||||
@csrf
|
||||
<button type="submit" class="font-bold underline underline-offset-4">Régénérer le code</button>
|
||||
</form>
|
||||
|
|
@ -133,4 +133,10 @@
|
|||
@endif
|
||||
</section>
|
||||
</div>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
|
||||
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
|
||||
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
|
|
|||
|
|
@ -66,11 +66,11 @@
|
|||
@endif
|
||||
</section>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script>
|
||||
@vite('resources/js/console.js')
|
||||
<script nonce="{{ Vite::cspNonce() }}" src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
// Après les modules Vite (différés) : window.Terminal est défini.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
|
||||
bouton.addEventListener('click', function () {
|
||||
var id = bouton.dataset.onglet;
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: false, reveil: true });
|
||||
});
|
||||
@endforeach
|
||||
});
|
||||
</script>
|
||||
@else
|
||||
<div class="py-20 text-center">
|
||||
|
|
@ -101,7 +102,7 @@
|
|||
</div>
|
||||
|
||||
@if (in_array($labSession->etat, [$etats::EN_ATTENTE, $etats::ADMIS, $etats::ECHEC], true))
|
||||
<script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
(function () {
|
||||
var etatAffiche = @json($labSession->etat);
|
||||
setInterval(function () {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@
|
|||
<div id="terminal-{{ $machine->id }}" class="h-full"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script>
|
||||
@vite('resources/js/console.js')
|
||||
<script nonce="{{ Vite::cspNonce() }}" src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
// Après les modules Vite (différés) : window.Terminal est défini.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
|
||||
bouton.addEventListener('click', function () {
|
||||
var id = bouton.dataset.onglet;
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: true });
|
||||
});
|
||||
@endforeach
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use App\Http\Controllers\ConsoleController;
|
|||
use App\Http\Controllers\CorrectionController;
|
||||
use App\Http\Controllers\EleveTableauDeBordController;
|
||||
use App\Http\Controllers\InscriptionController;
|
||||
use App\Http\Controllers\RapportCspController;
|
||||
use App\Http\Controllers\SeanceController;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'accueil')->name('accueil');
|
||||
|
|
@ -22,6 +24,11 @@ Route::view('/inscription', 'auth.inscription')->name('register');
|
|||
Route::view('/classes/suivi', 'classes.suivi')->name('suivi');
|
||||
Route::view('/examen', 'examen')->name('examen');
|
||||
// À brancher sur l'envoi d'e-mail (Mail::to('adam@belkacemi.me')->send(...)).
|
||||
// Envoyé par les navigateurs eux-mêmes (pas de jeton CSRF possible).
|
||||
Route::post('/csp-rapport', RapportCspController::class)
|
||||
->withoutMiddleware(ValidateCsrfToken::class)
|
||||
->middleware('throttle:30,1')
|
||||
->name('csp.rapport');
|
||||
Route::post('/contact', fn () => back()->with('contact_ok', true))->name('contact');
|
||||
Route::view('/fonctionnalites', 'fonctionnalites')->name('fonctionnalites');
|
||||
Route::view('/legal', 'legal')->name('legal');
|
||||
|
|
|
|||
22
portail/tests/Feature/PagesPubliquesTest.php
Normal file
22
portail/tests/Feature/PagesPubliquesTest.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Toutes les pages publiques sans paramètre doivent s'afficher : la page
|
||||
// /cours a été en erreur 500 de l'étape 8 à l'étape 12 sans que personne
|
||||
// ne le voie.
|
||||
test('chaque page publique s’affiche sans erreur', function () {
|
||||
$pages = collect(Route::getRoutes())
|
||||
->filter(fn ($r) => in_array('GET', $r->methods(), true)
|
||||
&& $r->getName() !== null
|
||||
&& ! str_contains($r->uri(), '{')
|
||||
&& ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
|
||||
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || $r->uri() === 'up');
|
||||
|
||||
expect($pages)->not->toBeEmpty();
|
||||
|
||||
$echecs = $pages->mapWithKeys(fn ($r) => [$r->uri() => $this->get('/'.ltrim($r->uri(), '/'))->getStatusCode()])
|
||||
->reject(fn ($code) => $code === 200);
|
||||
|
||||
expect($echecs->all())->toBe([]);
|
||||
});
|
||||
48
portail/tests/Feature/PolitiqueSecuriteContenuTest.php
Normal file
48
portail/tests/Feature/PolitiqueSecuriteContenuTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Eleve;
|
||||
|
||||
function politique($reponse): array
|
||||
{
|
||||
return collect(explode(';', $reponse->headers->get('Content-Security-Policy')))
|
||||
->mapWithKeys(function (string $d) {
|
||||
$morceaux = preg_split('/\s+/', trim($d), 2);
|
||||
|
||||
return [$morceaux[0] => $morceaux[1] ?? ''];
|
||||
})->all();
|
||||
}
|
||||
|
||||
test("les pages de l'application ont une CSP stricte : pas de script en ligne sans le nonce", function () {
|
||||
$eleve = Eleve::factory()->create();
|
||||
$jeton = $eleve->demarreNouvelleSession();
|
||||
|
||||
$reponse = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('eleve'));
|
||||
$csp = politique($reponse);
|
||||
|
||||
expect($csp['script-src'])->toMatch("/^'self' 'nonce-[A-Za-z0-9+\\/=]+'$/")
|
||||
->and($csp['script-src'])->not->toContain('unsafe-inline')
|
||||
->and($csp['object-src'])->toBe("'none'")
|
||||
->and($csp['frame-ancestors'])->toBe("'none'");
|
||||
});
|
||||
|
||||
test('la page de connexion est stricte aussi, la vitrine tolère les scripts en ligne', function () {
|
||||
expect(politique($this->get(route('login')))['script-src'])->not->toContain('unsafe-inline')
|
||||
->and(politique($this->get(route('accueil')))['script-src'])->toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
test("chaque script de la page d'inscription porte le nonce de l'en-tête", function () {
|
||||
$reponse = $this->get(route('register'));
|
||||
preg_match("/'nonce-([^']+)'/", $reponse->headers->get('Content-Security-Policy'), $m);
|
||||
|
||||
preg_match_all('/<script\b[^>]*>/', $reponse->getContent(), $scripts);
|
||||
expect($scripts[0])->not->toBeEmpty();
|
||||
foreach ($scripts[0] as $balise) {
|
||||
expect($balise)->toContain("nonce=\"{$m[1]}\"");
|
||||
}
|
||||
});
|
||||
|
||||
test('le rapport de violation est accepté sans jeton CSRF', function () {
|
||||
$this->call('POST', route('csp.rapport'), [], [], [], ['CONTENT_TYPE' => 'application/csp-report'],
|
||||
json_encode(['csp-report' => ['document-uri' => 'https://syllabuslab.fr/eleve?x=1', 'violated-directive' => 'script-src', 'blocked-uri' => 'inline']]))
|
||||
->assertNoContent();
|
||||
});
|
||||
|
|
@ -9,8 +9,10 @@
|
|||
|
||||
Usage (sur le serveur, après preparer-parcours.php) :
|
||||
sudo -u www-data python3 tests/Reels/parcours-lab.py
|
||||
(PARCOURS_BASE=… pour viser une autre adresse que https://syllabuslab.fr)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -18,7 +20,9 @@ import time
|
|||
import requests
|
||||
import websocket
|
||||
|
||||
BASE = "http://127.0.0.1"
|
||||
# Depuis l'étape 12 : par le domaine, en HTTPS (cookies « secure »).
|
||||
BASE = os.environ.get("PARCOURS_BASE", "https://syllabuslab.fr")
|
||||
BASE_WS = re.sub(r"^http", "ws", BASE)
|
||||
ids = json.load(open("/tmp/parcours-identifiants.json"))
|
||||
seance = ids["seance_id"]
|
||||
|
||||
|
|
@ -70,7 +74,7 @@ consoles = {}
|
|||
for url, nom in zip(re.findall(r"fetch\('(http[^']+/console)'", page), re.findall(r'data-onglet="\d+"[^>]*>([^<]+)<', page)):
|
||||
url = re.sub(r"^https?://[^/]+", BASE, url)
|
||||
jeton = eleve.post(url, headers={"X-CSRF-TOKEN": csrf}).json()["jeton"]
|
||||
consoles[nom] = websocket.create_connection(f"ws://127.0.0.1/console/{jeton}", timeout=10)
|
||||
consoles[nom] = websocket.create_connection(f"{BASE_WS}/console/{jeton}", timeout=10)
|
||||
|
||||
|
||||
def lire_jusqua(ws, motif, delai=15):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import tailwindcss from '@tailwindcss/vite';
|
|||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/console.js'],
|
||||
refresh: true,
|
||||
fonts: [
|
||||
bunny('Instrument Sans', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue