ajoute la visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur

Jitsi (docker-jitsi-meet, version figée) derrière Nginx sur meet.syllabuslab.fr,
accès par jeton JWT signé par le portail, modération du micro, mains levées
synchronisées avec l'application. Pare-feu : UDP 10000 et pont interne
br-visio (accords d'Adam). Jicofo n'authentifie plus lui-même : il donnait
le rôle de propriétaire aux élèves. Capacité ramenée à 20 VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-12 12:35:35 +02:00
parent dd4f9c3c55
commit ec4eae3744
36 changed files with 1406 additions and 18 deletions

View file

@ -5,6 +5,20 @@ durcissement_ports_ouverts:
- 80
- 443
# Ports UDP ouverts. 10000 : média de la visio (Jitsi Videobridge, étape 15,
# accord d'Adam le 2026-10-09). Le relais TURN sur 443 pour les réseaux qui
# bloquent l'UDP reste à faire (docs/dette.md).
durcissement_ports_udp_ouverts:
- 10000
# Délai avant retour automatique à l'état d'avant durcissement, si la vérification
# post-changement échoue (voir CLAUDE.md et le prompt de déploiement, règle 4).
durcissement_delai_filet_secondes: 300
# Ponts Docker dont les conteneurs peuvent se parler entre eux. br_netfilter
# fait passer le trafic interne d'un pont par la chaîne forward (politique
# drop) : sans cette règle, les conteneurs d'un même réseau ne se joignent
# pas. Seul le trafic qui entre ET ressort par ce pont est accepté.
# br-visio : Jitsi (étape 15, accord d'Adam le 2026-10-12).
durcissement_ponts_internes:
- br-visio

View file

@ -1,7 +1,7 @@
#!/usr/sbin/nft -f
# Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
# Politique : tout est refusé en entrée sauf loopback, trafic déjà établi,
# ICMP, et les ports {{ durcissement_ports_ouverts | join(', ') }}.
# ICMP, les ports TCP {{ durcissement_ports_ouverts | join(', ') }}{% if durcissement_ports_udp_ouverts | default([]) %} et UDP {{ durcissement_ports_udp_ouverts | join(', ') }}{% endif %}.
flush ruleset
@ -17,10 +17,18 @@ table inet filter {
meta l4proto ipv6-icmp accept
tcp dport { {{ durcissement_ports_ouverts | join(', ') }} } accept
{% if durcissement_ports_udp_ouverts | default([]) %}
udp dport { {{ durcissement_ports_udp_ouverts | join(', ') }} } accept
{% endif %}
}
chain forward {
type filter hook forward priority 0; policy drop;
{% for pont in durcissement_ponts_internes | default([]) %}
# Trafic interne au réseau Docker {{ pont }} (conteneur ↔ conteneur).
iifname "{{ pont }}" oifname "{{ pont }}" accept
{% endfor %}
{% if reseau_veth_gw_subnet is defined %}
# Un seul sens autorisé : réseau externe OpenStack -> Internet (NAT),

View file

@ -16,3 +16,6 @@ portail_domaine_statut: status.syllabuslab.fr
portail_statut_slug: syllabus
portail_ip_publique: 163.172.4.195
portail_port_uptime_kuma: 3001
# Étape 15 : visio (Jitsi, rôle visio), activée quand ce nom résout vers le serveur.
portail_domaine_visio: meet.syllabuslab.fr
portail_port_visio: 8090

View file

@ -26,12 +26,20 @@
changed_when: false
failed_when: false
- name: noter si la page de statut peut être activée
- name: vérifier le DNS du sous-domaine de la visio
ansible.builtin.command: getent ahostsv4 {{ portail_domaine_visio }}
register: portail_dns_visio
changed_when: false
failed_when: false
- name: noter si la page de statut et la visio peuvent être activées
ansible.builtin.set_fact:
portail_statut_actif: "{{ portail_ip_publique in portail_dns_statut.stdout }}"
portail_visio_actif: "{{ portail_ip_publique in portail_dns_visio.stdout }}"
portail_noms_certificat: >-
{{ [portail_domaine] + portail_domaines_alias
+ ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else []) }}
+ ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else [])
+ ([portail_domaine_visio] if portail_ip_publique in portail_dns_visio.stdout else []) }}
- name: vérifier si le certificat existe déjà
ansible.builtin.stat:

View file

@ -40,7 +40,7 @@
server {
listen 80;
listen [::]:80;
server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %};
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 %};
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.
@ -117,6 +117,48 @@ server {
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 {
listen 443 ssl http2;
listen [::]:443 ssl http2;
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.

View file

@ -6,5 +6,7 @@ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" alway
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;
# Caméra, micro et partage d'écran : délégués à la seule visio intégrée
# (étape 15) ; refusés à tout autre site.
add_header Permissions-Policy "camera=(self \"https://{{ portail_domaine_visio }}\"), microphone=(self \"https://{{ portail_domaine_visio }}\"), display-capture=(self \"https://{{ portail_domaine_visio }}\"), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

View file

@ -0,0 +1,17 @@
---
# Étape 15 : visio de classe (Jitsi Meet auto-hébergé, docker-jitsi-meet).
visio_version: stable-11248
visio_domaine: meet.syllabuslab.fr
visio_dossier: /opt/visio
visio_port_web: 8090
visio_ip_publique: 163.172.4.195
visio_port_media: 10000
# Émetteur et audience des jetons signés par le portail (portail/app/Services/Visio).
visio_jwt_app_id: syllabus
visio_jwt_audience: syllabus-visio
# Mémoire plafonnée (Java) : la visio prend sur la capacité des VM d'élèves.
visio_memoire_jvb: 1024m
visio_memoire_jicofo: 512m
# Utilisateur des conteneurs docker-jitsi-meet (ils refusent de démarrer
# si leurs volumes ne lui appartiennent pas).
visio_uid_conteneurs: 1000

View file

@ -0,0 +1,7 @@
---
- name: remettre en cache la configuration du portail
ansible.builtin.command:
cmd: runuser -u www-data -- php artisan config:cache
args:
chdir: /var/www/portail
changed_when: true

View file

@ -0,0 +1,126 @@
---
# Étape 15 : visio de classe. Jitsi Meet (docker-jitsi-meet, version figée)
# derrière le Nginx de l'hôte ; accès par jeton JWT signé par le portail.
- name: créer les dossiers de la visio
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /etc/visio
- "{{ visio_dossier }}"
- name: créer les dossiers des conteneurs (au nom de leur utilisateur)
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ visio_uid_conteneurs }}"
group: "{{ visio_uid_conteneurs }}"
mode: "0750"
loop:
- "{{ visio_dossier }}/config"
- "{{ visio_dossier }}/config/web"
- "{{ visio_dossier }}/config/storage/web"
- "{{ visio_dossier }}/config/storage/transcripts"
- "{{ visio_dossier }}/config/tmp/web-load-test"
- "{{ visio_dossier }}/config/prosody/config"
- "{{ visio_dossier }}/config/prosody/prosody-plugins-custom"
- "{{ visio_dossier }}/config/storage/prosody"
- "{{ visio_dossier }}/config/jicofo"
- "{{ visio_dossier }}/config/jvb"
# Générés une seule fois sur le serveur, jamais dans Git ni affichés.
- name: générer les secrets de la visio (première fois seulement)
ansible.builtin.copy:
dest: /etc/visio/secrets.env
owner: root
group: root
mode: "0600"
force: false
content: |
JICOFO_AUTH_PASSWORD={{ lookup('community.general.random_string', length=40, special=false) }}
JVB_AUTH_PASSWORD={{ lookup('community.general.random_string', length=40, special=false) }}
JWT_APP_SECRET={{ lookup('community.general.random_string', length=64, special=false) }}
no_log: true
- name: déployer la configuration de la visio
ansible.builtin.template:
src: visio.env.j2
dest: /etc/visio/visio.env
owner: root
group: root
mode: "0640"
register: visio_config
- name: déployer docker-compose.yml de la visio
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ visio_dossier }}/docker-compose.yml"
owner: root
group: root
mode: "0640"
register: visio_compose
- name: démarrer Jitsi (web, prosody, jicofo, jvb)
community.docker.docker_compose_v2:
project_src: "{{ visio_dossier }}"
project_name: visio
state: present
pull: missing
recreate: "{{ 'always' if (visio_config.changed or visio_compose.changed) else 'auto' }}"
- name: attendre que Jitsi web réponde sur 127.0.0.1
ansible.builtin.uri:
url: "http://127.0.0.1:{{ visio_port_web }}/"
status_code: [200]
register: visio_web
retries: 20
delay: 3
until: visio_web.status == 200
- name: vérifier que JVB écoute l'UDP {{ visio_port_media }}
ansible.builtin.shell:
cmd: "ss -Hulnp 'sport = :{{ visio_port_media }}' | grep -q java"
register: visio_jvb
retries: 20
delay: 3
until: visio_jvb.rc == 0
changed_when: false
# Jicofo doit voir un JVB opérationnel : c'est la preuve que les quatre
# services se parlent (cassé tant que le pare-feu jetait le trafic entre
# conteneurs, voir durcissement_ponts_internes).
- name: vérifier que Jicofo voit un JVB opérationnel
ansible.builtin.shell:
cmd: >-
docker compose -p visio -f {{ visio_dossier }}/docker-compose.yml exec -T jicofo
curl -s http://127.0.0.1:8888/stats
register: visio_stats_jicofo
retries: 20
delay: 5
until: >-
visio_stats_jicofo.rc == 0 and
(visio_stats_jicofo.stdout | from_json).bridge_selector.operational_bridge_count | int >= 1
changed_when: false
# Le portail signe les jetons avec le même secret (portail/config/visio.php).
- name: lire le secret de signature des jetons
ansible.builtin.slurp:
src: /etc/visio/secrets.env
register: visio_secrets_brut
no_log: true
- name: transmettre la configuration de la visio au portail
ansible.builtin.lineinfile:
path: /var/www/portail/.env
regexp: "^{{ item.cle }}="
line: "{{ item.cle }}={{ item.valeur }}"
loop:
- { cle: "VISIO_DOMAINE", valeur: "{{ visio_domaine }}" }
- { cle: "VISIO_JWT_APP_ID", valeur: "{{ visio_jwt_app_id }}" }
- { cle: "VISIO_JWT_AUDIENCE", valeur: "{{ visio_jwt_audience }}" }
- { cle: "VISIO_JWT_SECRET", valeur: "{{ visio_secrets_brut.content | b64decode | regex_search('JWT_APP_SECRET=(\\S+)', '\\1') | first }}" }
no_log: true
notify: remettre en cache la configuration du portail

View file

@ -0,0 +1,88 @@
# Géré par Ansible (rôle visio). D'après docker-jitsi-meet {{ visio_version }}
# (docker-compose.yml officiel), adapté à ce serveur :
# - Docker tourne sans pont par défaut ni règles iptables (étape 5) : JVB est
# sur le réseau de l'hôte (UDP {{ visio_port_media }} sans NAT) et joint Prosody
# par 127.0.0.1 ;
# - le conteneur web n'écoute que sur 127.0.0.1, derrière le Nginx de l'hôte.
services:
web:
image: ghcr.io/jitsi/web:{{ visio_version }}
restart: unless-stopped
read_only: true
tmpfs:
- /run:size=16M,mode=1750,exec
- /tmp:size=16M,mode=1777,noexec
ports:
- '127.0.0.1:{{ visio_port_web }}:8000'
volumes:
- {{ visio_dossier }}/config/web:/config:Z
- {{ visio_dossier }}/config/storage/web:/storage:Z
- {{ visio_dossier }}/config/storage/transcripts:/usr/share/jitsi-meet/transcripts:ro,z
- {{ visio_dossier }}/config/tmp/web-load-test:/usr/share/jitsi-meet/load-test:Z
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
networks:
meet.jitsi:
depends_on:
- prosody
prosody:
image: ghcr.io/jitsi/prosody:{{ visio_version }}
restart: unless-stopped
read_only: true
tmpfs:
- /run:size=16M,mode=1750,exec
- /tmp:size=16M,mode=1777,noexec
ports:
# Pour JVB, sur le réseau de l'hôte.
- '127.0.0.1:5222:5222'
expose:
- '5269'
- '5347'
- '5280'
volumes:
- {{ visio_dossier }}/config/prosody/config:/config:Z
- {{ visio_dossier }}/config/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z
- {{ visio_dossier }}/config/storage/prosody:/var/lib/prosody:Z
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
networks:
meet.jitsi:
aliases:
- xmpp.meet.jitsi
jicofo:
image: ghcr.io/jitsi/jicofo:{{ visio_version }}
restart: unless-stopped
read_only: true
tmpfs:
- /run:size=32M,mode=1750,exec
- /tmp:size=16M,mode=1777,noexec
volumes:
- {{ visio_dossier }}/config/jicofo:/config:Z
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
networks:
meet.jitsi:
depends_on:
- prosody
jvb:
image: ghcr.io/jitsi/jvb:{{ visio_version }}
restart: unless-stopped
read_only: true
tmpfs:
- /run:size=32M,mode=1750,exec
- /tmp:size=16M,mode=1777,noexec
network_mode: host
volumes:
- {{ visio_dossier }}/config/jvb:/config:Z
env_file: [/etc/visio/visio.env, /etc/visio/secrets.env]
environment:
XMPP_SERVER: 127.0.0.1
depends_on:
- prosody
networks:
meet.jitsi:
# Nom fixe : le pare-feu n'autorise le trafic entre conteneurs que
# sur ce pont (rôle durcissement, durcissement_ponts_internes).
driver_opts:
com.docker.network.bridge.name: br-visio

View file

@ -0,0 +1,44 @@
# Géré par Ansible (rôle visio) — aucun secret ici (voir /etc/visio/secrets.env).
PUBLIC_URL=https://{{ visio_domaine }}
TZ=Europe/Paris
# HTTPS assuré par le Nginx de l'hôte (certificat Let's Encrypt du portail).
DISABLE_HTTPS=1
ENABLE_HTTP_REDIRECT=0
ENABLE_LETSENCRYPT=0
# Entrée seulement avec un jeton signé par le portail ; pas d'invités.
ENABLE_AUTH=1
AUTH_TYPE=jwt
ENABLE_GUESTS=0
JWT_APP_ID={{ visio_jwt_app_id }}
JWT_ACCEPTED_ISSUERS={{ visio_jwt_app_id }}
JWT_ACCEPTED_AUDIENCES={{ visio_jwt_audience }}
# Le prof seul modérateur : rôle lu dans le jeton (context.user.moderator,
# module natif token_affiliation), jamais attribué au premier arrivé.
XMPP_MUC_MODULES=token_affiliation
ENABLE_AUTO_OWNER=0
# Jicofo ne fait PAS sa propre authentification : sinon il donne le rôle de
# propriétaire à tout utilisateur authentifié, élèves compris (constaté le
# 2026-10-12 : un élève pouvait ouvrir son micro sans la parole). Prosody
# vérifie toujours chaque jeton ; token_affiliation seul décide du rôle.
JICOFO_ENABLE_AUTH=0
# Salle d'attente, modération du micro et de la caméra.
ENABLE_LOBBY=1
ENABLE_AV_MODERATION=1
# Tout le monde arrive micro et caméra coupés ; pas de page d'accueil ni de
# pré-réunion (le nom vient du jeton et ne se modifie pas).
START_AUDIO_MUTED=0
START_VIDEO_MUTED=0
ENABLE_WELCOME_PAGE=0
ENABLE_PREJOIN_PAGE=0
# Sans « chat » : désactivé par défaut.
TOOLBAR_BUTTONS=microphone,camera,desktop,raisehand,participants-pane,tileview,settings,fullscreen,hangup
JVB_ADVERTISE_IPS={{ visio_ip_publique }}
JVB_PORT={{ visio_port_media }}
VIDEOBRIDGE_MAX_MEMORY={{ visio_memoire_jvb }}
JICOFO_MAX_MEMORY={{ visio_memoire_jicofo }}