Auto-hébergement
Le Playground (bac à sable en ligne) est un ensemble de fichiers statiques, et
slides --server est un seul processus sur la boucle locale. Les deux
peuvent tourner sur votre propre machine, sous votre propre nom, pour des personnes qui se
connectent avec le compte de votre organisation. Cette page le fait avec
Caddy (testé avec la v2.11.7), qui obtient
et renouvelle lui-même les certificats HTTPS, et
oauth2-proxy pour
l’authentification. Tous les fichiers montrés ici se trouvent dans
selfhost/,
et son CI vérifie chaque Caddyfile avec caddy validate.
Le Playground
Chaque version du site joint le site entier, Playground compris, sous la forme d’une seule archive tarball construite pour la racine d’un domaine, avec son SHA-256 :
curl -fLO https://github.com/go-slides/go-slides.github.io/releases/download/v0.1.0/go-slides-site-v0.1.0.tar.gz
curl -fLO https://github.com/go-slides/go-slides.github.io/releases/download/v0.1.0/go-slides-site-v0.1.0.tar.gz.sha256
sha256sum -c go-slides-site-v0.1.0.tar.gz.sha256
tar -xzf go-slides-site-v0.1.0.tar.gz -C /srv
mv /srv/go-slides-site-v0.1.0 /srv/go-slides-siteL’archive est reproductible : reconstruire le même tag donne les mêmes
octets. À côté de chaque fichier qui se compresse (les modules WebAssembly, les
polices, les pages, les scripts), elle contient le même fichier compressé en
Brotli (.br) et en gzip (.gz), de sorte que le serveur les envoie tels quels et
ne compresse rien à la volée.
Caddyfile
slides.example.org est votre nom. Ses enregistrements A et AAAA pointent vers la
machine, et les ports 80 et 443 atteignent Caddy :
import headers.caddy
slides.example.org {
root * /srv/go-slides-site
file_server {
precompressed br gzip
}
import playground_headers
}caddy run --config Caddyfile.playgroundLe Playground est alors à l’adresse https://slides.example.org/playground/.
Ce que le serveur doit faire correctement
- HTTPS. Caddy obtient le certificat auprès de Let’s Encrypt avec ACME
(RFC 8555), en prouvant qu’il détient
le nom par une réponse sur le port 80 (HTTP-01) ou 443 (TLS-ALPN-01), puis
le renouvelle. Une machine que l’autorité de certification ne peut pas joindre peut utiliser le défi
DNS-01 à la place (un enregistrement TXT, qui demande un Caddy construit avec le module de
votre fournisseur DNS), ou son propre certificat avec
tls cert.pem key.pem. Sur une machine de test,tls internalutilise l’autorité locale de Caddy. application/wasm. Caddy envoie ce type pour.wasm; un autre serveur peut demander à être configuré. Le Playground compile ses modules à partir des octets qu’il a lus (le moteur d’export au fil du téléchargement, dans une réponse à part avec ce type), si bien qu’il démarre même depuis un serveur qui envoie un autre type ; avecnosniff, le bon type reste celui qu’il faut envoyer.- Compression.
precompressed br gzipenvoieplayground.wasm.brà un navigateur qui accepte Brotli,.gzà un navigateur qui accepte gzip, et le fichier lui-même sinon, avecVary: Accept-Encoding. Le module de l’éditeur fait environ 7 Mio en Brotli, contre 33 Mio non compressé. - Cache. La page demande chaque module, worker, police et image
avec la version dans la requête (
playground.wasm?v=v0.1.0), ils peuvent donc être conservés un an (immutable). Les pages elles-mêmes sont revalidées (no-cache) : une nouvelle version est vue à la visite suivante. - Les en-têtes de
headers.caddy:
(playground_headers) {
header {
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self' https:; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: blob: https:; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
Strict-Transport-Security "max-age=31536000"
}
@versioned query v=*
header @versioned Cache-Control "public, max-age=31536000, immutable"
@unversioned not query v=*
header @unversioned Cache-Control "no-cache"
}La politique est celle dont le Playground a besoin : ses scripts (le chargeur est
en ligne), son WebAssembly ('wasm-unsafe-eval') et son worker, tous depuis
votre serveur. Les images, feuilles de style et polices d’une présentation peuvent venir de toute
adresse https:, comme dans la sortie de la commande ; retirez https: de
img-src, style-src, font-src, media-src et connect-src pour garder
toutes les requêtes sur votre serveur, et les présentations qui pointent ailleurs s’afficheront sans
ces ressources. La présentation elle-même s’exécute dans des iframes isolées (sandbox) sans votre
origine, sous une politique plus stricte qui leur est propre et que le Playground écrit
dans chaque cadre : elle ne peut atteindre ni le Playground, ni son stockage, ni vos
cookies, quoi que dise cet en-tête. frame-ancestors 'self' empêche les autres
sites d’inclure le Playground dans un cadre.
- Ni COOP ni COEP. Le Playground n’utilise ni
SharedArrayBufferni threads, il n’a donc pas besoin d’être isolé en cross-origin. EnvoyerCross-Origin-Embedder-Policy: require-corpbloquerait les images d’une présentation venant d’autres sites.
Sans le réseau
Une fois la page chargée, tout se passe dans le navigateur : l’éditeur,
l’aperçu, la vue présentateur, et les exports PDF, PPTX et ODP, dont les
polices sont dans l’archive. La présentation n’est envoyée ni à votre serveur ni
ailleurs. Ce qui demande le réseau, c’est ce que la présentation nomme : des images ou
des feuilles de style à une adresse https:, et la police web du thème gaia, qu’il
demande à fonts.bunny.net (sans elle, les diapositives se rabattent sur une autre
police). Les liens vers cette documentation quittent votre serveur.
slides --server derrière le proxy
slides --server reste sur 127.0.0.1 et Caddy lui transmet les requêtes. Le serveur
vérifie l’en-tête Host de chaque requête et l’Origin du WebSocket de chaque
rechargement à chaud, contre le DNS rebinding et les pages d’autres sites, et le
proxy conserve le nom public dans les deux. --allowed-host ajoute ce nom à
ce que les contrôles acceptent ; ils restent actifs :
slides -s --allowed-host decks.example.org /srv/decksdecks.example.org {
reverse_proxy 127.0.0.1:8080
}Le rechargement à chaud passe tel quel (Caddy relaie les WebSockets sans
configuration). N’utilisez pas --host 0.0.0.0 pour cela : il servirait le
répertoire à tout le réseau, en contournant le proxy et son authentification.
--allowed-host est dans la v0.14.0 et les suivantes :
go install github.com/go-slides/slides/cmd/slides@v0.18.0. Il demande Go 1.27.3
ou plus récent.Authentification avec OpenID Connect
Caddy interroge oauth2-proxy pour chaque requête (forward_auth). oauth2-proxy
répond 202 pour un navigateur authentifié, que Caddy sert alors,
et 401 sinon, que Caddy transforme en redirection vers la page de connexion. La
connexion suit le flux par code d’autorisation d'
OpenID Connect Core 1.0 :
le navigateur va chez votre fournisseur (Keycloak, Authentik, Entra ID, Google,
Okta, Dex…), revient sur /oauth2/callback avec un code, oauth2-proxy
échange le code contre un ID Token, le vérifie et pose son propre cookie.
- Enregistrez un client auprès de votre fournisseur, confidentiel, avec le
flux par code d’autorisation. Ses URI de redirection, qui doivent correspondre exactement :
https://slides.example.org/oauth2/callbackethttps://decks.example.org/oauth2/callback. - Mettez les secrets dans des fichiers lisibles par oauth2-proxy seul : le
secret du client, sans saut de ligne final, et un secret de cookie de
exactement 32 octets aléatoires (
head -c 32 /dev/urandom). Votre gestionnaire de secrets les écrit (credentials systemd, secrets Docker ou Kubernetes, Vault…) ; aucun ne va dans un fichier de configuration, une ligne de commande ou une variable d’environnement, où les journaux etpsles montrent. - Configurez oauth2-proxy (
oauth2-proxy.cfg) :
http_address = "127.0.0.1:4180"
reverse_proxy = true
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
upstreams = ["static://202"]
set_xauthrequest = true
provider = "oidc"
oidc_issuer_url = "https://id.example.org/realms/example"
client_id = "go-slides"
client_secret_file = "/run/secrets/oauth2-proxy-client-secret"
code_challenge_method = "S256"
redirect_url = "/oauth2/callback"
whitelist_domains = ["slides.example.org", "decks.example.org"]
email_domains = ["example.org"]
cookie_secret_file = "/run/secrets/oauth2-proxy-cookie-secret"
cookie_secure = true
cookie_samesite = "lax"redirect_url sans hôte ramène la connexion de chaque site vers son propre
callback, de sorte que chaque site garde son propre cookie. whitelist_domains est l’endroit où une
connexion peut revenir : si on lui demande d’aller ailleurs, elle reste sur votre site.
trusted_proxy_ips ne croit les en-têtes X-Forwarded-* que s’ils viennent de Caddy.
email_domains dit qui peut entrer ; oauth2-proxy a aussi des options pour
les groupes et les listes d’adresses.
- Configurez Caddy (
Caddyfile.oidc) :
import headers.caddy
(signed_in) {
handle /oauth2/* {
reverse_proxy 127.0.0.1:4180
}
handle {
forward_auth 127.0.0.1:4180 {
uri /oauth2/auth
copy_headers X-Auth-Request-User X-Auth-Request-Email
@unauthorized status 401
handle_response @unauthorized {
redir * /oauth2/sign_in?rd={scheme}://{hostport}{uri} 302
}
}
{block}
}
}
slides.example.org {
import signed_in {
root * /srv/go-slides-site
file_server {
precompressed br gzip
}
import playground_headers
}
}
decks.example.org {
import signed_in {
reverse_proxy 127.0.0.1:8080
}
}slides -s --allowed-host decks.example.org /srv/decks
oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
caddy run --config Caddyfile.oidcoauth2-proxy et slides n’écoutent que sur la boucle locale : la seule entrée passe
par Caddy, donc par l’authentification. Les en-têtes du Playground vont
à l’intérieur du bloc authentifié, pour le Playground seul : la page de connexion
d’oauth2-proxy est un formulaire, que leur form-action 'none' bloquerait.
L’alternative est caddy-security,
un module Caddy qui fait l’authentification dans Caddy, sans second processus.
Il demande un Caddy construit avec le module (xcaddy), et son propre langage
de configuration.
slides-gitd : les identifiants restent sur le serveur
slides-gitd (v0.1.0) est le backend git
d’un Playground auto-hébergé. Il charge le dossier d’une présentation depuis
une forge, propose les changements dans une nouvelle branche avec une pull
(merge) request, et cherche les changements en amont, tandis que les
identifiants de la forge restent sur le serveur : le navigateur ne détient
qu’un cookie de session HttpOnly. Il se place derrière Caddy et oauth2-proxy
(OIDC), écoute sur 127.0.0.1:8091, ne prend l’identité de l’utilisateur que
du proxy, et n’écrit que dans des branches nommées
slides/<utilisateur>/<titre>-<heure> que le serveur nomme lui-même.
Le mode serveur du Playground, qui parle à slides-gitd au lieu que le navigateur détienne un jeton, est décrit dans Le Playground derrière slides-gitd.
Configuration (les secrets sont des chemins de fichiers, jamais des valeurs ; chaque fichier ne doit être lisible que par son propriétaire) :
{
"listen": "127.0.0.1:8091",
"origin": "https://slides.example.org",
"proxy_secret_file": "/etc/slides-gitd/proxy.secret",
"master_key_file": "/etc/slides-gitd/master.key",
"data_dir": "/var/lib/slides-gitd",
"audit_file": "/var/log/slides-gitd/audit.log",
"forges": {
"github": {"kind": "github", "api": "https://api.github.com", "auth": "github-app",
"app_client_id": "Iv1.…", "installation_id": 12345678,
"app_key_file": "/etc/slides-gitd/github-app.pem"},
"gitlab": {"kind": "gitlab", "api": "https://gitlab.example.org/api/v4", "auth": "user-token"}
},
"users": {
"3f2b…-oidc-sub": {"slug": "ada", "repos": ["github:lab/talks", "gitlab:team/slides"]}
}
}Un utilisateur n’atteint que les entrées forge:propriétaire/nom listées pour
son sujet OIDC (refus par défaut). Créez la clé maîtresse avec
head -c 32 /dev/urandom > master.key && chmod 600 master.key, puis lancez
slides-gitd check -c config.json, qui charge tous les fichiers de secrets et
s’arrête, et slides-gitd serve -c config.json.
Le côté Caddy, avec oauth2-proxy lancé avec --set-xauthrequest :
slides.example.org {
handle /oauth2/* {
reverse_proxy 127.0.0.1:4180
}
handle /api/git/* {
# never let a client choose its identity, whatever Caddy's version does
request_header -X-Auth-Request-User
request_header -X-Auth-Request-Email
request_header -X-Gitd-Proxy-Secret
forward_auth 127.0.0.1:4180 {
uri /oauth2/auth
copy_headers X-Auth-Request-User X-Auth-Request-Email
}
request_body { max_size 64MB }
reverse_proxy 127.0.0.1:8091 {
header_up X-Gitd-Proxy-Secret {file./etc/caddy/gitd-proxy.secret}
}
}
handle { root * /srv/slides; file_server }
}Les lignes request_header -… suppriment tout en-tête d’identité envoyé par le
client, qui ne peut donc pas choisir qui il est ; le Caddy récent le fait
lui-même avant copy_headers, les anciennes versions non. {file.*} lit le
secret du proxy dans un fichier, il n’est donc pas dans une variable
d’environnement.
GitHub utilise une App. Créez-la dans Settings ▸ Developer settings ▸ GitHub Apps avec les permissions Contents en lecture/écriture, Pull requests en lecture/écriture et Metadata en lecture, sans webhook ; installez-la sur les seuls dépôts des présentations. slides-gitd émet un jeton d’installation pour l’unique dépôt de chaque appel, valable une heure.
GitLab : chaque utilisateur enregistre un jeton d’accès personnel ou de
projet de portée api. Pourquoi api et non write_repository, et ce
qu’un jeton volé peut faire, est expliqué
sur la page du Playground ; préférez un
jeton d’accès de projet ou de groupe limité aux dépôts des présentations, ou
un compte dédié.
Le Playground derrière slides-gitd (mode serveur)
Lorsque le Playground est servi derrière slides-gitd sur sa propre origine, il
le détecte en interrogeant GET /api/git/session sur cette origine. 401
signifie qu’un serveur est là et que personne n’est connecté : le panneau
affiche Se connecter. Une session JSON signifie connecté. Tout le reste, par
exemple le 404 d’un hébergeur statique, signifie qu’il n’y a pas de serveur, et
le panneau fonctionne exactement comme sur go-slides.github.io : un jeton gardé
dans l’onglet, ou la connexion GitLab.
En mode serveur, le panneau masque les champs du jeton et d’OAuth. Il affiche
Signed in as <identity> (connecté en tant que) et la liste des dépôts que le
serveur accorde. Charger, Proposer les changements… (le serveur nomme la
branche slides/<readable>-<hash>/… ; la page ne peut pas en choisir une),
Chercher des changements et Prendre les leurs vont tous à /api/git/*
sur la même origine. Le navigateur ne contacte aucun hôte de forge.
Ce qui reste où :
- Les jetons vivent sur le serveur, dans un coffre AES-GCM avec une clé par utilisateur.
- Le navigateur ne détient que le cookie
__Host-gitd(HttpOnly,Secure,SameSite=Strict), que les scripts ne peuvent pas lire. - Le jeton CSRF n’est gardé que dans la mémoire de la page et n’est jamais stocké.
- Toute écriture porte l’
Originexacte,X-Requested-With: slides-gitd, un corps JSON etX-CSRF-Token.
Quand la connexion a expiré ou manque (HTTP 401), le panneau affiche
« Votre connexion a pris fin » avec un bouton Se connecter
(/oauth2/sign_in?rd=<this page> d’oauth2-proxy). Il ne se rabat jamais sur un
jeton saisi dans le navigateur.
Pour le servir, utilisez selfhost/Caddyfile.gitd de go-slides/go-slides.github.io.
Sa Content-Security-Policy est connect-src 'self' ; le site reste public et
seul /api/git/* se trouve derrière oauth2-proxy. Le réglage origin de
slides-gitd doit être égal à l’origine du Playground. Une forge dont l’auth
est user-token (GitLab, par exemple) exige le jeton propre de chaque
utilisateur, enregistré sur le serveur par POST /api/git/credential. Le
panneau n’offre aucun champ pour cela : l’administrateur ou l’utilisateur le fait
en dehors de la page.
Une limite : le Playground garde les brouillons et le dernier nom de dépôt dans le profil du navigateur (IndexedDB et localStorage), comme auparavant. Sur un profil de navigateur partagé, utilisez Draft ▸ Forget my drafts ou des profils séparés. En mode serveur, un dépôt mémorisé auquel l’utilisateur n’a pas accès est abandonné.
Vérifier
curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0'La réponse indique content-type: application/wasm, content-encoding: br
et cache-control: public, max-age=31536000, immutable, avec le
content-security-policy ci-dessus. Avec l’authentification devant, la même
requête sans cookie répond 302 vers /oauth2/sign_in.
Références
- Caddy : HTTPS automatique,
file_server,header,reverse_proxy,forward_auth. - RFC 8555, ACME : HTTP-01 (8.3), DNS-01 (8.4).
- OpenID Connect Core 1.0 : le flux par code d’autorisation (3.1), l’URI de redirection (3.1.2.1), l’ID Token (2).
- oauth2-proxy : configuration, fournisseur OpenID Connect.
Créée le par tannevaled
Modifiée le par tannevaled