Самостоятельный хостинг
Playground — это статические файлы, а
slides --server — один процесс на loopback. Оба можно
запустить на своей машине, под своим именем, для людей, входящих с учётной
записью вашей организации. Эта страница делает это через
Caddy (проверено с v2.11.7), который сам
получает и продлевает сертификаты HTTPS, и
oauth2-proxy для входа. Все
показанные здесь файлы лежат в
selfhost/,
а CI проверяет каждый Caddyfile командой caddy validate.
Playground
Каждый выпуск сайта прикладывает весь сайт, включая Playground, одним архивом tar, собранным для корня домена, вместе с его 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Архив воспроизводим: повторная сборка того же тега даёт те же байты. Рядом с
каждым файлом, который сжимается (модули WebAssembly, шрифты, страницы,
скрипты), он содержит тот же файл, сжатый Brotli (.br) и gzip (.gz), так
что сервер отдаёт их как есть и ничего не сжимает на лету.
Caddyfile
slides.example.org — это ваше имя. Его записи A и AAAA указывают на машину, а
порты 80 и 443 доходят до Caddy:
import headers.caddy
slides.example.org {
root * /srv/go-slides-site
file_server {
precompressed br gzip
}
import playground_headers
}caddy run --config Caddyfile.playgroundPlayground после этого доступен по адресу https://slides.example.org/playground/.
Что сервер должен делать правильно
- HTTPS. Caddy получает сертификат от Let’s Encrypt по ACME
(RFC 8555), доказывая, что владеет
именем, ответом на порту 80 (HTTP-01) или 443 (TLS-ALPN-01), и продлевает его.
Машина, до которой центр сертификации не может добраться, может использовать
вызов DNS-01 (запись TXT, для чего нужен Caddy, собранный с модулем вашего
DNS-провайдера) или собственный сертификат через
tls cert.pem key.pem. На тестовой машинеtls internalиспользует локальный центр сертификации Caddy. application/wasm. Caddy отправляет этот тип для.wasm; другому серверу, возможно, придётся об этом сказать. Playground компилирует свои модули из прочитанных байтов (движок экспорта — по мере загрузки, в собственном ответе с этим типом), поэтому запускается даже с сервера, отправляющего другой тип; приnosniffправильный тип всё же стоит отправлять.- Сжатие.
precompressed br gzipотправляетplayground.wasm.brбраузеру, принимающему Brotli,.gz— принимающему gzip, а иначе сам файл, сVary: Accept-Encoding. Модуль редактора занимает около 7 МиБ в Brotli против 33 МиБ без сжатия. - Кэширование. Страница запрашивает каждый модуль, worker, шрифт и картинку
с выпуском в запросе (
playground.wasm?v=v0.1.0), поэтому их можно хранить год (immutable). Сами страницы перепроверяются (no-cache): новый выпуск виден при следующем посещении. - Заголовки в
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"
}Политика — та, что нужна Playground: её скрипты (загрузчик встроенный), её
WebAssembly ('wasm-unsafe-eval') и её worker, всё с вашего сервера.
Картинки, таблицы стилей и шрифты презентации могут приходить с любого адреса
https:, как и в выводе команды; уберите https: из img-src, style-src,
font-src, media-src и connect-src, чтобы все запросы шли на ваш сервер, —
тогда презентации, ссылающиеся на сторонние ресурсы, будут показываться без
них. Сама презентация работает в iframe, изолированных без вашего источника, под
собственной, более строгой политикой, которую Playground записывает в каждый
фрейм: он не может обратиться ни к Playground, ни к его хранилищу, ни к вашим
cookie, что бы ни говорил этот заголовок. frame-ancestors 'self' не даёт
другим сайтам встраивать Playground во фреймы.
- Без COOP и COEP. Playground не использует ни
SharedArrayBuffer, ни потоки, поэтому ей не нужна кросс-источниковая изоляция. ОтправкаCross-Origin-Embedder-Policy: require-corpзаблокировала бы картинки презентации с других сайтов.
Без сети
Когда страница загружена, всё происходит в браузере: редактор, предпросмотр,
режим докладчика и экспорт в PDF, PPTX и ODP, шрифты для которых лежат в
архиве. Презентация не отправляется ни на ваш сервер, ни куда-либо ещё. Сети
требует то, что называет сама презентация: картинки или таблицы стилей по
адресу https: и веб-шрифт темы gaia, который она запрашивает у
fonts.bunny.net (без него слайды переходят на другой шрифт). Ссылки на эту
документацию уходят с вашего сервера.
slides --server за прокси
slides --server остаётся на 127.0.0.1, а Caddy пересылает ему запросы.
Сервер проверяет заголовок Host каждого запроса и Origin WebSocket каждой
живой перезагрузки — против DNS rebinding и страниц других сайтов, — а прокси
сохраняет публичное имя в обоих. --allowed-host добавляет это имя к тому, что
проверки принимают; сами они остаются включёнными:
slides -s --allowed-host decks.example.org /srv/decksdecks.example.org {
reverse_proxy 127.0.0.1:8080
}Живая перезагрузка проходит как есть (Caddy проксирует WebSocket без настройки).
Не используйте для этого --host 0.0.0.0: он отдаёт каталог всей сети в обход
прокси и его входа.
--allowed-host есть в v0.14.0 и позже:
go install github.com/go-slides/slides/cmd/slides@v0.20.1. Нужен Go 1.27.3
или новее.Вход через OpenID Connect
Caddy спрашивает oauth2-proxy о каждом запросе (forward_auth). oauth2-proxy
отвечает 202 браузеру, который вошёл, — и Caddy его обслуживает, — и 401 в
остальных случаях, который Caddy превращает в перенаправление на вход. Вход —
это поток кода авторизации
OpenID Connect Core 1.0:
браузер идёт к вашему провайдеру (Keycloak, Authentik, Entra ID, Google, Okta,
Dex…), возвращается на /oauth2/callback с кодом, oauth2-proxy обменивает код
на ID Token, проверяет его и ставит собственный cookie.
- Зарегистрируйте клиента у вашего провайдера, конфиденциального, с потоком
кода авторизации. Его URI перенаправления, которые должны совпадать точно:
https://slides.example.org/oauth2/callbackиhttps://decks.example.org/oauth2/callback. - Положите секреты в файлы, читаемые только oauth2-proxy: секрет клиента без
перевода строки в конце и секрет cookie ровно из 32 случайных байт
(
head -c 32 /dev/urandom). Их записывает ваше хранилище секретов (учётные данные systemd, секреты Docker или Kubernetes, Vault…); ни один не попадает в файл конфигурации, командную строку или переменную окружения, где его покажут журналы иps. - Настройте 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 без хоста возвращает вход каждого сайта на его собственный
callback, так что у каждого сайта свой cookie. whitelist_domains — это то,
куда вход может вернуть: если попросить перейти куда-то ещё, он остаётся на
вашем сайте. trusted_proxy_ips доверяет заголовкам X-Forwarded-* только от
Caddy. email_domains определяет, кто может войти; у oauth2-proxy есть и
параметры для групп и списков адресов.
- Настройте Caddy (
Caddyfile.oidc):
import headers.caddy
(signed_in) {
handle /oauth2/* {
reverse_proxy 127.0.0.1:4180
}
handle {
forward_auth 127.0.0.1:4180 {
uri /oauth2/auth
copy_headers X-Auth-Request-User X-Auth-Request-Email
@unauthorized status 401
handle_response @unauthorized {
redir * /oauth2/sign_in?rd={scheme}://{hostport}{uri} 302
}
}
{block}
}
}
slides.example.org {
import signed_in {
root * /srv/go-slides-site
file_server {
precompressed br gzip
}
import playground_headers
}
}
decks.example.org {
import signed_in {
reverse_proxy 127.0.0.1:8080
}
}slides -s --allowed-host decks.example.org /srv/decks
oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
caddy run --config Caddyfile.oidcoauth2-proxy и slides слушают только loopback: единственный путь внутрь
идёт через Caddy, а значит, через вход. Заголовки Playground находятся внутри
блока с входом и относятся только к нему: страница входа oauth2-proxy — это
форма, которую остановил бы их form-action 'none'.
Альтернатива — caddy-security,
модуль Caddy, выполняющий вход внутри Caddy, без второго процесса. Ему нужен
Caddy, собранный с модулем (xcaddy), и собственный язык конфигурации.
slides-gitd: учётные данные остаются на сервере
slides-gitd (v0.1.0) — это git-бэкенд
собственного Playground. Он загружает папку презентации из forge, предлагает
изменения в новой ветке с pull (merge) request и проверяет изменения в
первоисточнике, а учётные данные forge остаются на сервере: у браузера есть
только сессионный cookie HttpOnly. Он стоит за Caddy и oauth2-proxy (OIDC),
слушает 127.0.0.1:8091, личность пользователя берёт только у прокси и пишет
только в ветки с именем slides/<пользователь>/<заголовок>-<время>, которые
называет сам сервер.
Серверный режим Playground, который обращается к slides-gitd вместо того, чтобы браузер хранил токен, описан в разделе Playground за slides-gitd.
Конфигурация (секреты — это пути к файлам, никогда не значения; каждый файл должен читаться только владельцем):
{
"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"]}
}
}Пользователь достаёт только записи forge:владелец/имя, перечисленные для его
субъекта OIDC (по умолчанию запрещено). Создайте главный ключ командой
head -c 32 /dev/urandom > master.key && chmod 600 master.key, затем запустите
slides-gitd check -c config.json, который загружает все файлы секретов и
завершается, и slides-gitd serve -c config.json.
Сторона Caddy, с oauth2-proxy, запущенным с --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 }
}Строки request_header -… удаляют любой заголовок личности, присланный клиентом,
так что клиент не может выбрать, кто он; свежий Caddy делает это сам перед
copy_headers, старые версии — нет. {file.*} читает секрет прокси из файла,
поэтому его нет в переменной окружения.
GitHub использует App. Создайте её в Settings ▸ Developer settings ▸ GitHub Apps с правами Contents чтение и запись, Pull requests чтение и запись и Metadata чтение, без webhook; установите её только на репозитории презентаций. slides-gitd выпускает токен установки для единственного репозитория каждого вызова, действующий один час.
GitLab: каждый пользователь сохраняет персональный токен доступа или токен
проекта с областью api. Почему api, а не write_repository, и что может
украденный токен, объяснено на странице Playground;
выбирайте токен доступа проекта или группы, ограниченный репозиториями
презентаций, или отдельную учётную запись.
Playground за slides-gitd (серверный режим)
Когда Playground отдаётся за slides-gitd со своего origin, он обнаруживает это,
запрашивая GET /api/git/session на этом origin. 401 означает, что сервер
есть, но никто не вошёл: панель показывает Войти. JSON-сессия означает, что
вход выполнен. Всё остальное, например 404 статического хоста, означает, что
сервера нет, и панель работает точно так же, как на go-slides.github.io: с
токеном во вкладке или со входом через GitLab.
В серверном режиме панель скрывает поля токена и OAuth. Она показывает
Signed in as <identity> (вы вошли как) и список репозиториев, которые выдаёт
сервер. Загрузить, Предложить изменения… (ветку
slides/<readable>-<hash>/… называет сервер; страница не может выбрать свою),
Проверить изменения и Взять их обращаются к /api/git/* на том же
origin. Браузер не обращается ни к одному хосту forge.
Что где остаётся:
- Токены живут на сервере, в хранилище AES-GCM с одним ключом на пользователя.
- Браузер хранит только cookie
__Host-gitd(HttpOnly,Secure,SameSite=Strict), которую скрипты прочитать не могут. - Токен CSRF хранится только в памяти страницы и никогда не сохраняется.
- Каждая запись несёт точный
Origin,X-Requested-With: slides-gitd, тело JSON иX-CSRF-Token.
Если вход истёк или отсутствует (HTTP 401), панель показывает «Ваш вход
завершён» с кнопкой Войти (/oauth2/sign_in?rd=<this page> из oauth2-proxy).
Она никогда не возвращается к токену, введённому в браузере.
Чтобы отдавать его, используйте selfhost/Caddyfile.gitd из
go-slides/go-slides.github.io. Его Content-Security-Policy — connect-src 'self'; сайт остаётся публичным, и только /api/git/* стоит за oauth2-proxy.
Параметр origin у slides-gitd должен совпадать с origin Playground. Forge, у
которого auth равно user-token (например, GitLab), требует собственный токен
каждого пользователя, сохранённый на сервере через POST /api/git/credential.
Поля для этого на панели нет, поэтому администратор или пользователь делает это
вне страницы.
Одно ограничение: Playground по-прежнему хранит черновики и последнее имя репозитория в профиле браузера (IndexedDB и localStorage). В общем профиле браузера используйте Draft ▸ Forget my drafts или отдельные профили. В серверном режиме запомненный репозиторий, к которому у пользователя нет доступа, отбрасывается.
Проверка
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.
Источники
- Caddy: автоматический HTTPS,
file_server,header,reverse_proxy,forward_auth. - RFC 8555, ACME: HTTP-01 (8.3), DNS-01 (8.4).
- OpenID Connect Core 1.0: поток кода авторизации (3.1), URI перенаправления (3.1.2.1), ID Token (2).
- oauth2-proxy: конфигурация, провайдер OpenID Connect.
Created on by tannevaled
Modified on by tannevaled