Pular para o conteúdo

Auto-hospedagem

Sirva o Playground e o slides --server a partir da sua própria máquina com o Caddy: HTTPS automático, os cabeçalhos de que o Playground precisa e um login OpenID Connect na frente.

O Playground são arquivos estáticos, e o slides --server é um único processo no loopback. Ambos podem rodar na sua própria máquina, sob o seu próprio nome, para pessoas que entram com a conta da sua organização. Esta página faz isso com o Caddy (testado com a v2.11.7), que obtém e renova sozinho os certificados HTTPS, e com o oauth2-proxy para o login. Todos os arquivos mostrados aqui estão em selfhost/, e a CI dele verifica cada Caddyfile com caddy validate.

O Playground

Cada release do site anexa o site inteiro, Playground incluído, como um único tarball construído para a raiz de um domínio, com o seu 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

O tarball é reproduzível: construir a mesma tag de novo dá os mesmos bytes. Ao lado de cada arquivo que comprime (os módulos WebAssembly, as fontes, as páginas, os scripts) ele traz o mesmo arquivo comprimido com Brotli (.br) e gzip (.gz), de modo que o servidor os envia como estão e não comprime nada na hora.

Caddyfile

slides.example.org é o seu nome. Os registros A e AAAA dele apontam para a máquina, e as portas 80 e 443 chegam ao 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

O Playground fica então em https://slides.example.org/playground/.

O que o servidor precisa acertar

  • HTTPS. O Caddy obtém o certificado da Let’s Encrypt com ACME (RFC 8555), provando que possui o nome ao responder na porta 80 (HTTP-01) ou 443 (TLS-ALPN-01), e o renova. Uma máquina que a CA não consegue alcançar pode usar o desafio DNS-01 (um registro TXT, que exige um Caddy construído com o módulo do seu provedor de DNS), ou o seu próprio certificado com tls cert.pem key.pem. Em uma máquina de teste, tls internal usa a CA local do Caddy.
  • application/wasm. O Caddy envia este tipo para .wasm; outro servidor pode precisar ser instruído. O Playground compila os seus módulos a partir dos bytes que leu (o motor de exportação à medida que baixa, em uma resposta própria com esse tipo), então ele inicia mesmo a partir de um servidor que envia outro tipo; com nosniff, o tipo certo continua sendo o que se deve enviar.
  • Compressão. precompressed br gzip envia playground.wasm.br a um navegador que aceita Brotli, .gz a um que aceita gzip, e o próprio arquivo nos demais casos, com Vary: Accept-Encoding. O módulo do editor tem cerca de 7 MiB em Brotli, contra 33 MiB sem compressão.
  • Cache. A página pede cada módulo, worker, fonte e imagem com a release na query (playground.wasm?v=v0.1.0), de modo que eles podem ser mantidos por um ano (immutable). As próprias páginas são revalidadas (no-cache): uma nova release é vista na próxima visita.
  • Os cabeçalhos em 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"
}

A política é a de que o Playground precisa: os seus scripts (o loader é em linha), o seu WebAssembly ('wasm-unsafe-eval') e o seu worker, todos vindos do seu servidor. As imagens, folhas de estilo e fontes de uma apresentação podem vir de qualquer endereço https:, como na saída do comando; retire https: de img-src, style-src, font-src, media-src e connect-src para manter todas as requisições no seu servidor, e as apresentações que apontam para outro lugar são exibidas sem esses recursos. A própria apresentação roda em iframes em sandbox sem a sua origem, sob uma política própria mais estrita que o Playground escreve em cada frame: ela não alcança o Playground, o armazenamento dele nem os seus cookies, diga o que disser este cabeçalho. frame-ancestors 'self' impede que outros sites incorporem o Playground em frames.

  • Nem COOP nem COEP. O Playground não usa SharedArrayBuffer nem threads, então não precisa de isolamento cross-origin. Enviar Cross-Origin-Embedder-Policy: require-corp bloquearia as imagens de uma apresentação vindas de outros sites.

Sem a rede

Depois que a página é carregada, tudo acontece no navegador: o editor, a pré-visualização, a visão do apresentador, e as exportações para PDF, PPTX e ODP, cujas fontes estão no tarball. A apresentação não é enviada nem ao seu servidor nem a qualquer outro lugar. O que precisa da rede é o que a apresentação nomeia: imagens ou folhas de estilo em um endereço https:, e a fonte web do tema gaia, que ela pede ao fonts.bunny.net (sem ela, os slides recorrem a outra fonte). Os links para esta documentação saem do seu servidor.

slides --server atrás do proxy

O slides --server fica em 127.0.0.1 e o Caddy encaminha para ele. O servidor verifica o cabeçalho Host de cada requisição e o Origin do WebSocket de cada recarga ao vivo, contra DNS rebinding e páginas de outros sites, e o proxy mantém o nome público nos dois. --allowed-host acrescenta esse nome ao que as verificações aceitam; elas continuam ativas:

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

A recarga ao vivo passa como está (o Caddy faz proxy de WebSockets sem configuração). Não use --host 0.0.0.0 para isso: ele serve o diretório para a rede inteira, contornando o proxy e o login dele.

--allowed-host está na v0.14.0 e posteriores: go install github.com/go-slides/slides/cmd/slides@v0.20.1. Requer Go 1.27.3 ou posterior.

Login com OpenID Connect

O Caddy consulta o oauth2-proxy sobre cada requisição (forward_auth). O oauth2-proxy responde 202 para um navegador que fez login, o que o Caddy então serve, e 401 caso contrário, o que o Caddy transforma em um redirecionamento para o login. O login é o fluxo de código de autorização do OpenID Connect Core 1.0: o navegador vai ao seu provedor (Keycloak, Authentik, Entra ID, Google, Okta, Dex…), volta a /oauth2/callback com um código, o oauth2-proxy troca o código por um ID Token, o verifica e define o seu próprio cookie.

  1. Registre um cliente no seu provedor, confidencial, com o fluxo de código de autorização. As URIs de redirecionamento dele, que devem coincidir exatamente: https://slides.example.org/oauth2/callback e https://decks.example.org/oauth2/callback.
  2. Coloque os segredos em arquivos legíveis somente pelo oauth2-proxy: o segredo do cliente, sem quebra de linha no final, e um segredo de cookie de exatamente 32 bytes aleatórios (head -c 32 /dev/urandom). O seu repositório de segredos os escreve (credenciais do systemd, segredos do Docker ou do Kubernetes, Vault…); nenhum dos dois vai para um arquivo de configuração, uma linha de comando ou uma variável de ambiente, onde os logs e o ps os mostram.
  3. Configure o 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 sem host leva o login de cada site de volta ao seu próprio callback, de modo que cada site mantém o seu próprio cookie. whitelist_domains é para onde um login pode retornar: se pedirem para ir a qualquer outro lugar, ele permanece no seu site. trusted_proxy_ips acredita nos cabeçalhos X-Forwarded-* somente do Caddy. email_domains diz quem pode entrar; o oauth2-proxy também tem opções para grupos e listas de endereços.

  1. Configure o 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

O oauth2-proxy e o slides escutam somente no loopback: o único caminho de entrada passa pelo Caddy, e portanto pelo login. Os cabeçalhos do Playground ficam dentro do bloco de login, somente no Playground: a página de login do oauth2-proxy é um formulário, que o form-action 'none' deles bloquearia.

A alternativa é o caddy-security, um módulo do Caddy que faz o login dentro do próprio Caddy, sem um segundo processo. Ele exige um Caddy construído com o módulo (xcaddy) e tem a sua própria linguagem de configuração.

slides-gitd: as credenciais ficam no servidor

O slides-gitd (v0.1.0) é o backend de git de um Playground auto-hospedado. Ele carrega a pasta de uma apresentação de uma forja, propõe as mudanças em um novo branch com um pull (merge) request e procura mudanças a montante, enquanto as credenciais da forja ficam no servidor: o navegador só tem um cookie de sessão HttpOnly. Fica atrás do Caddy e do oauth2-proxy (OIDC), escuta em 127.0.0.1:8091, toma a identidade do usuário só do proxy e só escreve em branches chamados slides/<usuário>/<título>-<hora> que o próprio servidor nomeia.

O modo servidor do Playground, que fala com o slides-gitd em vez de o navegador guardar um token, é descrito em O Playground atrás do slides-gitd.

Configuração (os segredos são caminhos de arquivo, nunca valores; cada arquivo só deve ser legível pelo dono):

{
  "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"]}
  }
}

Um usuário só alcança as entradas forja:dono/nome listadas para o seu sujeito OIDC (negado por padrão). Crie a chave mestra com head -c 32 /dev/urandom > master.key && chmod 600 master.key, depois execute slides-gitd check -c config.json, que carrega todos os arquivos de segredos e sai, e slides-gitd serve -c config.json.

O lado do Caddy, com o oauth2-proxy iniciado com --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 }
}

As linhas request_header -… apagam qualquer cabeçalho de identidade enviado pelo cliente, que assim não pode escolher quem é; o Caddy recente faz isso sozinho antes de copy_headers, as versões antigas não. {file.*} lê o segredo do proxy de um arquivo, então ele não fica em uma variável de ambiente.

O GitHub usa um App. Crie-o em Settings ▸ Developer settings ▸ GitHub Apps com as permissões Contents de leitura e escrita, Pull requests de leitura e escrita e Metadata de leitura, sem webhook; instale-o só nos repositórios das apresentações. O slides-gitd emite um token de instalação para o único repositório de cada chamada, válido por uma hora.

GitLab: cada usuário guarda um token de acesso pessoal ou de projeto com o escopo api. Por que api e não write_repository, e o que um token roubado pode fazer, está explicado na página do Playground; prefira um token de acesso de projeto ou de grupo limitado aos repositórios das apresentações, ou uma conta dedicada.

O Playground atrás do slides-gitd (modo servidor)

Quando o Playground é servido atrás do slides-gitd em sua própria origem, ele detecta isso perguntando GET /api/git/session nessa origem. 401 significa que há um servidor e ninguém fez login: o painel mostra Entrar. Uma sessão JSON significa que o login foi feito. Qualquer outra coisa, como o 404 de um host estático, significa que não há servidor, e o painel funciona exatamente como em go-slides.github.io: um token guardado na aba, ou o login do GitLab.

No modo servidor, o painel esconde os campos de token e de OAuth. Ele mostra Signed in as <identity> (logado como) e a lista de repositórios que o servidor concede. Carregar, Propor mudanças… (o servidor nomeia o branch slides/<readable>-<hash>/…; a página não pode escolher um), Procurar mudanças e Ficar com as deles vão todos para /api/git/* na mesma origem. O navegador não contata nenhum host da forja.

O que fica onde:

  • Os tokens vivem no servidor, em um cofre AES-GCM com uma chave por usuário.
  • O navegador guarda apenas o cookie __Host-gitd (HttpOnly, Secure, SameSite=Strict), que os scripts não conseguem ler.
  • O token CSRF fica só na memória da página e nunca é armazenado.
  • Toda escrita leva o Origin exato, X-Requested-With: slides-gitd, um corpo JSON e X-CSRF-Token.

Quando o login expirou ou não existe (HTTP 401), o painel mostra “Seu login terminou” com um botão Entrar (/oauth2/sign_in?rd=<this page> do oauth2-proxy). Ele nunca recorre a um token digitado no navegador.

Para servi-lo, use selfhost/Caddyfile.gitd de go-slides/go-slides.github.io. A sua Content-Security-Policy é connect-src 'self'; o site continua público e só /api/git/* fica atrás do oauth2-proxy. A configuração origin do slides-gitd deve ser igual à origem do Playground. Uma forja cujo auth é user-token (GitLab, por exemplo) precisa do token próprio de cada usuário, guardado no servidor por POST /api/git/credential. O painel não oferece um campo para isso, então o administrador ou o usuário o faz fora da página.

Um limite: o Playground guarda os rascunhos e o último nome de repositório no perfil do navegador (IndexedDB e localStorage), como antes. Em um perfil de navegador compartilhado, use Draft ▸ Forget my drafts ou perfis separados. No modo servidor, um repositório lembrado ao qual o usuário não tem acesso é descartado.

Verificar

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

A resposta diz content-type: application/wasm, content-encoding: br e cache-control: public, max-age=31536000, immutable, com o content-security-policy acima. Com o login na frente, a mesma requisição sem cookie responde 302 para /oauth2/sign_in.

Referências

Created on by tannevaled

Modified on by tannevaled