diff --git a/content/de/cli.md b/content/de/cli.md index 74e5c74..d1a1598 100644 --- a/content/de/cli.md +++ b/content/de/cli.md @@ -43,6 +43,7 @@ angibt. | `-s`, `--server dir` | `build` | stellt ein Verzeichnis bereit und wandelt seine Foliensätze auf Anfrage um (in v0.6.0); siehe unten | | `-p`, `--preview` | `build` | öffnet das Ergebnis im Standardbrowser (in v0.6.0): die HTML-Seiten mit `--watch` (impliziert), den Server mit `--server` | | `--host addr` | `build` | die Adresse, auf der `--server` lauscht, standardmäßig `127.0.0.1` (in v0.6.0) | +| `--allowed-host name` | `build` | der öffentliche Hostname eines Reverse-Proxys, auf den `--server` antwortet, wiederholbar (nach v0.13.0); siehe [Self-Hosting]({{< relref "self-hosting" >}}) | | `--template bespoke\|bare` | `build` | die Vorlage der HTML-Seite (in v0.6.0): `bespoke`, der Standard, mit Präsentationsskript, Bedienelementen auf dem Bildschirm und Übergängen, oder `bare`, die Folien untereinander (`--bare`) | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | die Optionen der bespoke-Vorlage, mit den Standardwerten von marp-cli (in v0.6.0): Bedienelemente an, **Fortschrittsbalken aus** (er wurde zuvor immer gezeichnet), Übergänge an; `--bespoke.osc=false` schaltet eines ab | | `-c`, `--config-file file`, `--no-config-file` | `build` | die Konfigurationsdatei von marp-cli, JSON oder YAML (in v0.6.0); siehe unten. `--config` ist die eigene Datei von `slides` | @@ -227,6 +228,12 @@ auf jeder Schnittstelle lauscht und nichts prüft: - nur `GET` und `HEAD`; wenige Umwandlungen gleichzeitig; höchstens 4096 Verzeichnisse beobachtet, und keines in `node_modules` oder unter einem Punkt. +Hinter einem Reverse-Proxy fügt `--allowed-host name` (nach v0.13.0, +wiederholbar) den öffentlichen Namen des Proxys zu den akzeptierten `Host` und +`Origin` hinzu; die Prüfungen bleiben aktiv, und der Server bleibt auf dem +Loopback. [Self-Hosting]({{< relref "self-hosting" >}}) stellt ihn hinter Caddy, +mit HTTPS und einer Anmeldung. + ## Seitenmetadaten {#page-metadata} Die Kommandozeile hat Vorrang vor dem Foliensatz, wie bei marp-cli, wo die Flags diff --git a/content/de/playground.md b/content/de/playground.md index 99eb8e5..e5a520a 100644 --- a/content/de/playground.md +++ b/content/de/playground.md @@ -162,6 +162,9 @@ jeder Frame erzeugt daraus seine eigenen `blob:`-URLs, in seinem eigenen Ursprun erzeugt wurde, lädt dort nicht, und die Richtlinie der Frames erhält lediglich `blob:` für Medien und Schriften hinzu. +Der Playground kann von Ihrem eigenen Rechner aus bereitgestellt werden, hinter +einer Anmeldung: siehe [Self-Hosting]({{< relref "self-hosting" >}}). + ## Größe und Geschwindigkeit {#size-and-speed} Das Modul umfasst 8,8 MiB, wie GitHub Pages es ausliefert (gzip; Pages liefert diff --git a/content/de/self-hosting.md b/content/de/self-hosting.md new file mode 100644 index 0000000..5c1bd02 --- /dev/null +++ b/content/de/self-hosting.md @@ -0,0 +1,286 @@ +--- +title: "Self-Hosting" +weight: 58 +description: "Den Playground und slides --server vom eigenen Rechner aus mit Caddy bereitstellen: automatisches HTTPS, die Header, die der Playground braucht, und eine OpenID-Connect-Anmeldung davor." +tags: [playground, server, security] +--- + +Der [Playground]({{< relref "playground" >}}) besteht aus statischen Dateien, und +[`slides --server`]({{< relref "cli" >}}) ist ein einzelner Prozess auf dem Loopback. Beide +können auf Ihrem eigenen Rechner laufen, unter Ihrem eigenen Namen, für Personen, die sich +mit dem Konto Ihrer Organisation anmelden. Diese Seite richtet das ein mit +[Caddy](https://caddyserver.com/docs/) (getestet mit v2.11.7), der die HTTPS-Zertifikate +selbst bezieht und erneuert, und +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) für die +Anmeldung. Jede hier gezeigte Datei liegt in +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost), +und deren CI prüft jedes Caddyfile mit `caddy validate`. + +## Der Playground {#the-playground} + +Jedes Release der Website hängt die ganze Website samt Playground als +ein Tarball an, gebaut für die Wurzel einer Domain, mit seiner SHA-256: + +```sh +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-site +``` + +Der Tarball ist reproduzierbar: Wer denselben Tag erneut baut, erhält dieselben +Bytes. Neben jeder Datei, die sich komprimieren lässt (die WebAssembly-Module, die +Schriften, die Seiten, die Skripte), liegt dieselbe Datei komprimiert mit +Brotli (`.br`) und gzip (`.gz`), sodass der Server sie unverändert sendet und +nichts im laufenden Betrieb komprimiert. + +### Caddyfile {#caddyfile} + +`slides.example.org` ist Ihr Name. Seine A- und AAAA-Einträge zeigen auf den +Rechner, und die Ports 80 und 443 erreichen Caddy: + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +Der Playground liegt dann unter `https://slides.example.org/playground/`. + +### Was der Server richtig machen muss {#what-the-server-must-get-right} + +- **HTTPS.** Caddy bezieht das Zertifikat von Let's Encrypt per ACME + ([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555)), beweist den Besitz + des Namens, indem es auf Port 80 (HTTP-01) oder 443 (TLS-ALPN-01) antwortet, und + erneuert es. Ein Rechner, den die CA nicht erreichen kann, kann stattdessen die + DNS-01-Challenge nutzen (ein TXT-Eintrag, wofür ein Caddy mit dem Modul Ihres + DNS-Anbieters gebaut sein muss) oder ein eigenes Zertifikat mit `tls cert.pem key.pem`. + Auf einem Testrechner verwendet `tls internal` die lokale CA von Caddy. +- **`application/wasm`.** Caddy sendet diesen Typ für `.wasm`; ein anderer Server + muss es möglicherweise ausdrücklich gesagt bekommen. Der Playground kompiliert seine Module aus den Bytes, die er + gelesen hat (die Export-Engine schon beim Herunterladen, in einer eigenen Antwort + mit diesem Typ), er startet also auch von einem Server, der einen anderen Typ sendet; + mit `nosniff` ist der richtige Typ trotzdem der, den man senden sollte. +- **Kompression.** `precompressed br gzip` sendet `playground.wasm.br` an einen + Browser, der Brotli akzeptiert, `.gz` an einen, der gzip akzeptiert, und sonst die Datei + selbst, mit `Vary: Accept-Encoding`. Das Modul des Editors hat + mit Brotli etwa 7 MiB, unkomprimiert 33 MiB. +- **Caching.** Die Seite fordert jedes Modul, jeden Worker, jede Schrift und jedes Bild + mit dem Release in der Query an (`playground.wasm?v=v0.1.0`), daher können diese + ein Jahr lang aufbewahrt werden (`immutable`). Die Seiten selbst werden neu validiert + (`no-cache`): Ein neues Release ist beim nächsten Besuch zu sehen. +- **Die Header in `headers.caddy`:** + +```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" +} +``` + +Die Richtlinie ist die, die der Playground braucht: seine Skripte (der Loader ist +inline), sein WebAssembly (`'wasm-unsafe-eval'`) und sein Worker, alle von +Ihrem Server. Bilder, Stylesheets und Schriften einer Präsentation dürfen von jeder +`https:`-Adresse kommen, wie in der Ausgabe des Befehls; entfernen Sie `https:` aus +`img-src`, `style-src`, `font-src`, `media-src` und `connect-src`, damit +alle Anfragen auf Ihrem Server bleiben; Präsentationen, die woandershin verweisen, werden dann ohne +diese Ressourcen angezeigt. Die Präsentation selbst läuft in iframes in einer Sandbox ohne Ihren +Ursprung, unter einer strengeren eigenen Richtlinie, die der Playground +in jeden Frame schreibt: Sie kann weder den Playground noch seinen Speicher noch Ihre +Cookies erreichen, was auch immer dieser Header sagt. `frame-ancestors 'self'` verhindert, dass andere +Websites den Playground in einen Frame einbetten. + +- **Kein COOP und kein COEP.** Der Playground verwendet weder `SharedArrayBuffer` noch + Threads, er muss also nicht cross-origin-isoliert sein. Wer + `Cross-Origin-Embedder-Policy: require-corp` sendet, blockiert die Bilder einer Präsentation + von anderen Websites. + +### Ohne Netzwerk {#without-the-network} + +Sobald die Seite geladen ist, passiert alles im Browser: der Editor, +die Vorschau, die Referentenansicht und die PDF-, PPTX- und ODP-Exporte, deren +Schriften im Tarball liegen. Die Präsentation wird weder an Ihren Server noch +sonst irgendwohin gesendet. Das Netzwerk brauchen die Dinge, die die Präsentation nennt: Bilder oder +Stylesheets unter einer `https:`-Adresse und die Webschrift des Themes `gaia`, die +es bei `fonts.bunny.net` anfordert (ohne sie weichen die Folien auf eine andere +Schrift aus). Die Links zu dieser Dokumentation verlassen Ihren Server. + +## `slides --server` hinter dem Proxy {#slides---server-behind-the-proxy} + +`slides --server` bleibt auf `127.0.0.1`, und Caddy leitet an ihn weiter. Der Server +prüft den `Host`-Header jeder Anfrage und den `Origin` des WebSockets jedes +Live-Reloads, gegen DNS-Rebinding und Seiten anderer Websites, und der +Proxy behält in beiden den öffentlichen Namen. `--allowed-host` fügt diesen Namen dem hinzu, +was die Prüfungen akzeptieren; sie bleiben eingeschaltet: + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.example.org { + reverse_proxy 127.0.0.1:8080 +} +``` + +Der Live-Reload geht unverändert durch (Caddy leitet WebSockets ohne +Konfiguration weiter). Verwenden Sie dafür nicht `--host 0.0.0.0`: Das stellt das +Verzeichnis dem ganzen Netzwerk bereit, am Proxy und seiner Anmeldung vorbei. + +{{< callout type="info" >}} +`--allowed-host` kommt nach v0.13.0. Bis zum nächsten Release installieren Sie +den Commit, der es enthält: +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e`. +{{< /callout >}} + +## Anmeldung mit OpenID Connect {#sign-in-with-openid-connect} + +Caddy fragt oauth2-proxy bei jeder Anfrage (`forward_auth`). oauth2-proxy +antwortet `202` für einen Browser, der sich angemeldet hat, was Caddy dann ausliefert, +und sonst `401`, was Caddy in eine Weiterleitung zur Anmeldung verwandelt. Die +Anmeldung ist der Authorization-Code-Flow von +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): +Der Browser geht zu Ihrem Anbieter (Keycloak, Authentik, Entra ID, Google, +Okta, Dex...), kommt mit einem Code auf `/oauth2/callback` zurück, oauth2-proxy +tauscht den Code gegen ein ID Token, prüft es und setzt sein eigenes Cookie. + +1. **Registrieren Sie einen Client** bei Ihrem Anbieter, vertraulich, mit dem + Authorization-Code-Flow. Seine Redirect-URIs, die genau übereinstimmen müssen: + `https://slides.example.org/oauth2/callback` und + `https://decks.example.org/oauth2/callback`. +2. **Legen Sie die Geheimnisse in Dateien**, die nur oauth2-proxy lesen kann: das + Geheimnis des Clients, ohne Zeilenumbruch am Ende, und ein Cookie-Geheimnis aus + genau 32 Zufallsbytes (`head -c 32 /dev/urandom`). Ihr + Secret-Speicher schreibt sie (systemd-Credentials, Docker- oder Kubernetes-Secrets, + Vault...); keines gehört in eine Konfigurationsdatei, eine Befehlszeile + oder eine Umgebungsvariable, wo Logs und `ps` sie zeigen. +3. **Konfigurieren Sie oauth2-proxy** (`oauth2-proxy.cfg`): + +```toml +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` ohne Host führt die Anmeldung jeder Website zu ihrem eigenen +Callback zurück, sodass jede Website ihr eigenes Cookie behält. `whitelist_domains` ist, wohin eine +Anmeldung zurückkehren darf: Soll sie anderswohin gehen, bleibt sie auf Ihrer Website. +`trusted_proxy_ips` glaubt den `X-Forwarded-*`-Headern nur von Caddy. +`email_domains` legt fest, wer hinein darf; oauth2-proxy hat außerdem Optionen für +Gruppen und Adresslisten. + +4. **Konfigurieren Sie Caddy** (`Caddyfile.oidc`): + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-proxy und `slides` lauschen nur auf dem Loopback: Der einzige Zugang führt +über Caddy und damit über die Anmeldung. Die Header des Playgrounds stehen +innerhalb des angemeldeten Blocks, nur beim Playground: Die Anmeldeseite von +oauth2-proxy ist ein Formular, das ihr `form-action 'none'` verhindern würde. + +Die Alternative ist [caddy-security](https://github.com/greenpau/caddy-security), +ein Caddy-Modul, das die Anmeldung innerhalb von Caddy erledigt, ohne zweiten Prozess. +Es braucht einen Caddy, der mit dem Modul gebaut wurde (`xcaddy`), und eine eigene +Konfigurationssprache. + +## Prüfen {#check-it} + +```sh +curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0' +``` + +Die Antwort nennt `content-type: application/wasm`, `content-encoding: br` +und `cache-control: public, max-age=31536000, immutable`, mit dem +`content-security-policy` von oben. Mit der Anmeldung davor antwortet dieselbe +Anfrage ohne Cookie mit `302` auf `/oauth2/sign_in`. + +## Referenzen {#references} + +- Caddy: [automatisches HTTPS](https://caddyserver.com/docs/automatic-https), + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server), + [`header`](https://caddyserver.com/docs/caddyfile/directives/header), + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth). +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555), ACME: HTTP-01 (8.3), + DNS-01 (8.4). +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): + der Authorization-Code-Flow (3.1), die Redirect-URI (3.1.2.1), das ID + Token (2). +- oauth2-proxy: [Konfiguration](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview), + [OpenID-Connect-Anbieter](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect). diff --git a/content/en/cli.md b/content/en/cli.md index 43abb2a..d75b6c6 100644 --- a/content/en/cli.md +++ b/content/en/cli.md @@ -43,6 +43,7 @@ otherwise. | `-s`, `--server dir` | `build` | serve a directory, converting its decks on request (in v0.6.0); see below | | `-p`, `--preview` | `build` | open the result in the default browser (in v0.6.0): the HTML pages with `--watch` (implied), the server with `--server` | | `--host addr` | `build` | the address `--server` listens on, `127.0.0.1` by default (in v0.6.0) | +| `--allowed-host name` | `build` | a reverse proxy's public host name that `--server` answers to, repeatable (after v0.13.0); see [Self-hosting]({{< relref "self-hosting" >}}) | | `--template bespoke\|bare` | `build` | the HTML page's template (in v0.6.0): `bespoke`, the default, with the presenting script, on-screen controller and transitions, or `bare`, the slides stacked (`--bare`) | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | the bespoke template's options, with marp-cli's defaults (in v0.6.0): controller on, **progress bar off** (it was always drawn before), transitions on; `--bespoke.osc=false` turns one off | | `-c`, `--config-file file`, `--no-config-file` | `build` | marp-cli's configuration file, JSON or YAML (in v0.6.0); see below. `--config` is `slides`' own file | @@ -229,6 +230,12 @@ listens on every interface and checks nothing: - only `GET` and `HEAD`; a few conversions at once; at most 4096 directories watched, and none in `node_modules` or under a dot. +Behind a reverse proxy, `--allowed-host name` (after v0.13.0, repeatable) +adds the proxy's public name to the accepted `Host` and `Origin`; the checks +stay on and the server stays on the loopback. +[Self-hosting]({{< relref "self-hosting" >}}) puts it behind Caddy, with +HTTPS and a sign-in. + ## Page metadata The command line wins over the deck, as in marp-cli, where the flags are diff --git a/content/en/playground.md b/content/en/playground.md index dd1db32..4f60e03 100644 --- a/content/en/playground.md +++ b/content/en/playground.md @@ -164,6 +164,9 @@ each frame makes its own `blob:` URLs of them, in its own origin. A `blob:` URL made by the playground does not load there, and the frames' policy gains only `blob:` for media and fonts. +The playground can be served from your own machine, behind a sign-in: +see [Self-hosting]({{< relref "self-hosting" >}}). + ## Size and speed The module is 8.8 MiB as GitHub Pages serves it (gzip; Pages does not diff --git a/content/en/self-hosting.md b/content/en/self-hosting.md new file mode 100644 index 0000000..7b5fe6e --- /dev/null +++ b/content/en/self-hosting.md @@ -0,0 +1,286 @@ +--- +title: "Self-hosting" +weight: 58 +description: "Serve the playground and slides --server from your own machine with Caddy: automatic HTTPS, the headers the playground needs, and an OpenID Connect sign-in in front." +tags: [playground, server, security] +--- + +The [playground]({{< relref "playground" >}}) is static files, and +[`slides --server`]({{< relref "cli" >}}) is one process on the loopback. Both +can run on your own machine, behind your own name, for people who sign in +with your organisation's account. This page does it with +[Caddy](https://caddyserver.com/docs/) (tested with v2.11.7), which obtains +and renews the HTTPS certificates itself, and +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) for the +sign-in. Every file shown here is in +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost), +and its CI checks each Caddyfile with `caddy validate`. + +## The playground + +Each release of the site attaches the whole site, playground included, as +one tarball built for the root of a domain, with its SHA-256: + +```sh +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-site +``` + +The tarball is reproducible: building the same tag again gives the same +bytes. Beside every file that compresses (the WebAssembly modules, the +fonts, the pages, the scripts) it carries the same file compressed with +Brotli (`.br`) and gzip (`.gz`), so the server sends them as they are and +compresses nothing on the fly. + +### Caddyfile + +`slides.example.org` is your name. Its A and AAAA records point at the +machine, and ports 80 and 443 reach Caddy: + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +The playground is then at `https://slides.example.org/playground/`. + +### What the server must get right + +- **HTTPS.** Caddy gets the certificate from Let's Encrypt with ACME + ([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555)), proving that it holds + the name by answering on port 80 (HTTP-01) or 443 (TLS-ALPN-01), and + renews it. A machine that the CA cannot reach can use the DNS-01 + challenge instead (a TXT record, which needs a Caddy built with your DNS + provider's module), or your own certificate with `tls cert.pem key.pem`. + On a test machine, `tls internal` uses Caddy's local CA. +- **`application/wasm`.** Caddy sends this type for `.wasm`; another server + may need to be told. The playground compiles its modules from the bytes it + has read (the export engine as it downloads, in a response of its own + with that type), so it starts even from a server that sends another type; + with `nosniff`, the right type is still the one to send. +- **Compression.** `precompressed br gzip` sends `playground.wasm.br` to a + browser that accepts Brotli, `.gz` to one that accepts gzip, and the file + itself otherwise, with `Vary: Accept-Encoding`. The editor's module is + about 7 MiB in Brotli, against 33 MiB uncompressed. +- **Caching.** The page asks for every module, worker, font and picture + with the release in the query (`playground.wasm?v=v0.1.0`), so those can + be kept for a year (`immutable`). The pages themselves are revalidated + (`no-cache`): a new release is seen at the next visit. +- **The headers in `headers.caddy`:** + +```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" +} +``` + +The policy is the one the playground needs: its scripts (the loader is +inline), its WebAssembly (`'wasm-unsafe-eval'`) and its worker, all from +your server. A deck's pictures, style sheets and fonts may come from any +`https:` address, as they do in the command's output; drop `https:` from +`img-src`, `style-src`, `font-src`, `media-src` and `connect-src` to keep +every request on your server, and decks that point elsewhere show without +those resources. The deck itself runs in iframes sandboxed without your +origin, under a stricter policy of their own that the playground writes +into each frame: it cannot reach the playground, its storage or your +cookies, whatever this header says. `frame-ancestors 'self'` stops other +sites from framing the playground. + +- **No COOP or COEP.** The playground uses neither `SharedArrayBuffer` nor + threads, so it does not need to be cross-origin isolated. Sending + `Cross-Origin-Embedder-Policy: require-corp` would block a deck's pictures + from other sites. + +### Without the network + +Once the page is loaded, everything happens in the browser: the editor, +the preview, the presenter view, and the PDF, PPTX and ODP exports, whose +fonts are in the tarball. The deck is sent neither to your server nor +anywhere else. What needs the network is what the deck names: pictures or +style sheets at an `https:` address, and the `gaia` theme's web font, which +it asks of `fonts.bunny.net` (without it, the slides fall back to another +font). The links to this documentation leave your server. + +## `slides --server` behind the proxy + +`slides --server` stays on `127.0.0.1` and Caddy forwards to it. The server +checks the `Host` header of each request and the `Origin` of each live +reload's WebSocket, against DNS rebinding and other sites' pages, and the +proxy keeps the public name in both. `--allowed-host` adds that name to +what the checks accept; they stay on: + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.example.org { + reverse_proxy 127.0.0.1:8080 +} +``` + +The live reload goes through as it is (Caddy proxies WebSockets without +configuration). Do not use `--host 0.0.0.0` for this: it serves the +directory to the whole network, around the proxy and its sign-in. + +{{< callout type="info" >}} +`--allowed-host` comes after v0.13.0. Until the next release, install the +commit that has it: +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e`. +{{< /callout >}} + +## Sign-in with OpenID Connect + +Caddy asks oauth2-proxy about every request (`forward_auth`). oauth2-proxy +answers `202` for a browser that has signed in, which Caddy then serves, +and `401` otherwise, which Caddy turns into a redirect to the sign-in. The +sign-in is the authorization code flow of +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): +the browser goes to your provider (Keycloak, Authentik, Entra ID, Google, +Okta, Dex...), comes back to `/oauth2/callback` with a code, oauth2-proxy +exchanges the code for an ID Token, checks it, and sets its own cookie. + +1. **Register a client** with your provider, confidential, with the + authorization code flow. Its redirect URIs, which must match exactly: + `https://slides.example.org/oauth2/callback` and + `https://decks.example.org/oauth2/callback`. +2. **Put the secrets in files** readable by oauth2-proxy alone: the + client's secret, with no newline at the end, and a cookie secret of + exactly 32 random bytes (`head -c 32 /dev/urandom`). Your + secret store writes them (systemd credentials, Docker or Kubernetes + secrets, Vault...); neither goes into a configuration file, a command + line or an environment variable, where logs and `ps` show them. +3. **Configure oauth2-proxy** (`oauth2-proxy.cfg`): + +```toml +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` without a host brings each site's sign-in back to its own +callback, so each site keeps its own cookie. `whitelist_domains` is where a +sign-in may return to: asked to go anywhere else, it stays on your site. +`trusted_proxy_ips` believes the `X-Forwarded-*` headers from Caddy alone. +`email_domains` says who may come in; oauth2-proxy also has options for +groups and lists of addresses. + +4. **Configure Caddy** (`Caddyfile.oidc`): + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-proxy and `slides` listen on the loopback only: the one way in is +through Caddy, and so through the sign-in. The playground's headers go +inside the signed-in block, on the playground alone: oauth2-proxy's sign-in +page is a form, which their `form-action 'none'` would stop. + +The alternative is [caddy-security](https://github.com/greenpau/caddy-security), +a Caddy module that does the sign-in inside Caddy, with no second process. +It needs a Caddy built with the module (`xcaddy`), and its own +configuration language. + +## Check it + +```sh +curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0' +``` + +The answer says `content-type: application/wasm`, `content-encoding: br` +and `cache-control: public, max-age=31536000, immutable`, with the +`content-security-policy` above. With the sign-in in front, the same +request without a cookie answers `302` to `/oauth2/sign_in`. + +## References + +- Caddy: [automatic HTTPS](https://caddyserver.com/docs/automatic-https), + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server), + [`header`](https://caddyserver.com/docs/caddyfile/directives/header), + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth). +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555), ACME: HTTP-01 (8.3), + DNS-01 (8.4). +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): + the authorization code flow (3.1), the redirect URI (3.1.2.1), the ID + Token (2). +- oauth2-proxy: [configuration](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview), + [OpenID Connect provider](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect). diff --git a/content/es/cli.md b/content/es/cli.md index c8d0701..c260721 100644 --- a/content/es/cli.md +++ b/content/es/cli.md @@ -44,6 +44,7 @@ deck.md` son equivalentes. Las opciones largas llevan dos guiones (`--html`); | `-s`, `--server dir` | `build` | sirve un directorio y convierte sus presentaciones bajo demanda (en v0.6.0); véase más abajo | | `-p`, `--preview` | `build` | abre el resultado en el navegador por defecto (en v0.6.0): las páginas HTML con `--watch` (implícito), el servidor con `--server` | | `--host addr` | `build` | la dirección en la que escucha `--server`, `127.0.0.1` por defecto (en v0.6.0) | +| `--allowed-host name` | `build` | el nombre de host público de un proxy inverso al que responde `--server`, repetible (después de v0.13.0); consulte [Autoalojamiento]({{< relref "self-hosting" >}}) | | `--template bespoke\|bare` | `build` | la plantilla de la página HTML (en v0.6.0): `bespoke`, la de por defecto, con el script de presentación, el controlador en pantalla y las transiciones, o `bare`, con las diapositivas apiladas (`--bare`) | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | las opciones de la plantilla bespoke, con los valores por defecto de marp-cli (en v0.6.0): controlador activado, **barra de progreso desactivada** (antes se dibujaba siempre), transiciones activadas; `--bespoke.osc=false` desactiva una | | `-c`, `--config-file file`, `--no-config-file` | `build` | el archivo de configuración de marp-cli, JSON o YAML (en v0.6.0); véase más abajo. `--config` es el archivo propio de `slides` | @@ -243,6 +244,12 @@ de marp-cli, que escucha en todas las interfaces y no comprueba nada: - solo `GET` y `HEAD`; pocas conversiones a la vez; como máximo 4096 directorios observados, y ninguno en `node_modules` ni bajo un punto. +Detrás de un proxy inverso, `--allowed-host name` (después de v0.13.0, +repetible) añade el nombre público del proxy a los `Host` y `Origin` aceptados; +las comprobaciones siguen activas y el servidor sigue en el bucle local. +[Autoalojamiento]({{< relref "self-hosting" >}}) lo coloca detrás de Caddy, con +HTTPS y un inicio de sesión. + ## Metadatos de la página {#page-metadata} La línea de comandos prevalece sobre la presentación, como en marp-cli, donde las diff --git a/content/es/playground.md b/content/es/playground.md index 284e405..85ffb5e 100644 --- a/content/es/playground.md +++ b/content/es/playground.md @@ -175,6 +175,9 @@ cada marco crea con ellos sus propias URL `blob:`, en su propio origen. Una URL `blob:` creada por el playground no se carga allí, y la política de los marcos solo añade `blob:` para medios y fuentes. +El playground puede servirse desde su propio equipo, detrás de un inicio de +sesión: consulte [Autoalojamiento]({{< relref "self-hosting" >}}). + ## Tamaño y velocidad {#size-and-speed} El módulo pesa 8,8 MiB tal como lo sirve GitHub Pages (gzip; Pages no sirve diff --git a/content/es/self-hosting.md b/content/es/self-hosting.md new file mode 100644 index 0000000..c81b8c5 --- /dev/null +++ b/content/es/self-hosting.md @@ -0,0 +1,286 @@ +--- +title: "Autoalojamiento" +weight: 58 +description: "Sirva el playground y slides --server desde su propio equipo con Caddy: HTTPS automático, las cabeceras que necesita el playground y un inicio de sesión OpenID Connect por delante." +tags: [playground, server, security] +--- + +El [playground]({{< relref "playground" >}}) son archivos estáticos, y +[`slides --server`]({{< relref "cli" >}}) es un solo proceso en el bucle local. Ambos +pueden ejecutarse en su propio equipo, con su propio nombre, para personas que inician +sesión con la cuenta de su organización. Esta página lo hace con +[Caddy](https://caddyserver.com/docs/) (probado con v2.11.7), que obtiene +y renueva por sí mismo los certificados HTTPS, y +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) para el +inicio de sesión. Todos los archivos que se muestran aquí están en +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost), +y su CI comprueba cada Caddyfile con `caddy validate`. + +## El playground {#the-playground} + +Cada versión del sitio adjunta el sitio completo, playground incluido, como +un solo tarball construido para la raíz de un dominio, con su SHA-256: + +```sh +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-site +``` + +El tarball es reproducible: construir de nuevo la misma etiqueta da los mismos +bytes. Junto a cada archivo que se comprime (los módulos WebAssembly, las +fuentes, las páginas, los scripts), lleva el mismo archivo comprimido con +Brotli (`.br`) y gzip (`.gz`), de modo que el servidor los envía tal cual y +no comprime nada al vuelo. + +### Caddyfile {#caddyfile} + +`slides.example.org` es su nombre. Sus registros A y AAAA apuntan al +equipo, y los puertos 80 y 443 llegan a Caddy: + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +El playground queda entonces en `https://slides.example.org/playground/`. + +### Lo que el servidor debe hacer bien {#what-the-server-must-get-right} + +- **HTTPS.** Caddy obtiene el certificado de Let's Encrypt con ACME + ([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555)), demostrando que posee + el nombre al responder en el puerto 80 (HTTP-01) o 443 (TLS-ALPN-01), y + lo renueva. Un equipo al que la CA no puede llegar puede usar en su lugar el desafío + DNS-01 (un registro TXT, que requiere un Caddy compilado con el módulo de su + proveedor de DNS), o su propio certificado con `tls cert.pem key.pem`. + En un equipo de pruebas, `tls internal` usa la CA local de Caddy. +- **`application/wasm`.** Caddy envía este tipo para `.wasm`; en otro servidor + puede que haya que indicarlo. El playground compila sus módulos a partir de los bytes que + ha leído (el motor de exportación mientras se descarga, en una respuesta propia + con ese tipo), así que arranca incluso desde un servidor que envía otro tipo; + con `nosniff`, el tipo correcto sigue siendo el que hay que enviar. +- **Compresión.** `precompressed br gzip` envía `playground.wasm.br` a un + navegador que acepta Brotli, `.gz` a uno que acepta gzip, y el archivo + mismo en caso contrario, con `Vary: Accept-Encoding`. El módulo del editor pesa + unos 7 MiB en Brotli, frente a 33 MiB sin comprimir. +- **Caché.** La página pide cada módulo, worker, fuente e imagen + con la versión en la consulta (`playground.wasm?v=v0.1.0`), de modo que se pueden + conservar durante un año (`immutable`). Las páginas en sí se revalidan + (`no-cache`): una versión nueva se ve en la siguiente visita. +- **Las cabeceras de `headers.caddy`:** + +```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 política es la que necesita el playground: sus scripts (el cargador está +en línea), su WebAssembly (`'wasm-unsafe-eval'`) y su worker, todos desde +su servidor. Las imágenes, hojas de estilo y fuentes de una presentación pueden venir de cualquier +dirección `https:`, como en la salida del comando; quite `https:` de +`img-src`, `style-src`, `font-src`, `media-src` y `connect-src` para mantener +todas las peticiones en su servidor, y las presentaciones que apunten a otra parte se mostrarán sin +esos recursos. La presentación en sí se ejecuta en iframes aislados (sandbox) sin su +origen, bajo una política propia más estricta que el playground escribe +en cada marco: no puede llegar al playground, a su almacenamiento ni a sus +cookies, diga lo que diga esta cabecera. `frame-ancestors 'self'` impide que otros +sitios incrusten el playground en un marco. + +- **Ni COOP ni COEP.** El playground no usa `SharedArrayBuffer` ni + hilos, así que no necesita estar aislado entre orígenes. Enviar + `Cross-Origin-Embedder-Policy: require-corp` bloquearía las imágenes de una presentación + procedentes de otros sitios. + +### Sin red {#without-the-network} + +Una vez cargada la página, todo ocurre en el navegador: el editor, +la vista previa, la vista del presentador y las exportaciones a PDF, PPTX y ODP, cuyas +fuentes están en el tarball. La presentación no se envía ni a su servidor ni +a ningún otro sitio. Lo que necesita la red es lo que la presentación nombra: imágenes u +hojas de estilo en una dirección `https:`, y la fuente web del tema `gaia`, que +pide a `fonts.bunny.net` (sin ella, las diapositivas recurren a otra +fuente). Los enlaces a esta documentación salen de su servidor. + +## `slides --server` detrás del proxy {#slides---server-behind-the-proxy} + +`slides --server` se queda en `127.0.0.1` y Caddy le reenvía las peticiones. El servidor +comprueba la cabecera `Host` de cada petición y el `Origin` del WebSocket de cada +recarga en vivo, contra el DNS rebinding y las páginas de otros sitios, y el +proxy conserva el nombre público en ambos. `--allowed-host` añade ese nombre a +lo que aceptan las comprobaciones; estas siguen activas: + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.example.org { + reverse_proxy 127.0.0.1:8080 +} +``` + +La recarga en vivo pasa tal cual (Caddy hace de proxy de los WebSockets sin +configuración). No use `--host 0.0.0.0` para esto: serviría el +directorio a toda la red, saltándose el proxy y su inicio de sesión. + +{{< callout type="info" >}} +`--allowed-host` llega después de v0.13.0. Hasta la próxima versión, instale +el commit que lo incluye: +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e`. +{{< /callout >}} + +## Inicio de sesión con OpenID Connect {#sign-in-with-openid-connect} + +Caddy consulta a oauth2-proxy en cada petición (`forward_auth`). oauth2-proxy +responde `202` a un navegador que ha iniciado sesión, y Caddy entonces lo sirve, +y `401` en caso contrario, que Caddy convierte en una redirección al inicio de sesión. El +inicio de sesión es el flujo de código de autorización de +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): +el navegador va a su proveedor (Keycloak, Authentik, Entra ID, Google, +Okta, Dex...), vuelve a `/oauth2/callback` con un código, oauth2-proxy +intercambia el código por un ID Token, lo comprueba y establece su propia cookie. + +1. **Registre un cliente** en su proveedor, confidencial, con el flujo de + código de autorización. Sus URI de redirección, que deben coincidir exactamente: + `https://slides.example.org/oauth2/callback` y + `https://decks.example.org/oauth2/callback`. +2. **Ponga los secretos en archivos** que solo oauth2-proxy pueda leer: el + secreto del cliente, sin salto de línea al final, y un secreto de cookie de + exactamente 32 bytes aleatorios (`head -c 32 /dev/urandom`). Su + almacén de secretos los escribe (credenciales de systemd, secretos de Docker o Kubernetes, + Vault...); ninguno va en un archivo de configuración, una línea + de comandos ni una variable de entorno, donde los registros y `ps` los muestran. +3. **Configure oauth2-proxy** (`oauth2-proxy.cfg`): + +```toml +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` sin host devuelve el inicio de sesión de cada sitio a su propio +callback, de modo que cada sitio conserva su propia cookie. `whitelist_domains` es adonde puede volver un +inicio de sesión: si se le pide ir a cualquier otro sitio, se queda en el suyo. +`trusted_proxy_ips` solo cree las cabeceras `X-Forwarded-*` que vienen de Caddy. +`email_domains` indica quién puede entrar; oauth2-proxy también tiene opciones para +grupos y listas de direcciones. + +4. **Configure Caddy** (`Caddyfile.oidc`): + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-proxy y `slides` escuchan solo en el bucle local: la única entrada pasa +por Caddy y, por tanto, por el inicio de sesión. Las cabeceras del playground van +dentro del bloque con sesión iniciada, solo en el playground: la página de inicio +de sesión de oauth2-proxy es un formulario, que su `form-action 'none'` bloquearía. + +La alternativa es [caddy-security](https://github.com/greenpau/caddy-security), +un módulo de Caddy que hace el inicio de sesión dentro de Caddy, sin un segundo proceso. +Necesita un Caddy compilado con el módulo (`xcaddy`) y su propio lenguaje +de configuración. + +## Compruébelo {#check-it} + +```sh +curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0' +``` + +La respuesta indica `content-type: application/wasm`, `content-encoding: br` +y `cache-control: public, max-age=31536000, immutable`, con el +`content-security-policy` de arriba. Con el inicio de sesión por delante, la misma +petición sin cookie responde `302` a `/oauth2/sign_in`. + +## Referencias {#references} + +- Caddy: [HTTPS automático](https://caddyserver.com/docs/automatic-https), + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server), + [`header`](https://caddyserver.com/docs/caddyfile/directives/header), + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth). +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555), ACME: HTTP-01 (8.3), + DNS-01 (8.4). +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): + el flujo de código de autorización (3.1), la URI de redirección (3.1.2.1), el ID + Token (2). +- oauth2-proxy: [configuración](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview), + [proveedor OpenID Connect](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect). diff --git a/content/fr/cli.md b/content/fr/cli.md index 4e1373c..b1777fe 100644 --- a/content/fr/cli.md +++ b/content/fr/cli.md @@ -43,6 +43,7 @@ autrement. | `-s`, `--server dir` | `build` | sert un répertoire en convertissant ses présentations à la demande (depuis la v0.6.0) ; voir plus bas | | `-p`, `--preview` | `build` | ouvre le résultat dans le navigateur par défaut (depuis la v0.6.0) : les pages HTML avec `--watch` (implicite), le serveur avec `--server` | | `--host addr` | `build` | l'adresse sur laquelle écoute `--server`, `127.0.0.1` par défaut (depuis la v0.6.0) | +| `--allowed-host name` | `build` | le nom d'hôte public d'un reverse proxy auquel `--server` répond, répétable (après la v0.13.0) ; voir [Auto-hébergement]({{< relref "self-hosting" >}}) | | `--template bespoke\|bare` | `build` | le gabarit de la page HTML (depuis la v0.6.0) : `bespoke`, par défaut, avec le script de présentation, le contrôleur à l'écran et les transitions, ou `bare`, les diapositives empilées (`--bare`) | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | les options du gabarit bespoke, avec les valeurs par défaut de marp-cli (depuis la v0.6.0) : contrôleur activé, **barre de progression désactivée** (elle était auparavant toujours affichée), transitions activées ; `--bespoke.osc=false` en désactive une | | `-c`, `--config-file file`, `--no-config-file` | `build` | le fichier de configuration de marp-cli, en JSON ou en YAML (depuis la v0.6.0) ; voir plus bas. `--config` désigne le fichier propre à `slides` | @@ -229,6 +230,12 @@ Le serveur est une porte d'entrée sur la machine : il est donc plus strict que - seulement `GET` et `HEAD` ; quelques conversions à la fois ; au plus 4096 répertoires surveillés, et aucun dans `node_modules` ni sous un répertoire commençant par un point. +Derrière un reverse proxy, `--allowed-host name` (après la v0.13.0, répétable) +ajoute le nom public du proxy aux `Host` et `Origin` acceptés ; les contrôles +restent actifs et le serveur reste sur la boucle locale. +[Auto-hébergement]({{< relref "self-hosting" >}}) le place derrière Caddy, avec +HTTPS et une authentification. + ## Métadonnées de la page {#page-metadata} La ligne de commande l'emporte sur la présentation, comme dans marp-cli, où les options sont diff --git a/content/fr/playground.md b/content/fr/playground.md index 555b7de..44c50ef 100644 --- a/content/fr/playground.md +++ b/content/fr/playground.md @@ -163,6 +163,9 @@ chaque cadre en fait ses propres URL `blob:`, dans sa propre origine. Une URL `b créée par le playground ne s'y charge pas, et la politique des cadres n'ajoute que `blob:` pour les médias et les polices. +Le playground peut être servi depuis votre propre machine, derrière une +authentification : voir [Auto-hébergement]({{< relref "self-hosting" >}}). + ## Taille et vitesse {#size-and-speed} Le module pèse 8,8 Mio tel que GitHub Pages le sert (gzip ; Pages ne diff --git a/content/fr/self-hosting.md b/content/fr/self-hosting.md new file mode 100644 index 0000000..64e7c69 --- /dev/null +++ b/content/fr/self-hosting.md @@ -0,0 +1,286 @@ +--- +title: "Auto-hébergement" +weight: 58 +description: "Servir le playground et slides --server depuis votre propre machine avec Caddy : HTTPS automatique, les en-têtes dont le playground a besoin, et une authentification OpenID Connect devant." +tags: [playground, server, security] +--- + +Le [playground]({{< relref "playground" >}}) est un ensemble de fichiers statiques, et +[`slides --server`]({{< relref "cli" >}}) 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](https://caddyserver.com/docs/) (testé avec la v2.11.7), qui obtient +et renouvelle lui-même les certificats HTTPS, et +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) pour +l'authentification. Tous les fichiers montrés ici se trouvent dans +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost), +et son CI vérifie chaque Caddyfile avec `caddy validate`. + +## Le playground {#the-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 : + +```sh +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-site +``` + +L'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 {#caddyfile} + +`slides.example.org` est votre nom. Ses enregistrements A et AAAA pointent vers la +machine, et les ports 80 et 443 atteignent Caddy : + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +Le playground est alors à l'adresse `https://slides.example.org/playground/`. + +### Ce que le serveur doit faire correctement {#what-the-server-must-get-right} + +- **HTTPS.** Caddy obtient le certificat auprès de Let's Encrypt avec ACME + ([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555)), 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 internal` utilise 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 ; + avec `nosniff`, le bon type reste celui qu'il faut envoyer. +- **Compression.** `precompressed br gzip` envoie `playground.wasm.br` à un + navigateur qui accepte Brotli, `.gz` à un navigateur qui accepte gzip, et le fichier + lui-même sinon, avec `Vary: 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` :** + +```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 `SharedArrayBuffer` ni + threads, il n'a donc pas besoin d'être isolé en cross-origin. Envoyer + `Cross-Origin-Embedder-Policy: require-corp` bloquerait les images d'une présentation + venant d'autres sites. + +### Sans le réseau {#without-the-network} + +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-behind-the-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 : + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.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. + +{{< callout type="info" >}} +`--allowed-host` vient après la v0.13.0. En attendant la prochaine version, installez +le commit qui le contient : +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e`. +{{< /callout >}} + +## Authentification avec OpenID Connect {#sign-in-with-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](https://openid.net/specs/openid-connect-core-1_0.html) : +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. + +1. **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/callback` et + `https://decks.example.org/oauth2/callback`. +2. **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 et `ps` les montrent. +3. **Configurez oauth2-proxy** (`oauth2-proxy.cfg`) : + +```toml +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. + +4. **Configurez Caddy** (`Caddyfile.oidc`) : + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-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](https://github.com/greenpau/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. + +## Vérifier {#check-it} + +```sh +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 {#references} + +- Caddy : [HTTPS automatique](https://caddyserver.com/docs/automatic-https), + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server), + [`header`](https://caddyserver.com/docs/caddyfile/directives/header), + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth). +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555), ACME : HTTP-01 (8.3), + DNS-01 (8.4). +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) : + le flux par code d'autorisation (3.1), l'URI de redirection (3.1.2.1), l'ID + Token (2). +- oauth2-proxy : [configuration](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview), + [fournisseur OpenID Connect](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect). diff --git a/content/ja/cli.md b/content/ja/cli.md index cd9f05c..017aef8 100644 --- a/content/ja/cli.md +++ b/content/ja/cli.md @@ -42,6 +42,7 @@ deck.md` は同じです。長い形式のフラグはダッシュ 2 つ(`--ht | `-s`, `--server dir` | `build` | ディレクトリを配信し、リクエストに応じてその中のスライドデッキを変換します(v0.6.0)。後述 | | `-p`, `--preview` | `build` | 結果をデフォルトのブラウザーで開きます(v0.6.0)。`--watch`(暗黙的に有効)では HTML ページを、`--server` ではサーバーを開きます | | `--host addr` | `build` | `--server` が待ち受けるアドレス。デフォルトは `127.0.0.1`(v0.6.0) | +| `--allowed-host name` | `build` | `--server` が応答する、リバースプロキシの公開ホスト名。複数回指定できます(v0.13.0 の後)。[セルフホスティング]({{< relref "self-hosting" >}})を参照 | | `--template bespoke\|bare` | `build` | HTML ページのテンプレート(v0.6.0)。デフォルトの `bespoke` は発表用スクリプト、画面上のコントローラー、トランジション付き、`bare` はスライドを縦に並べたもの(`--bare`)です | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | bespoke テンプレートのオプション。デフォルトは marp-cli と同じです(v0.6.0):コントローラーはオン、**プログレスバーはオフ**(以前は常に表示されていました)、トランジションはオン。`--bespoke.osc=false` のようにして個別にオフにします | | `-c`, `--config-file file`, `--no-config-file` | `build` | marp-cli の設定ファイル(JSON または YAML、v0.6.0)。後述。`--config` は `slides` 独自の設定ファイルです | @@ -224,6 +225,12 @@ marp-cli のサーバーよりも厳しくしています。 - 受け付けるのは `GET` と `HEAD` だけです。同時に行う変換は少数に限られ、監視するディレクトリは 最大 4096 個で、`node_modules` の中やドットで始まるディレクトリ以下は監視しません。 +リバースプロキシの背後では、`--allowed-host name`(v0.13.0 の後、複数回指定可)が +プロキシの公開名を、受け付ける `Host` と `Origin` に加えます。チェックは有効なままで、 +サーバーもループバックにとどまります。 +[セルフホスティング]({{< relref "self-hosting" >}})では、これを Caddy の背後に置き、 +HTTPS とサインインを組み合わせます。 + ## ページのメタデータ {#page-metadata} marp-cli と同じく、コマンドラインの指定がスライドデッキより優先されます。marp-cli では、フラグは diff --git a/content/ja/playground.md b/content/ja/playground.md index a1f971f..f38550c 100644 --- a/content/ja/playground.md +++ b/content/ja/playground.md @@ -158,6 +158,9 @@ iframe はストレージを共有できないので、Playground がメッセ 各フレームは自身のオリジンで、それぞれ独自の `blob:` URL を作成します。Playground が作成した `blob:` URL はそこでは読み込まれず、フレームのポリシーに追加されるのは、メディアとフォント向けの `blob:` だけです。 +Playground は、サインインを必須にしたうえで、自分のマシンから配信することもできます。詳しくは +[セルフホスティング]({{< relref "self-hosting" >}})を参照してください。 + ## サイズと速度 {#size-and-speed} モジュールは、GitHub Pages が配信する状態で 8.8 MiB です(gzip。Pages は diff --git a/content/ja/self-hosting.md b/content/ja/self-hosting.md new file mode 100644 index 0000000..ecc3a7c --- /dev/null +++ b/content/ja/self-hosting.md @@ -0,0 +1,278 @@ +--- +title: "セルフホスティング" +weight: 58 +description: "Caddy を使って、Playground と slides --server を自分のマシンから配信します。自動 HTTPS、Playground に必要なヘッダー、前段の OpenID Connect サインインまで説明します。" +tags: [playground, server, security] +--- + +[Playground]({{< relref "playground" >}})は静的ファイルの集まりで、 +[`slides --server`]({{< relref "cli" >}})はループバック上で動く 1 つのプロセスです。どちらも +自分のマシン上で、自分のドメイン名のもとに動かし、組織のアカウントでサインインする人に +提供できます。このページでは、HTTPS 証明書を自分で取得・更新する +[Caddy](https://caddyserver.com/docs/)(v2.11.7 で動作確認)と、 +サインインのための +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) を使います。ここに示すファイルはすべて +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost) +にあり、その CI が各 Caddyfile を `caddy validate` で検証しています。 + +## Playground {#the-playground} + +サイトの各リリースには、Playground を含むサイト全体が、 +ドメインのルート向けにビルドされた 1 つの tarball として、SHA-256 とともに添付されています。 + +```sh +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-site +``` + +この tarball は再現可能です。同じタグをもう一度ビルドすると、同じバイト列になります。 +圧縮できるファイル(WebAssembly モジュール、フォント、ページ、スクリプト)のそれぞれの隣には、 +同じファイルを Brotli(`.br`)と gzip(`.gz`)で圧縮したものも入っているので、サーバーはそれをそのまま送り、 +その場で圧縮することはありません。 + +### Caddyfile {#caddyfile} + +`slides.example.org` はあなたのドメイン名です。その A レコードと AAAA レコードがこのマシンを指し、 +ポート 80 と 443 が Caddy に届くようにします。 + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +これで Playground は `https://slides.example.org/playground/` で使えます。 + +### サーバーが正しく行うべきこと {#what-the-server-must-get-right} + +- **HTTPS。** Caddy は ACME([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555))で + Let's Encrypt から証明書を取得します。80 番ポート(HTTP-01)または 443 番ポート(TLS-ALPN-01)で + 応答して名前の所有を証明し、証明書を更新します。CA から到達できないマシンでは、代わりに + DNS-01 チャレンジ(TXT レコード。DNS プロバイダーのモジュールを組み込んだ Caddy が必要)か、 + `tls cert.pem key.pem` による自前の証明書を使えます。 + テスト用のマシンでは、`tls internal` が Caddy のローカル CA を使います。 +- **`application/wasm`。** Caddy は `.wasm` にこの型を付けて送ります。他のサーバーでは + 設定が必要かもしれません。Playground は読み込んだバイト列からモジュールをコンパイルする + (エクスポートエンジンはダウンロードしながら、この型の専用レスポンスで)ので、別の型を送るサーバーからでも + 起動しますが、`nosniff` がある場合でも、正しい型を送るのが望ましい形です。 +- **圧縮。** `precompressed br gzip` は、Brotli を受け付けるブラウザーには `playground.wasm.br` を、 + gzip を受け付けるブラウザーには `.gz` を、どちらも受け付けなければファイルそのものを、 + `Vary: Accept-Encoding` を付けて送ります。エディターのモジュールは Brotli で約 7 MiB、 + 無圧縮では 33 MiB です。 +- **キャッシュ。** ページは、モジュール、ワーカー、フォント、画像のすべてを、 + クエリにリリースを付けて(`playground.wasm?v=v0.1.0`)要求するので、これらは + 1 年間保持できます(`immutable`)。ページ自体は毎回再検証されます + (`no-cache`)。新しいリリースは次の訪問で反映されます。 +- **`headers.caddy` のヘッダー:** + +```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" +} +``` + +このポリシーは Playground に必要なものです。スクリプト(ローダーはインライン)、 +WebAssembly(`'wasm-unsafe-eval'`)、ワーカーは、すべてあなたのサーバーから読み込まれます。 +デッキの画像、スタイルシート、フォントは、コマンドの出力と同様に、任意の `https:` アドレスから +読み込めます。すべてのリクエストを自分のサーバーにとどめたい場合は、`img-src`、`style-src`、 +`font-src`、`media-src`、`connect-src` から `https:` を外してください。 +他所を指しているデッキは、それらのリソースなしで表示されます。デッキ自体は、あなたのオリジンを持たない +サンドボックス化された iframe の中で、Playground が各フレームに書き込む、より厳しい独自のポリシーのもとで動きます。 +このヘッダーの内容にかかわらず、デッキは Playground にも、そのストレージにも、あなたの +Cookie にも届きません。`frame-ancestors 'self'` は、他のサイトが Playground を +フレームに埋め込むのを防ぎます。 + +- **COOP も COEP もなし。** Playground は `SharedArrayBuffer` もスレッドも使わないので、 + クロスオリジン分離は不要です。 + `Cross-Origin-Embedder-Policy: require-corp` を送ると、デッキが他のサイトから読み込む画像が + ブロックされてしまいます。 + +### ネットワークなしで {#without-the-network} + +ページを読み込んだ後は、エディター、プレビュー、発表者ビュー、そして PDF・PPTX・ODP へのエクスポート +(フォントは tarball に含まれています)まで、すべてブラウザー内で完結します。デッキはあなたの +サーバーにも他のどこにも送られません。ネットワークが必要なのは、デッキが名指ししているもの、 +つまり `https:` アドレスの画像やスタイルシート、そして `gaia` テーマの Web フォントです。 +このフォントは `fonts.bunny.net` に要求します(取得できない場合、スライドは別のフォントで表示されます)。 +このドキュメントへのリンクは、あなたのサーバーの外に出ます。 + +## プロキシの背後で `slides --server` を動かす {#slides---server-behind-the-proxy} + +`slides --server` は `127.0.0.1` にとどまり、Caddy がそこへ転送します。サーバーは、DNS リバインディングや +他のサイトのページからの攻撃を防ぐため、各リクエストの `Host` ヘッダーと、各ライブリロードの +WebSocket の `Origin` を確認します。プロキシはどちらにも公開名を保ちます。 +`--allowed-host` は、その名前をチェックが受け付ける対象に加えます。チェックは有効なままです。 + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.example.org { + reverse_proxy 127.0.0.1:8080 +} +``` + +ライブリロードはそのまま通ります(Caddy は設定なしで WebSocket をプロキシします)。 +このために `--host 0.0.0.0` を使ってはいけません。ディレクトリがネットワーク全体に公開され、 +プロキシとそのサインインを素通りしてしまいます。 + +{{< callout type="info" >}} +`--allowed-host` は v0.13.0 の後に入りました。次のリリースまでは、 +それを含むコミットをインストールしてください。 +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e` +{{< /callout >}} + +## OpenID Connect によるサインイン {#sign-in-with-openid-connect} + +Caddy はすべてのリクエストについて oauth2-proxy に問い合わせます(`forward_auth`)。oauth2-proxy は、 +サインイン済みのブラウザーには `202` を返し、Caddy はそのリクエストを配信します。 +それ以外には `401` を返し、Caddy はこれをサインインへのリダイレクトに変えます。 +サインインは +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +の認可コードフローです。ブラウザーはプロバイダー(Keycloak、Authentik、Entra ID、Google、 +Okta、Dex など)に行き、コードを持って `/oauth2/callback` に戻り、oauth2-proxy が +そのコードを ID Token に交換して検証し、独自の Cookie を設定します。 + +1. プロバイダーに**クライアントを登録**します。機密クライアントで、認可コードフローを使います。 + リダイレクト URI は完全に一致する必要があります。 + `https://slides.example.org/oauth2/callback` と + `https://decks.example.org/oauth2/callback` です。 +2. **シークレットはファイルに置き**、oauth2-proxy だけが読めるようにします。クライアントの + シークレット(末尾に改行を付けない)と、ちょうど 32 バイトのランダムな + Cookie シークレット(`head -c 32 /dev/urandom`)です。 + シークレットストア(systemd の資格情報、Docker や Kubernetes の secret、Vault など)が + 書き出します。どちらも、設定ファイル、コマンドライン、環境変数には入れないでください。 + ログや `ps` に表示されてしまいます。 +3. **oauth2-proxy を設定**します(`oauth2-proxy.cfg`)。 + +```toml +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` は、各サイトのサインインをそのサイト自身のコールバックに戻すので、 +各サイトは独自の Cookie を持ちます。`whitelist_domains` はサインイン後に戻ってよい先です。 +それ以外の場所へ行くよう求められても、サインインはあなたのサイトにとどまります。 +`trusted_proxy_ips` は、Caddy からの `X-Forwarded-*` ヘッダーだけを信頼します。 +`email_domains` は入れる人を決めます。oauth2-proxy には、グループやアドレス一覧のための +オプションもあります。 + +4. **Caddy を設定**します(`Caddyfile.oidc`)。 + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-proxy と `slides` はループバックだけで待ち受けます。入口は Caddy だけで、 +したがってサインインを必ず通ります。Playground のヘッダーはサインイン済みブロックの内側に置き、 +Playground にだけ適用します。oauth2-proxy のサインインページはフォームであり、 +`form-action 'none'` がそれを止めてしまうからです。 + +別の方法として、[caddy-security](https://github.com/greenpau/caddy-security) があります。 +これは Caddy の中でサインインを行う Caddy モジュールで、2 つ目のプロセスは要りません。 +ただし、このモジュールを組み込んだ Caddy(`xcaddy`)と、独自の設定言語が必要です。 + +## 確認する {#check-it} + +```sh +curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0' +``` + +応答には `content-type: application/wasm`、`content-encoding: br`、 +`cache-control: public, max-age=31536000, immutable` が含まれ、上記の +`content-security-policy` も付いています。サインインを前段に置いている場合、同じリクエストを +Cookie なしで送ると、`302` で `/oauth2/sign_in` に転送されます。 + +## 参考資料 {#references} + +- Caddy:[automatic HTTPS](https://caddyserver.com/docs/automatic-https)、 + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server)、 + [`header`](https://caddyserver.com/docs/caddyfile/directives/header)、 + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)、 + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth) +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555)、ACME:HTTP-01(8.3)、 + DNS-01(8.4) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): + 認可コードフロー(3.1)、リダイレクト URI(3.1.2.1)、ID + Token(2) +- oauth2-proxy:[configuration](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview)、 + [OpenID Connect provider](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect) diff --git a/content/ko/cli.md b/content/ko/cli.md index 73d64fe..e46c305 100644 --- a/content/ko/cli.md +++ b/content/ko/cli.md @@ -43,6 +43,7 @@ deck.md`는 같습니다. 긴 플래그는 대시 두 개(`--html`)로 쓰며, | `-s`, `--server dir` | `build` | 디렉터리를 제공하며, 요청이 오면 그 안의 프레젠테이션을 변환합니다(v0.6.0부터). 아래 참고 | | `-p`, `--preview` | `build` | 결과를 기본 브라우저로 엽니다(v0.6.0부터). `--watch`(자동 적용)와 함께면 HTML 페이지를, `--server`와 함께면 서버를 엽니다 | | `--host addr` | `build` | `--server`가 수신할 주소. 기본값은 `127.0.0.1`입니다(v0.6.0부터) | +| `--allowed-host name` | `build` | `--server`가 응답할 리버스 프록시의 공개 호스트 이름. 여러 번 지정할 수 있습니다(v0.13.0 이후). [셀프 호스팅]({{< relref "self-hosting" >}}) 참고 | | `--template bespoke\|bare` | `build` | HTML 페이지 템플릿(v0.6.0부터). 기본값인 `bespoke`는 발표 스크립트, 화면 컨트롤러, 전환 효과를 포함하고, `bare`는 슬라이드를 쌓아 놓습니다(`--bare`) | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | bespoke 템플릿의 옵션으로, 기본값은 marp-cli와 같습니다(v0.6.0부터). 컨트롤러 켜짐, **진행률 막대 꺼짐**(이전에는 항상 그려졌음), 전환 효과 켜짐. `--bespoke.osc=false`처럼 하나씩 끌 수 있습니다 | | `-c`, `--config-file file`, `--no-config-file` | `build` | marp-cli의 설정 파일(JSON 또는 YAML, v0.6.0부터). 아래 참고. `--config`는 `slides` 자체의 설정 파일입니다 | @@ -235,6 +236,12 @@ v0.6.0부터 marp-cli의 경로와 실시간 새로 고침 프로토콜을 지 디렉터리는 최대 4096개이며 `node_modules` 안이나 점으로 시작하는 디렉터리 아래는 감시하지 않습니다. +리버스 프록시 뒤에서는 `--allowed-host name`(v0.13.0 이후, 여러 번 지정 가능)이 +프록시의 공개 이름을 허용되는 `Host`와 `Origin`에 추가합니다. 검사는 켜진 채로 +유지되고 서버는 루프백에 그대로 머뭅니다. +[셀프 호스팅]({{< relref "self-hosting" >}})은 이를 Caddy 뒤에 두고 +HTTPS와 로그인을 함께 구성합니다. + ## 페이지 메타데이터 {#page-metadata} marp-cli와 마찬가지로 명령줄이 프레젠테이션보다 우선합니다. marp-cli에서는 diff --git a/content/ko/playground.md b/content/ko/playground.md index 9fce165..0d5be03 100644 --- a/content/ko/playground.md +++ b/content/ko/playground.md @@ -157,6 +157,9 @@ Marp 허용 목록을 따릅니다. 5초 넘게 걸리는 렌더링(무한 반 URL은 그 안에서 로드되지 않으며, 프레임 정책에는 미디어와 글꼴용 `blob:`만 추가됩니다. +Playground는 로그인 뒤에서 자신의 컴퓨터로 서비스할 수 있습니다. +[셀프 호스팅]({{< relref "self-hosting" >}})을 참고하세요. + ## 크기와 속도 {#size-and-speed} 모듈은 GitHub Pages가 제공하는 상태(gzip, Pages는 brotli를 제공하지 않음)로 diff --git a/content/ko/self-hosting.md b/content/ko/self-hosting.md new file mode 100644 index 0000000..8602b55 --- /dev/null +++ b/content/ko/self-hosting.md @@ -0,0 +1,277 @@ +--- +title: "셀프 호스팅" +weight: 58 +description: "Caddy로 Playground와 slides --server를 자신의 컴퓨터에서 서비스합니다. 자동 HTTPS, Playground에 필요한 헤더, 앞단의 OpenID Connect 로그인까지 다룹니다." +tags: [playground, server, security] +--- + +[Playground]({{< relref "playground" >}})는 정적 파일이고, +[`slides --server`]({{< relref "cli" >}})는 루프백에서 동작하는 하나의 프로세스입니다. 둘 다 +자신의 컴퓨터에서, 자신의 이름으로 실행해 조직의 계정으로 로그인하는 사람들에게 +제공할 수 있습니다. 이 페이지에서는 HTTPS 인증서를 직접 받고 갱신하는 +[Caddy](https://caddyserver.com/docs/)(v2.11.7로 테스트)와, 로그인을 맡는 +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/)를 사용합니다. +여기에 나오는 모든 파일은 +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost)에 +있으며, 그곳의 CI가 각 Caddyfile을 `caddy validate`로 검사합니다. + +## Playground {#the-playground} + +사이트의 각 릴리스에는 Playground를 포함한 사이트 전체가 +도메인 루트용으로 빌드된 tarball 하나로, SHA-256과 함께 첨부됩니다. + +```sh +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-site +``` + +이 tarball은 재현 가능합니다. 같은 태그를 다시 빌드하면 같은 +바이트가 나옵니다. 압축되는 모든 파일(WebAssembly 모듈, 글꼴, 페이지, 스크립트) 옆에는 +같은 파일을 Brotli(`.br`)와 gzip(`.gz`)으로 압축한 것도 들어 있어서, 서버는 이를 +그대로 보내고 즉석에서는 아무것도 압축하지 않습니다. + +### Caddyfile {#caddyfile} + +`slides.example.org`는 여러분의 이름입니다. 이 이름의 A 레코드와 AAAA 레코드가 해당 +컴퓨터를 가리키고, 80번과 443번 포트가 Caddy에 도달해야 합니다. + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +그러면 Playground는 `https://slides.example.org/playground/`에서 열립니다. + +### 서버가 올바르게 해야 하는 일 {#what-the-server-must-get-right} + +- **HTTPS.** Caddy는 ACME([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555))로 + Let's Encrypt에서 인증서를 받습니다. 80번 포트(HTTP-01) 또는 443번 포트(TLS-ALPN-01)에서 + 응답해 이름을 가지고 있음을 증명하고, 인증서를 갱신합니다. CA가 접근할 수 없는 컴퓨터는 대신 + DNS-01 챌린지(TXT 레코드이며, DNS 제공업체의 모듈이 들어간 Caddy가 필요합니다)나 + `tls cert.pem key.pem`으로 지정한 자체 인증서를 쓸 수 있습니다. + 테스트용 컴퓨터에서는 `tls internal`이 Caddy의 로컬 CA를 사용합니다. +- **`application/wasm`.** Caddy는 `.wasm`에 이 타입을 보냅니다. 다른 서버는 + 따로 지정해야 할 수 있습니다. Playground는 읽어 들인 바이트에서 모듈을 컴파일하므로 + (내보내기 엔진은 내려받는 동안, 이 타입의 별도 응답으로), 다른 타입을 보내는 서버에서도 + 시작되지만, `nosniff`가 있더라도 올바른 타입을 보내는 것이 맞습니다. +- **압축.** `precompressed br gzip`은 Brotli를 받는 브라우저에는 `playground.wasm.br`을, + gzip을 받는 브라우저에는 `.gz`를, 둘 다 아니면 파일 자체를 `Vary: Accept-Encoding`과 + 함께 보냅니다. 편집기 모듈은 Brotli로 약 7 MiB이고, 압축하지 않으면 + 33 MiB입니다. +- **캐싱.** 페이지는 모든 모듈, 워커, 글꼴, 그림을 쿼리에 릴리스를 붙여 + (`playground.wasm?v=v0.1.0`) 요청하므로, 이들은 1년 동안 + 보관할 수 있습니다(`immutable`). 페이지 자체는 매번 재검증됩니다 + (`no-cache`). 새 릴리스는 다음 방문 때 보입니다. +- **`headers.caddy`의 헤더:** + +```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" +} +``` + +이 정책은 Playground가 필요로 하는 그대로입니다. 스크립트(로더는 +인라인), WebAssembly(`'wasm-unsafe-eval'`), 워커가 모두 +여러분의 서버에서 옵니다. 덱의 그림, 스타일시트, 글꼴은 명령의 출력과 마찬가지로 +어떤 `https:` 주소에서든 올 수 있습니다. 모든 요청을 여러분의 서버에 두려면 +`img-src`, `style-src`, `font-src`, `media-src`, `connect-src`에서 `https:`를 빼십시오. +그러면 다른 곳을 가리키는 덱은 그 리소스 없이 표시됩니다. 덱 자체는 여러분의 출처가 없는 +샌드박스 iframe 안에서, Playground가 각 프레임에 써 넣는 더 엄격한 자체 정책 아래 실행됩니다. +이 헤더가 무엇이라 하든, 덱은 Playground, 그 저장소, 여러분의 쿠키에 접근할 수 +없습니다. `frame-ancestors 'self'`는 다른 사이트가 Playground를 프레임에 넣지 +못하게 합니다. + +- **COOP도 COEP도 없음.** Playground는 `SharedArrayBuffer`도 스레드도 쓰지 않으므로 + 교차 출처 격리가 필요하지 않습니다. + `Cross-Origin-Embedder-Policy: require-corp`를 보내면 덱이 다른 사이트에서 가져오는 그림이 + 차단됩니다. + +### 네트워크 없이 {#without-the-network} + +페이지를 불러온 뒤에는 편집기, 미리보기, 발표자 보기, 그리고 PDF·PPTX·ODP 내보내기 +(글꼴은 tarball에 들어 있습니다)까지 모든 것이 브라우저 안에서 일어납니다. 덱은 여러분의 +서버로도, 다른 어디로도 전송되지 않습니다. 네트워크가 필요한 것은 덱이 직접 가리키는 것들, 즉 +`https:` 주소의 그림이나 스타일시트와, `gaia` 테마의 웹 글꼴입니다. 이 글꼴은 +`fonts.bunny.net`에 요청합니다(없으면 슬라이드는 다른 글꼴로 대체됩니다). +이 문서로 향하는 링크는 여러분의 서버를 벗어납니다. + +## 프록시 뒤의 `slides --server` {#slides---server-behind-the-proxy} + +`slides --server`는 `127.0.0.1`에 머물고 Caddy가 그쪽으로 전달합니다. 서버는 DNS 리바인딩과 +다른 사이트의 페이지를 막기 위해 각 요청의 `Host` 헤더와 각 라이브 리로드 +WebSocket의 `Origin`을 검사하며, 프록시는 둘 다에 공개 이름을 유지합니다. +`--allowed-host`는 그 이름을 검사가 받아들이는 대상에 추가합니다. 검사는 켜진 채로 있습니다. + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.example.org { + reverse_proxy 127.0.0.1:8080 +} +``` + +라이브 리로드는 그대로 통과합니다(Caddy는 설정 없이 WebSocket을 프록시합니다). +이를 위해 `--host 0.0.0.0`을 쓰지 마십시오. 디렉터리를 네트워크 전체에 제공하게 되어 +프록시와 로그인을 우회하게 됩니다. + +{{< callout type="info" >}} +`--allowed-host`는 v0.13.0 이후에 들어왔습니다. 다음 릴리스가 나올 때까지는 +이 옵션이 포함된 커밋을 설치하십시오. +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e`. +{{< /callout >}} + +## OpenID Connect로 로그인 {#sign-in-with-openid-connect} + +Caddy는 모든 요청에 대해 oauth2-proxy에 물어봅니다(`forward_auth`). oauth2-proxy는 +로그인한 브라우저에는 `202`로 답하고 Caddy가 그 요청을 처리하며, +그렇지 않으면 `401`로 답하고 Caddy가 이를 로그인으로의 리디렉션으로 바꿉니다. +로그인은 +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)의 +인증 코드 흐름입니다. 브라우저는 제공자(Keycloak, Authentik, Entra ID, Google, +Okta, Dex 등)에게 갔다가 코드를 가지고 `/oauth2/callback`으로 돌아오고, oauth2-proxy가 +그 코드를 ID Token으로 교환해 검증한 뒤 자체 쿠키를 설정합니다. + +1. 제공자에 **클라이언트를 등록**합니다. 기밀 클라이언트이며 인증 코드 흐름을 사용합니다. + 리디렉션 URI는 정확히 일치해야 합니다. + `https://slides.example.org/oauth2/callback`과 + `https://decks.example.org/oauth2/callback`입니다. +2. **비밀 값은 파일에 두고** oauth2-proxy만 읽을 수 있게 합니다. 클라이언트의 + 비밀 값(끝에 줄바꿈 없음)과, 정확히 32바이트의 무작위 쿠키 + 비밀 값(`head -c 32 /dev/urandom`)입니다. 비밀 값 저장소(systemd credentials, + Docker나 Kubernetes secret, Vault 등)가 이를 기록합니다. + 둘 다 설정 파일, 명령줄, 환경 변수에는 넣지 마십시오. 로그와 `ps`에 노출됩니다. +3. **oauth2-proxy를 설정**합니다(`oauth2-proxy.cfg`). + +```toml +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`은 각 사이트의 로그인을 그 사이트 자신의 콜백으로 되돌려 보내므로, +각 사이트가 자기 쿠키를 가집니다. `whitelist_domains`는 로그인 후 돌아갈 수 있는 곳입니다. +다른 곳으로 가라고 요청받아도 여러분의 사이트에 머뭅니다. +`trusted_proxy_ips`는 Caddy가 보낸 `X-Forwarded-*` 헤더만 믿습니다. +`email_domains`는 누가 들어올 수 있는지를 정합니다. oauth2-proxy에는 그룹과 +주소 목록을 위한 옵션도 있습니다. + +4. **Caddy를 설정**합니다(`Caddyfile.oidc`). + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-proxy와 `slides`는 루프백에서만 수신합니다. 들어오는 길은 Caddy뿐이며, +따라서 로그인을 반드시 거칩니다. Playground의 헤더는 로그인된 블록 안쪽에, +Playground에만 둡니다. oauth2-proxy의 로그인 페이지는 폼인데, +이 헤더의 `form-action 'none'`이 그것을 막기 때문입니다. + +대안은 [caddy-security](https://github.com/greenpau/caddy-security)입니다. +Caddy 안에서 로그인을 처리하는 Caddy 모듈로, 두 번째 프로세스가 필요 없습니다. +다만 이 모듈을 넣어 빌드한 Caddy(`xcaddy`)와 자체 설정 언어가 필요합니다. + +## 확인하기 {#check-it} + +```sh +curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0' +``` + +응답에는 `content-type: application/wasm`, `content-encoding: br`, +`cache-control: public, max-age=31536000, immutable`이 나오며, 위에서 본 +`content-security-policy`도 함께 옵니다. 앞단에 로그인이 있으면, 같은 +요청을 쿠키 없이 보낼 때 `302`로 `/oauth2/sign_in`에 리디렉션됩니다. + +## 참고 자료 {#references} + +- Caddy: [automatic HTTPS](https://caddyserver.com/docs/automatic-https), + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server), + [`header`](https://caddyserver.com/docs/caddyfile/directives/header), + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth) +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555), ACME: HTTP-01(8.3), + DNS-01(8.4) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): + 인증 코드 흐름(3.1), 리디렉션 URI(3.1.2.1), ID + Token(2) +- oauth2-proxy: [configuration](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview), + [OpenID Connect provider](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect) diff --git a/content/zh/cli.md b/content/zh/cli.md index 28c4baa..c7da8c0 100644 --- a/content/zh/cli.md +++ b/content/zh/cli.md @@ -42,6 +42,7 @@ deck.md` 等价。长参数使用两个短横线(`--html`);`-o` 是唯一 | `-s`, `--server dir` | `build` | 托管一个目录,按请求转换其中的演示文稿(v0.6.0 起);见下文 | | `-p`, `--preview` | `build` | 在默认浏览器中打开结果(v0.6.0 起):配合 `--watch`(隐含启用)打开 HTML 页面,配合 `--server` 打开服务器 | | `--host addr` | `build` | `--server` 监听的地址,默认 `127.0.0.1`(v0.6.0 起) | +| `--allowed-host name` | `build` | `--server` 应答的反向代理公开主机名,可重复使用(v0.13.0 之后);参见 [自托管]({{< relref "self-hosting" >}}) | | `--template bespoke\|bare` | `build` | HTML 页面的模板(v0.6.0 起):默认的 `bespoke` 带有演示脚本、屏幕控制器和过渡效果;`bare` 则将幻灯片依次堆叠(`--bare`) | | `--bespoke.osc`, `--bespoke.progress`, `--bespoke.transition` | `build` | bespoke 模板的选项,默认值与 marp-cli 相同(v0.6.0 起):控制器开启,**进度条关闭**(以前总是绘制),过渡效果开启;`--bespoke.osc=false` 可关闭其中一项 | | `-c`, `--config-file file`, `--no-config-file` | `build` | marp-cli 的配置文件,JSON 或 YAML 格式(v0.6.0 起);见下文。`--config` 是 `slides` 自己的配置文件 | @@ -204,6 +205,10 @@ marp-cli 从工作目录中的文件读取选项,`slides` 也一样,前提 - 只接受 `GET` 和 `HEAD`;同时进行的转换数量有限;最多监视 4096 个目录, 且不监视 `node_modules` 中或以点开头的目录。 +在反向代理之后,`--allowed-host name`(v0.13.0 之后,可重复使用)会把代理的公开名称 +加入可接受的 `Host` 和 `Origin`;检查保持开启,服务器仍停留在回环地址上。 +[自托管]({{< relref "self-hosting" >}})把它放在 Caddy 之后,并配有 HTTPS 和登录。 + ## 页面元数据 {#page-metadata} 与 marp-cli 一样,命令行优先于演示文稿;在 marp-cli 中,这些参数会作为最后一条指令注释追加到演示文稿末尾。 diff --git a/content/zh/playground.md b/content/zh/playground.md index 3bfa51c..0f1e43e 100644 --- a/content/zh/playground.md +++ b/content/zh/playground.md @@ -136,6 +136,9 @@ iframe 无法共享存储,因此由 Playground 通过消息在它们之间转 每个框架在自己的来源中为其创建各自的 `blob:` URL。由 Playground 创建的 `blob:` URL 在框架中无法加载,而框架的策略仅为媒体和字体额外放开了 `blob:`。 +Playground 可以从你自己的机器上提供服务,并置于登录之后:参见 +[自托管]({{< relref "self-hosting" >}})。 + ## 体积与速度 {#size-and-speed} 按 GitHub Pages 提供的形式(gzip;Pages 不提供 diff --git a/content/zh/self-hosting.md b/content/zh/self-hosting.md new file mode 100644 index 0000000..55b2465 --- /dev/null +++ b/content/zh/self-hosting.md @@ -0,0 +1,279 @@ +--- +title: "自托管" +weight: 58 +description: "用 Caddy 在你自己的机器上提供 Playground 和 slides --server:自动 HTTPS、Playground 所需的响应头,以及前置的 OpenID Connect 登录。" +tags: [playground, server, security] +--- + +[Playground]({{< relref "playground" >}}) 是一组静态文件, +[`slides --server`]({{< relref "cli" >}}) 是运行在回环地址上的单个进程。两者都 +可以运行在你自己的机器上,使用你自己的域名,供用你所在组织的账号登录的人使用。 +本页用 [Caddy](https://caddyserver.com/docs/)(已用 v2.11.7 测试)来完成,它会自行获取并 +续期 HTTPS 证书;登录则由 +[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) 负责。这里展示的每个文件都在 +[`selfhost/`](https://github.com/go-slides/go-slides.github.io/tree/main/selfhost) +中,其 CI 会用 `caddy validate` 检查每个 Caddyfile。 + +## Playground {#the-playground} + +站点的每个发布版本都会把整个站点(包括 Playground)打包成 +一个为域名根路径构建的 tarball,并附上它的 SHA-256: + +```sh +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-site +``` + +这个 tarball 是可复现的:再次构建同一个标签会得到相同的 +字节。每个可压缩的文件(WebAssembly 模块、字体、页面、脚本)旁边, +都带有用 Brotli(`.br`)和 gzip(`.gz`)压缩的同一文件,所以服务器会原样发送它们, +而不必即时压缩任何内容。 + +### Caddyfile {#caddyfile} + +`slides.example.org` 是你的域名。它的 A 记录和 AAAA 记录指向这台 +机器,80 和 443 端口可以到达 Caddy: + +```caddy +import headers.caddy + +slides.example.org { + root * /srv/go-slides-site + file_server { + precompressed br gzip + } + import playground_headers +} +``` + +```sh +caddy run --config Caddyfile.playground +``` + +之后 Playground 就在 `https://slides.example.org/playground/`。 + +### 服务器必须做对的事 {#what-the-server-must-get-right} + +- **HTTPS。** Caddy 通过 ACME([RFC 8555](https://www.rfc-editor.org/rfc/rfc8555)) + 从 Let's Encrypt 获取证书,通过在 80 端口(HTTP-01)或 443 端口(TLS-ALPN-01)上应答 + 来证明自己拥有该域名,并负责续期。CA 无法访问的机器可以改用 + DNS-01 质询(一条 TXT 记录,需要内置了你的 DNS 提供商模块的 Caddy), + 或者用 `tls cert.pem key.pem` 使用自己的证书。 + 在测试机器上,`tls internal` 使用 Caddy 的本地 CA。 +- **`application/wasm`。** Caddy 会为 `.wasm` 发送这个类型;换成别的服务器时 + 可能需要手动指定。Playground 是根据读到的字节来编译模块的 + (导出引擎边下载边编译,用的是一个带此类型的独立响应),所以即使服务器发送了别的类型它也能启动; + 不过有 `nosniff` 时,发送正确的类型仍然是应有的做法。 +- **压缩。** `precompressed br gzip` 会向支持 Brotli 的浏览器发送 `playground.wasm.br`, + 向支持 gzip 的浏览器发送 `.gz`,否则发送文件本身,并带上 + `Vary: Accept-Encoding`。编辑器的模块用 Brotli 约 7 MiB, + 未压缩为 33 MiB。 +- **缓存。** 页面在查询中带上发布版本来请求每个模块、worker、字体和图片 + (`playground.wasm?v=v0.1.0`),所以这些文件可以 + 缓存一年(`immutable`)。页面本身每次都会重新验证 + (`no-cache`):新版本在下次访问时就能看到。 +- **`headers.caddy` 中的响应头:** + +```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" +} +``` + +这条策略正是 Playground 所需要的:它的脚本(加载器是 +内联的)、它的 WebAssembly(`'wasm-unsafe-eval'`)和它的 worker,全部来自 +你的服务器。演示文稿的图片、样式表和字体可以来自任何 +`https:` 地址,与命令的输出一致;如果想让所有请求都留在你的服务器上, +请从 `img-src`、`style-src`、`font-src`、`media-src` 和 `connect-src` 中去掉 `https:`, +这样指向别处的演示文稿将不显示 +这些资源。演示文稿本身运行在不带你的来源的沙盒 iframe 中, +受 Playground 写入每个框架的、更严格的专属策略约束: +无论这个响应头怎么写,它都无法访问 Playground、它的存储或你的 +Cookie。`frame-ancestors 'self'` 可防止其他网站 +把 Playground 嵌入框架。 + +- **没有 COOP 和 COEP。** Playground 既不使用 `SharedArrayBuffer`,也不使用 + 线程,所以不需要跨源隔离。发送 + `Cross-Origin-Embedder-Policy: require-corp` 会阻止演示文稿加载其他网站的 + 图片。 + +### 没有网络时 {#without-the-network} + +页面加载完成后,一切都在浏览器中进行:编辑器、 +预览、演讲者视图,以及 PDF、PPTX 和 ODP 导出(所需 +字体都在 tarball 中)。演示文稿既不会发送到你的服务器, +也不会发送到别处。需要网络的是演示文稿自己引用的内容:`https:` 地址上的图片或 +样式表,以及 `gaia` 主题的网络字体,它 +向 `fonts.bunny.net` 请求这个字体(没有它,幻灯片会退回到其他 +字体)。指向本文档的链接会离开你的服务器。 + +## 代理之后的 `slides --server` {#slides---server-behind-the-proxy} + +`slides --server` 保持在 `127.0.0.1` 上,由 Caddy 转发给它。服务器会检查每个请求的 `Host` 头, +以及每个实时重载 WebSocket 的 `Origin`,以防范 DNS 重绑定和其他网站的页面,而代理在两者中 +都保留公开域名。`--allowed-host` 把这个域名加入 +检查所接受的范围;检查仍然开启: + +```sh +slides -s --allowed-host decks.example.org /srv/decks +``` + +```caddy +decks.example.org { + reverse_proxy 127.0.0.1:8080 +} +``` + +实时重载会原样通过(Caddy 无需配置就能代理 WebSocket)。 +不要为此使用 `--host 0.0.0.0`:它会把目录提供给整个网络, +绕过代理及其登录。 + +{{< callout type="info" >}} +`--allowed-host` 出现在 v0.13.0 之后。在下一个版本发布之前,请安装 +包含它的提交: +`go install github.com/go-slides/slides/cmd/slides@v0.13.1-0.20261026090714-2e1da79f663e`。 +{{< /callout >}} + +## 使用 OpenID Connect 登录 {#sign-in-with-openid-connect} + +Caddy 会就每个请求询问 oauth2-proxy(`forward_auth`)。对已登录的浏览器,oauth2-proxy +回答 `202`,Caddy 随后提供页面; +否则回答 `401`,Caddy 把它变成跳转到登录页的重定向。 +登录采用 +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +的授权码流程:浏览器前往你的提供商(Keycloak、Authentik、Entra ID、Google、 +Okta、Dex 等),带着授权码回到 `/oauth2/callback`,oauth2-proxy +把授权码换成 ID Token,对其进行校验,并设置自己的 Cookie。 + +1. **在提供商处注册一个客户端**,机密客户端,使用授权码流程。 + 它的重定向 URI 必须完全匹配: + `https://slides.example.org/oauth2/callback` 和 + `https://decks.example.org/oauth2/callback`。 +2. **把密钥放进文件**,且只有 oauth2-proxy 可读:客户端的 + 密钥(末尾不带换行),以及恰好 32 个随机字节的 Cookie 密钥 + (`head -c 32 /dev/urandom`)。由你的密钥存储来写入它们 + (systemd 凭据、Docker 或 Kubernetes 的 secret、Vault 等); + 两者都不要放进配置文件、命令行或环境变量,日志和 `ps` 会把它们显示出来。 +3. **配置 oauth2-proxy**(`oauth2-proxy.cfg`): + +```toml +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` 会让每个站点的登录回到它自己的 +回调,因此每个站点都有自己的 Cookie。`whitelist_domains` 是登录后允许返回的地方: +如果要求跳转到其他任何地方,登录会留在你的站点上。 +`trusted_proxy_ips` 只信任来自 Caddy 的 `X-Forwarded-*` 头。 +`email_domains` 规定谁可以进入;oauth2-proxy 还提供针对 +群组和地址列表的选项。 + +4. **配置 Caddy**(`Caddyfile.oidc`): + +```caddy +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 + } +} +``` + +```sh +slides -s --allowed-host decks.example.org /srv/decks +oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg +caddy run --config Caddyfile.oidc +``` + +oauth2-proxy 和 `slides` 只监听回环地址:唯一的入口是 +Caddy,因而必须经过登录。Playground 的响应头放在 +已登录块内部,且只用于 Playground:oauth2-proxy 的登录 +页面是一个表单,而这些响应头中的 `form-action 'none'` 会阻止它。 + +另一种方案是 [caddy-security](https://github.com/greenpau/caddy-security), +这是一个在 Caddy 内部完成登录的 Caddy 模块,不需要第二个进程。 +它需要内置了该模块的 Caddy(`xcaddy`),以及它自己的 +配置语言。 + +## 检查 {#check-it} + +```sh +curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0' +``` + +响应会显示 `content-type: application/wasm`、`content-encoding: br` +和 `cache-control: public, max-age=31536000, immutable`,并带有上面的 +`content-security-policy`。前面有登录时,同样的 +请求在不带 Cookie 的情况下会返回 `302`,跳转到 `/oauth2/sign_in`。 + +## 参考资料 {#references} + +- Caddy:[automatic HTTPS](https://caddyserver.com/docs/automatic-https)、 + [`file_server`](https://caddyserver.com/docs/caddyfile/directives/file_server)、 + [`header`](https://caddyserver.com/docs/caddyfile/directives/header)、 + [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)、 + [`forward_auth`](https://caddyserver.com/docs/caddyfile/directives/forward_auth)。 +- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555),ACME:HTTP-01(8.3)、 + DNS-01(8.4)。 +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): + 授权码流程(3.1)、重定向 URI(3.1.2.1)、ID + Token(2)。 +- oauth2-proxy:[configuration](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview)、 + [OpenID Connect provider](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect)。