본문으로 건너뛰기

셀프 호스팅

Caddy로 Playground와 slides --server를 자신의 컴퓨터에서 서비스합니다. 자동 HTTPS, Playground에 필요한 헤더, 앞단의 OpenID Connect 로그인까지 다룹니다.

Playground는 정적 파일이고, slides --server는 루프백에서 동작하는 하나의 프로세스입니다. 둘 다 자신의 컴퓨터에서, 자신의 이름으로 실행해 조직의 계정으로 로그인하는 사람들에게 제공할 수 있습니다. 이 페이지에서는 HTTPS 인증서를 직접 받고 갱신하는 Caddy(v2.11.7로 테스트)와, 로그인을 맡는 oauth2-proxy를 사용합니다. 여기에 나오는 모든 파일은 selfhost/에 있으며, 그곳의 CI가 각 Caddyfile을 caddy validate로 검사합니다.

Playground

사이트의 각 릴리스에는 Playground를 포함한 사이트 전체가 도메인 루트용으로 빌드된 tarball 하나로, 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

이 tarball은 재현 가능합니다. 같은 태그를 다시 빌드하면 같은 바이트가 나옵니다. 압축되는 모든 파일(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.playground

그러면 Playground는 https://slides.example.org/playground/에서 열립니다.

서버가 올바르게 해야 하는 일

  • HTTPS. Caddy는 ACME(RFC 8555)로 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의 헤더:
(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를 보내면 덱이 다른 사이트에서 가져오는 그림이 차단됩니다.

네트워크 없이

페이지를 불러온 뒤에는 편집기, 미리보기, 발표자 보기, 그리고 PDF·PPTX·ODP 내보내기 (글꼴은 tarball에 들어 있습니다)까지 모든 것이 브라우저 안에서 일어납니다. 덱은 여러분의 서버로도, 다른 어디로도 전송되지 않습니다. 네트워크가 필요한 것은 덱이 직접 가리키는 것들, 즉 https: 주소의 그림이나 스타일시트와, gaia 테마의 웹 글꼴입니다. 이 글꼴은 fonts.bunny.net에 요청합니다(없으면 슬라이드는 다른 글꼴로 대체됩니다). 이 문서로 향하는 링크는 여러분의 서버를 벗어납니다.

프록시 뒤의 slides --server

slides --server는 127.0.0.1에 머물고 Caddy가 그쪽으로 전달합니다. 서버는 DNS 리바인딩과 다른 사이트의 페이지를 막기 위해 각 요청의 Host 헤더와 각 라이브 리로드 WebSocket의 Origin을 검사하며, 프록시는 둘 다에 공개 이름을 유지합니다. --allowed-host는 그 이름을 검사가 받아들이는 대상에 추가합니다. 검사는 켜진 채로 있습니다.

slides -s --allowed-host decks.example.org /srv/decks
decks.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으로 교환해 검증한 뒤 자체 쿠키를 설정합니다.

  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).
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에는 그룹과 주소 목록을 위한 옵션도 있습니다.

  1. 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와 slides는 루프백에서만 수신합니다. 들어오는 길은 Caddy뿐이며, 따라서 로그인을 반드시 거칩니다. Playground의 헤더는 로그인된 블록 안쪽에, Playground에만 둡니다. oauth2-proxy의 로그인 페이지는 폼인데, 이 헤더의 form-action 'none'이 그것을 막기 때문입니다.

대안은 caddy-security입니다. Caddy 안에서 로그인을 처리하는 Caddy 모듈로, 두 번째 프로세스가 필요 없습니다. 다만 이 모듈을 넣어 빌드한 Caddy(xcaddy)와 자체 설정 언어가 필요합니다.

slides-gitd: 자격 증명은 서버에 남습니다

slides-gitd(v0.1.0)는 자체 호스팅 Playground의 git 백엔드입니다. forge에서 발표 자료 폴더를 불러오고, 변경 사항을 새 브랜치와 풀(머지) 리퀘스트로 제안하며, 업스트림 변경을 확인합니다. forge 자격 증명은 서버에 남고, 브라우저가 가지는 것은 HttpOnly 세션 쿠키뿐입니다. Caddy와 oauth2-proxy(OIDC) 뒤에 두며 127.0.0.1:8091에서 수신하고, 사용자 신원은 프록시에서만 받으며, 서버가 직접 이름을 붙인 slides/<사용자>/<제목>-<시각> 브랜치에만 씁니다.

브라우저가 토큰을 가지는 대신 slides-gitd와 통신하는 Playground의 서버 모드는 slides-gitd 뒤의 Playground에서 설명합니다.

설정(비밀은 파일 경로이며 값이 아닙니다. 각 파일은 소유자만 읽을 수 있어야 합니다):

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

사용자는 자신의 OIDC 주체에 나열된 forge:소유자/이름 항목에만 접근합니다(기본 거부). 마스터 키는 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의 개인 또는 프로젝트 액세스 토큰을 저장합니다. write_repository가 아니라 api여야 하는 이유와 도난당한 토큰으로 할 수 있는 일은 Playground 페이지에서 설명합니다. 발표 자료 저장소로 제한한 프로젝트 또는 그룹 액세스 토큰이나 전용 계정을 쓰세요.

slides-gitd 뒤의 Playground (서버 모드)

Playground를 slides-gitd 뒤에서 자체 오리진으로 제공하면, Playground는 그 오리진에 GET /api/git/session을 요청해 이를 알아냅니다. 401은 서버가 있지만 아직 아무도 로그인하지 않았다는 뜻이며, 창에 로그인이 표시됩니다. JSON 세션이 오면 로그인한 상태입니다. 그 밖의 응답, 예컨대 정적 호스트의 404는 서버가 없다는 뜻이며, 창은 go-slides.github.io에서와 똑같이 동작합니다. 즉 탭에 보관한 토큰을 쓰거나 GitLab 로그인을 씁니다.

서버 모드에서는 창이 토큰과 OAuth 입력란을 숨기고, Signed in as <identity>(로그인한 사용자)와 서버가 허용한 저장소 목록을 보여 줍니다. 불러오기, 변경 제안…(브랜치 slides/<readable>-<hash>/…는 서버가 이름을 붙이며 페이지는 고를 수 없습니다), 변경 확인, 상대 것 받기는 모두 같은 오리진의 /api/git/*로 갑니다. 브라우저는 forge 호스트에 접속하지 않습니다.

무엇이 어디에 남는가:

  • 토큰은 사용자마다 키가 하나인 AES-GCM 보관소에 담겨 서버에 있습니다.
  • 브라우저가 가지는 것은 __Host-gitd 쿠키(HttpOnly, Secure, SameSite=Strict)뿐이며, 스크립트는 이것을 읽을 수 없습니다.
  • CSRF 토큰은 페이지의 메모리에만 있고 저장되지 않습니다.
  • 모든 쓰기 요청은 정확한 Origin, X-Requested-With: slides-gitd, JSON 본문, X-CSRF-Token을 함께 보냅니다.

로그인이 만료되었거나 없으면(HTTP 401) 창에 “로그인이 종료되었습니다"와 로그인 버튼 (oauth2-proxy의 /oauth2/sign_in?rd=<this page>)이 표시됩니다. 브라우저에 입력한 토큰으로 대체하는 일은 절대 없습니다.

제공하려면 go-slides/go-slides.github.io의 selfhost/Caddyfile.gitd를 쓰세요. 그 Content-Security-Policy는 connect-src 'self'이고, 사이트는 공개 상태로 두며 oauth2-proxy 뒤에 두는 것은 /api/git/*뿐입니다. slides-gitd의 origin 설정은 Playground의 오리진과 같아야 합니다. auth가 user-token인 forge(예: 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도 함께 옵니다. 앞단에 로그인이 있으면, 같은 요청을 쿠키 없이 보낼 때 302로 /oauth2/sign_in에 리디렉션됩니다.

참고 자료

Created on by tannevaled

Modified on by tannevaled