Vai al contenuto

Self-hosting

Servire il Playground e slides --server dalla propria macchina con Caddy: HTTPS automatico, gli header necessari al Playground e un accesso OpenID Connect davanti.

Il Playground è fatto di file statici, e slides --server è un solo processo sul loopback. Entrambi possono girare sulla tua macchina, con il tuo nome, per persone che accedono con l’account della tua organizzazione. Questa pagina lo fa con Caddy (provato con la v2.11.7), che ottiene e rinnova da sé i certificati HTTPS, e con oauth2-proxy per l’accesso. Ogni file mostrato qui si trova in selfhost/, e la sua CI controlla ogni Caddyfile con caddy validate.

Il Playground

Ogni release del sito allega l’intero sito, Playground compreso, come un unico tarball costruito per la radice di un dominio, con il relativo SHA-256:

curl -fLO https://github.com/go-slides/go-slides.github.io/releases/download/v0.1.0/go-slides-site-v0.1.0.tar.gz
curl -fLO https://github.com/go-slides/go-slides.github.io/releases/download/v0.1.0/go-slides-site-v0.1.0.tar.gz.sha256
sha256sum -c go-slides-site-v0.1.0.tar.gz.sha256
tar -xzf go-slides-site-v0.1.0.tar.gz -C /srv
mv /srv/go-slides-site-v0.1.0 /srv/go-slides-site

Il tarball è riproducibile: ricostruire lo stesso tag dà gli stessi byte. Accanto a ogni file comprimibile (i moduli WebAssembly, i font, le pagine, gli script) contiene lo stesso file compresso con Brotli (.br) e gzip (.gz), così il server li invia così come sono e non comprime nulla al volo.

Caddyfile

slides.example.org è il tuo nome. I suoi record A e AAAA puntano alla macchina, e le porte 80 e 443 raggiungono Caddy:

import headers.caddy

slides.example.org {
	root * /srv/go-slides-site
	file_server {
		precompressed br gzip
	}
	import playground_headers
}
caddy run --config Caddyfile.playground

Il Playground si trova allora in https://slides.example.org/playground/.

Cosa deve fare bene il server

  • HTTPS. Caddy ottiene il certificato da Let’s Encrypt con ACME (RFC 8555), dimostrando di possedere il nome rispondendo sulla porta 80 (HTTP-01) o 443 (TLS-ALPN-01), e lo rinnova. Una macchina che la CA non può raggiungere può usare invece la sfida DNS-01 (un record TXT, che richiede un Caddy compilato con il modulo del tuo provider DNS), oppure un certificato proprio con tls cert.pem key.pem. Su una macchina di prova, tls internal usa la CA locale di Caddy.
  • application/wasm. Caddy invia questo tipo per .wasm; un altro server potrebbe doverne essere informato. Il Playground compila i propri moduli dai byte che ha letto (il motore di esportazione mentre scarica, in una risposta a sé con quel tipo), quindi parte anche da un server che invia un altro tipo; con nosniff, il tipo giusto resta comunque quello da inviare.
  • Compressione. precompressed br gzip invia playground.wasm.br a un browser che accetta Brotli, .gz a uno che accetta gzip, e il file stesso negli altri casi, con Vary: Accept-Encoding. Il modulo dell’editor è di circa 7 MiB in Brotli, contro 33 MiB non compresso.
  • Cache. La pagina richiede ogni modulo, worker, font e immagine con la release nella query (playground.wasm?v=v0.1.0), quindi questi possono essere conservati per un anno (immutable). Le pagine stesse vengono rivalidate (no-cache): una nuova release si vede alla visita successiva.
  • Gli header in headers.caddy:
(playground_headers) {
	header {
		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self' https:; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: blob: https:; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
		X-Content-Type-Options nosniff
		Referrer-Policy no-referrer
		Strict-Transport-Security "max-age=31536000"
	}
	@versioned query v=*
	header @versioned Cache-Control "public, max-age=31536000, immutable"
	@unversioned not query v=*
	header @unversioned Cache-Control "no-cache"
}

La policy è quella di cui il Playground ha bisogno: i suoi script (il loader è in linea), il suo WebAssembly ('wasm-unsafe-eval') e il suo worker, tutti dal tuo server. Immagini, fogli di stile e font di una presentazione possono provenire da qualsiasi indirizzo https:, come nell’output del comando; togli https: da img-src, style-src, font-src, media-src e connect-src per mantenere ogni richiesta sul tuo server, e le presentazioni che puntano altrove vengono mostrate senza quelle risorse. La presentazione stessa gira in iframe in sandbox senza la tua origine, sotto una policy più rigida di loro proprietà che il Playground scrive in ogni frame: non può raggiungere il Playground, la sua memoria né i tuoi cookie, qualunque cosa dica questo header. frame-ancestors 'self' impedisce ad altri siti di incorporare il Playground in un frame.

  • Né COOP né COEP. Il Playground non usa né SharedArrayBuffer né thread, quindi non ha bisogno di essere isolato cross-origin. Inviare Cross-Origin-Embedder-Policy: require-corp bloccherebbe le immagini di una presentazione provenienti da altri siti.

Senza rete

Una volta caricata la pagina, tutto avviene nel browser: l’editor, l’anteprima, la vista del relatore, e le esportazioni PDF, PPTX e ODP, i cui font sono nel tarball. La presentazione non è inviata né al tuo server né altrove. Ciò che richiede la rete è ciò che la presentazione nomina: immagini o fogli di stile a un indirizzo https:, e il web font del tema gaia, che chiede a fonts.bunny.net (senza di esso, le slide ripiegano su un altro font). I link a questa documentazione escono dal tuo server.

slides --server dietro il proxy

slides --server resta su 127.0.0.1 e Caddy inoltra verso di esso. Il server controlla l’header Host di ogni richiesta e l’Origin del WebSocket di ogni ricaricamento in tempo reale, contro il DNS rebinding e le pagine di altri siti, e il proxy mantiene in entrambi il nome pubblico. --allowed-host aggiunge quel nome a ciò che i controlli accettano; i controlli restano attivi:

slides -s --allowed-host decks.example.org /srv/decks
decks.example.org {
	reverse_proxy 127.0.0.1:8080
}

Il ricaricamento in tempo reale passa così com’è (Caddy fa da proxy ai WebSocket senza configurazione). Non usare --host 0.0.0.0 per questo: servirebbe la cartella all’intera rete, aggirando il proxy e il suo accesso.

--allowed-host è nella v0.14.0 e successive: go install github.com/go-slides/slides/cmd/slides@v0.17.1. Richiede Go 1.27.3 o successivo.

Accesso con OpenID Connect

Caddy interroga oauth2-proxy per ogni richiesta (forward_auth). oauth2-proxy risponde 202 a un browser che ha effettuato l’accesso, e Caddy allora serve la richiesta, e 401 altrimenti, che Caddy trasforma in un reindirizzamento all’accesso. L' accesso è l’authorization code flow di OpenID Connect Core 1.0: il browser va dal tuo provider (Keycloak, Authentik, Entra ID, Google, Okta, Dex…), torna a /oauth2/callback con un codice, oauth2-proxy scambia il codice con un ID Token, lo verifica e imposta un proprio cookie.

  1. Registra un client presso il tuo provider, confidenziale, con l' authorization code flow. I suoi redirect URI, che devono corrispondere esattamente: https://slides.example.org/oauth2/callback e https://decks.example.org/oauth2/callback.
  2. Metti i segreti in file leggibili solo da oauth2-proxy: il segreto del client, senza a capo finale, e un segreto per il cookie di esattamente 32 byte casuali (head -c 32 /dev/urandom). Li scrive il tuo gestore di segreti (credenziali systemd, segreti Docker o Kubernetes, Vault…); nessuno dei due va in un file di configurazione, in una riga di comando o in una variabile d’ambiente, dove log e ps li mostrano.
  3. Configura oauth2-proxy (oauth2-proxy.cfg):
http_address = "127.0.0.1:4180"
reverse_proxy = true
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
upstreams = ["static://202"]
set_xauthrequest = true

provider = "oidc"
oidc_issuer_url = "https://id.example.org/realms/example"
client_id = "go-slides"
client_secret_file = "/run/secrets/oauth2-proxy-client-secret"
code_challenge_method = "S256"

redirect_url = "/oauth2/callback"
whitelist_domains = ["slides.example.org", "decks.example.org"]
email_domains = ["example.org"]

cookie_secret_file = "/run/secrets/oauth2-proxy-cookie-secret"
cookie_secure = true
cookie_samesite = "lax"

redirect_url senza host riporta l’accesso di ciascun sito al proprio callback, così ogni sito mantiene il proprio cookie. whitelist_domains è dove un accesso può tornare: se gli si chiede di andare altrove, resta sul tuo sito. trusted_proxy_ips crede agli header X-Forwarded-* solo da Caddy. email_domains dice chi può entrare; oauth2-proxy ha anche opzioni per gruppi ed elenchi di indirizzi.

  1. Configura Caddy (Caddyfile.oidc):
import headers.caddy

(signed_in) {
	handle /oauth2/* {
		reverse_proxy 127.0.0.1:4180
	}
	handle {
		forward_auth 127.0.0.1:4180 {
			uri /oauth2/auth
			copy_headers X-Auth-Request-User X-Auth-Request-Email
			@unauthorized status 401
			handle_response @unauthorized {
				redir * /oauth2/sign_in?rd={scheme}://{hostport}{uri} 302
			}
		}
		{block}
	}
}

slides.example.org {
	import signed_in {
		root * /srv/go-slides-site
		file_server {
			precompressed br gzip
		}
		import playground_headers
	}
}

decks.example.org {
	import signed_in {
		reverse_proxy 127.0.0.1:8080
	}
}
slides -s --allowed-host decks.example.org /srv/decks
oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
caddy run --config Caddyfile.oidc

oauth2-proxy e slides ascoltano solo sul loopback: l’unico ingresso passa da Caddy, e quindi dall’accesso. Gli header del Playground vanno dentro il blocco con accesso, solo sul Playground: la pagina di accesso di oauth2-proxy è un modulo, che il loro form-action 'none' bloccherebbe.

L’alternativa è caddy-security, un modulo di Caddy che gestisce l’accesso dentro Caddy, senza un secondo processo. Richiede un Caddy compilato con il modulo (xcaddy) e un proprio linguaggio di configurazione.

slides-gitd: le credenziali restano sul server

slides-gitd (v0.1.0) è il backend git di un Playground self-hosted. Carica la cartella di una presentazione da una forge, propone le modifiche in un nuovo branch con una pull (merge) request e cerca le modifiche a monte, mentre le credenziali della forge restano sul server: il browser ha solo un cookie di sessione HttpOnly. Sta dietro Caddy e oauth2-proxy (OIDC), ascolta su 127.0.0.1:8091, prende l’identità dell’utente solo dal proxy e scrive solo in branch chiamati slides/<utente>/<titolo>-<ora> che il server nomina da sé.

Non ancora disponibile: la modalità server del Playground, che parla con slides-gitd invece di far tenere un token al browser, è la prossima versione. Oggi slides-gitd funziona e si può verificare, ma il Playground non lo usa.

Configurazione (i segreti sono percorsi di file, mai valori; ogni file deve essere leggibile solo dal proprietario):

{
  "listen": "127.0.0.1:8091",
  "origin": "https://slides.example.org",
  "proxy_secret_file": "/etc/slides-gitd/proxy.secret",
  "master_key_file": "/etc/slides-gitd/master.key",
  "data_dir": "/var/lib/slides-gitd",
  "audit_file": "/var/log/slides-gitd/audit.log",
  "forges": {
    "github": {"kind": "github", "api": "https://api.github.com", "auth": "github-app",
               "app_client_id": "Iv1.…", "installation_id": 12345678,
               "app_key_file": "/etc/slides-gitd/github-app.pem"},
    "gitlab": {"kind": "gitlab", "api": "https://gitlab.example.org/api/v4", "auth": "user-token"}
  },
  "users": {
    "3f2b…-oidc-sub": {"slug": "ada", "repos": ["github:lab/talks", "gitlab:team/slides"]}
  }
}

Un utente raggiunge solo le voci forge:proprietario/nome elencate per il suo soggetto OIDC (negato per impostazione predefinita). Create la chiave master con head -c 32 /dev/urandom > master.key && chmod 600 master.key, poi eseguite slides-gitd check -c config.json, che carica tutti i file di segreti ed esce, e slides-gitd serve -c config.json.

Il lato Caddy, con oauth2-proxy avviato con --set-xauthrequest:

slides.example.org {
    handle /oauth2/* {
        reverse_proxy 127.0.0.1:4180
    }
    handle /api/git/* {
        # never let a client choose its identity, whatever Caddy's version does
        request_header -X-Auth-Request-User
        request_header -X-Auth-Request-Email
        request_header -X-Gitd-Proxy-Secret
        forward_auth 127.0.0.1:4180 {
            uri /oauth2/auth
            copy_headers X-Auth-Request-User X-Auth-Request-Email
        }
        request_body { max_size 64MB }
        reverse_proxy 127.0.0.1:8091 {
            header_up X-Gitd-Proxy-Secret {file./etc/caddy/gitd-proxy.secret}
        }
    }
    handle { root * /srv/slides; file_server }
}

Le righe request_header -… eliminano ogni header di identità inviato dal client, che così non può scegliere chi è; il Caddy recente lo fa da sé prima di copy_headers, le versioni più vecchie no. {file.*} legge il segreto del proxy da un file, quindi non sta in una variabile d’ambiente.

GitHub usa una App. Createla in Settings ▸ Developer settings ▸ GitHub Apps con i permessi Contents in lettura e scrittura, Pull requests in lettura e scrittura e Metadata in lettura, senza webhook; installatela solo sui repository delle presentazioni. slides-gitd emette un token di installazione per l’unico repository di ogni chiamata, valido un’ora.

GitLab: ogni utente registra un token di accesso personale o di progetto con lo scope api. Perché api e non write_repository, e che cosa può fare un token rubato, è spiegato nella pagina del Playground; preferite un token di accesso di progetto o di gruppo limitato ai repository delle presentazioni, o un account dedicato.

Verificarlo

curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0'

La risposta riporta content-type: application/wasm, content-encoding: br e cache-control: public, max-age=31536000, immutable, con la content-security-policy vista sopra. Con l’accesso davanti, la stessa richiesta senza cookie risponde 302 verso /oauth2/sign_in.

Riferimenti

Created on by tannevaled

Modified on by tannevaled