clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Clone private repos over HTTPS with a token

cat committed 3cd778d
For a git path of a repo without git-daemon-export-ok, private or not
there at all, Caddy first asks the private server (forward_auth to
/auth/git, with the owner/repo it matched in X-Forge-Repo). Without a
token the answer is 401, so git sends its credentials; with a live
token, 200 if its holder may read the repo and 404 if not, alike for a
private repo and a missing one. Only then does git http-backend run, and
only that route sets GIT_HTTP_EXPORT_ALL. Public repos clone as before,
and nothing but upload-pack paths is routed. The token goes in as the
password (https://dami:<token>@host/...), as the user name alone, or
from a credential helper.

private-web-clone clones through a real Caddy when it is installed, with
test/git-cgi.ts standing in for fcgiwrap.
commit
3cd778d7f75ff2debcc4020f3f983399b978505b
parent
6010fa07e38f59814b3b456713f53ee6093fe872
author
cat <admin@dev.site>
kind
feat risk score 1.6 of 3, Jev's estimate
5 files changed, +209 -14
Mbin/forge45+++++++++++---
Mlib/private-server.ts26++++++++
Atest/git-cgi.ts49++++++++++++++++
Mtest/scenarios/private-http.sh9++-
Atest/scenarios/private-web-clone.sh94++++++++++++++++++++++++++++++
M bin/forge +35 -10
@@ -1046,19 +1046,44 @@ EOF
10461046 # Clones and fetches go to git http-backend (through fcgiwrap): smart HTTP10471047 # packs only what the client needs and supports shallow fetches, which the10481048 # go command uses, and it answers the dumb protocol's paths too (HEAD,1049- # info/refs, objects). It serves only repos with git-daemon-export-ok, so1050- # a private repo is a 404, the same as a missing one. Read-only: only1051- # upload-pack is routed; pushes are ssh. Nothing else inside a bare repo1052- # is served (no forge/, config or hooks).1049+ # info/refs, objects). By itself it serves only repos with1050+ # git-daemon-export-ok. A repo without the flag (private, or not there at1051+ # all) is asked about first: forward_auth hands the token to the private1052+ # server, which answers 401 without one and 404 unless its holder may1053+ # read the repo Caddy matched (X-Forge-Repo), so a private repo and a1054+ # missing one look alike. Only then does http-backend run, with1055+ # GIT_HTTP_EXPORT_ALL. Read-only: only upload-pack is routed; pushes are1056+ # ssh. Nothing else inside a bare repo is served (no forge/, config or1057+ # hooks).10531058 if command -v fcgiwrap >/dev/null 2>&1; then10541059 cat <<EOF1055- @git path_regexp git ^/[A-Za-z0-9_-][^/]*/[^/]+\\.git/(HEAD|info/refs|objects/info/(alternates|http-alternates|packs)|objects/[0-9a-f]{2}/([0-9a-f]{38}|[0-9a-f]{62})|objects/pack/pack-([0-9a-f]{40}|[0-9a-f]{64})\\.(pack|idx)|git-upload-pack)$1060+ @git path_regexp git ^/([A-Za-z0-9_-][^/]*)/([^/]+)\\.git/(HEAD|info/refs|objects/info/(alternates|http-alternates|packs)|objects/[0-9a-f]{2}/([0-9a-f]{38}|[0-9a-f]{62})|objects/pack/pack-([0-9a-f]{40}|[0-9a-f]{64})\\.(pack|idx)|git-upload-pack)$10561061 handle @git {1057- reverse_proxy unix//run/fcgiwrap.socket {1058- transport fastcgi {1059- env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend1060- env GIT_PROJECT_ROOT $FORGE_ROOT1061- env PATH_INFO {path}1062+ @hidden not file {1063+ root $FORGE_ROOT1064+ try_files /{re.git.1}/{re.git.2}.git/git-daemon-export-ok1065+ }1066+ handle @hidden {1067+ forward_auth 127.0.0.1:${FORGE_PRIVATE_PORT:-8093} {1068+ uri /auth/git1069+ header_up X-Forge-Repo {re.git.1}/{re.git.2}1070+ }1071+ reverse_proxy unix//run/fcgiwrap.socket {1072+ transport fastcgi {1073+ env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend1074+ env GIT_PROJECT_ROOT $FORGE_ROOT1075+ env PATH_INFO {path}1076+ env GIT_HTTP_EXPORT_ALL 11077+ }1078+ }1079+ }1080+ handle {1081+ reverse_proxy unix//run/fcgiwrap.socket {1082+ transport fastcgi {1083+ env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend1084+ env GIT_PROJECT_ROOT $FORGE_ROOT1085+ env PATH_INFO {path}1086+ }10621087 }10631088 }10641089 }
M lib/private-server.ts +26 -0
@@ -18,6 +18,8 @@
1818 // /private/ the private repos you can read1919 // /assets/<file> the forge's assets, for pages here before a build has shipped them2020 // /<owner>/<repo>/ a private repo's pages21+// /auth/git Caddy's forward_auth for HTTPS clones of repos without22+// git-daemon-export-ok: may this token read X-Forge-Repo?2123 //2224 // node lib/private-server.ts (FORGE_ROOT, FORGE_BASE_URL, FORGE_PRIVATE_PORT: 8093)2325 import http from 'node:http';
@@ -178,6 +180,29 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
178180 sendFile(req, res, file, { 'Cache-Control': 'private, no-cache', ...cookie(who) });179181 }180182 183+ // An HTTPS clone or fetch of a repo without git-daemon-export-ok, asked by184+ // Caddy (forward_auth) before git http-backend serves it. X-Forge-Repo is185+ // the owner/repo Caddy matched in the path it will serve. git sends its186+ // credentials only when asked, so without a token the answer is 401, for187+ // any repo; with a live token, 200 if its holder may read the repo and188+ // 404 otherwise, the same for a private repo and a missing one. Only189+ // tokens count here: git has no cookies.190+ function gitAuth(req: http.IncomingMessage, res: http.ServerResponse): void {191+ const who = gate.byToken(presented(req));192+ const slug = String(req.headers['x-forge-repo'] ?? '');193+ if (!who) {194+ res.writeHead(401, { 'WWW-Authenticate': `Basic realm="${cfg.name ?? 'forge'}", charset="UTF-8"`, 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' });195+ res.end('A token is needed: ssh <host> token --repo <owner>/<repo>, then use it as the password.\n');196+ return;197+ }198+ const dir = repoDir(cfg.root, slug);199+ if (!dir || !(isPublic(dir) || gate.reads(who, slug, dir))) {200+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' }).end('Repository not found\n');201+ return;202+ }203+ res.writeHead(200, { 'Cache-Control': 'no-cache' }).end();204+ }205+181206 // The forge's assets by hashed name, and the CSS bundles of the pages182207 // rendered here, in case a build hasn't put them in www yet.183208 function asset(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
@@ -276,6 +301,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
276301 else if (url.pathname === '/private/' && get) listPage(req, res);277302 else if (url.pathname === '/login' || url.pathname === '/private') res.writeHead(308, { Location: url.pathname + '/' + url.search }).end();278303 else if (url.pathname.startsWith('/assets/') && get) asset(req, res, url);304+ else if (url.pathname === '/auth/git' && get) gitAuth(req, res);279305 else page(req, res, url);280306 } catch (e) {281307 fail(e);
A test/git-cgi.ts +49 -0
@@ -0,0 +1,49 @@
1+// Stands in for fcgiwrap in scenarios, which may run where it isn't2+// installed: an HTTP server that runs git http-backend as a CGI script for3+// each request. Caddy reaches it with a reverse_proxy whose header_up lines4+// carry what the forge's Caddyfile passes fcgiwrap as env (X-Cgi-<NAME>:5+// <value>; test/scenarios/private-web-clone.sh rewrites the one into the6+// other), so the routing under test is Caddy's own.7+//8+// node test/git-cgi.ts <port>9+import http from 'node:http';10+import path from 'node:path';11+import { execFileSync, spawn } from 'node:child_process';12+13+const backend = path.join(execFileSync('git', ['--exec-path']).toString().trim(), 'git-http-backend');14+const keep = ['PATH', 'HOME', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM'];15+16+http.createServer((req, res) => {17+ const url = new URL(req.url ?? '/', 'http://x');18+ const env: Record<string, string> = { REQUEST_METHOD: req.method ?? 'GET', QUERY_STRING: url.search.slice(1), REMOTE_ADDR: '127.0.0.1', CONTENT_TYPE: req.headers['content-type'] ?? '' };19+ for (const k of keep) if (process.env[k]) env[k] = process.env[k]!;20+ if (req.headers['content-length']) env.CONTENT_LENGTH = req.headers['content-length'];21+ if (req.headers['content-encoding']) env.HTTP_CONTENT_ENCODING = req.headers['content-encoding'];22+ if (req.headers['git-protocol']) env.GIT_PROTOCOL = String(req.headers['git-protocol']);23+ for (const [k, v] of Object.entries(req.headers)) if (k.startsWith('x-cgi-')) env[k.slice(6).toUpperCase()] = String(v);24+ const cgi = spawn(backend, [], { env, stdio: ['pipe', 'pipe', 'inherit'] });25+ req.pipe(cgi.stdin);26+ // CGI output: header lines (Status: among them), a blank line, the body.27+ let head = Buffer.alloc(0);28+ let sent = false;29+ cgi.stdout.on('data', (d: Buffer) => {30+ if (sent) return void res.write(d);31+ head = Buffer.concat([head, d]);32+ const end = head.indexOf('\r\n\r\n') >= 0 ? head.indexOf('\r\n\r\n') + 4 : head.indexOf('\n\n') >= 0 ? head.indexOf('\n\n') + 2 : -1;33+ if (end < 0) return;34+ let status = 200;35+ const headers: Record<string, string> = {};36+ for (const line of head.subarray(0, end).toString('latin1').split(/\r?\n/)) {37+ const i = line.indexOf(':');38+ if (i <= 0) continue;39+ const k = line.slice(0, i).trim();40+ const v = line.slice(i + 1).trim();41+ if (k.toLowerCase() === 'status') status = Number(v.split(' ')[0]);42+ else headers[k] = v;43+ }44+ res.writeHead(status, headers);45+ sent = true;46+ res.write(head.subarray(end));47+ });48+ cgi.on('close', () => (sent ? res.end() : res.writeHead(502).end()));49+}).listen(Number(process.argv[2]), '127.0.0.1', () => console.log('git-cgi up'));
M test/scenarios/private-http.sh +5 -4
A test/scenarios/private-web-clone.sh +94 -0
@@ -0,0 +1,94 @@
1+# HTTPS clones of a private repo with a token: Caddy asks the private server2+# (forward_auth) about any repo without git-daemon-export-ok, and only what3+# it lets through reaches git http-backend with GIT_HTTP_EXPORT_ALL. Without4+# a token, a private repo and a missing one both ask for credentials (401);5+# with a token that may not read it, both are a 404. Public repos clone as6+# before. With caddy installed, a real git clones through a real Caddy;7+# test/git-cgi.ts stands in for fcgiwrap.8+. "$(dirname "$0")/../lib.sh"9+user dami10+user ana11+user cat12+repo --private dami/secret13+repo dami/open14+for r in secret open; do15+ clone dami "dami/$r" "$T/w/$r"16+ at "$T/w/$r" commit "first $r"17+ ok push_as dami "$T/w/$r" origin main18+done19+ok ssh_as dami readers dami/secret add ana20+21+mkdir "$T/bin"22+printf '#!/bin/sh\n' >"$T/bin/fcgiwrap"23+chmod +x "$T/bin/fcgiwrap"24+PATH="$T/bin:$PATH" forge server caddyfile git.test >"$T/Caddyfile" 2>"$T_LOG" || fail "caddyfile"25+# The flag that makes http-backend serve any repo is set once, after forward_auth.26+eq "$(grep -c 'GIT_HTTP_EXPORT_ALL' "$T/Caddyfile")" 127+awk '/forward_auth/ { a = NR } /GIT_HTTP_EXPORT_ALL/ { e = NR } END { exit !(a && e > a) }' "$T/Caddyfile" || fail "GIT_HTTP_EXPORT_ALL before forward_auth"28+has "$T/Caddyfile" 'try_files /\{re\.git\.1\}/\{re\.git\.2\}\.git/git-daemon-export-ok'29+has "$T/Caddyfile" 'header_up X-Forge-Repo \{re\.git\.1\}/\{re\.git\.2\}'30+command -v caddy >/dev/null 2>&1 || { note "caddy is not installed: skipped cloning through it"; exit 0; }31+ok caddy adapt --config "$T/Caddyfile" --adapter caddyfile32+33+cport=$(free_port)34+gport=$(free_port)35+private_server36+node "$T_HOME/test/git-cgi.ts" "$gport" >"$T/cgi.log" 2>&1 &37+T_PIDS="$T_PIDS $!"38+{39+ printf '{\n\tadmin off\n\tauto_https off\n\tpersist_config off\n}\n\n'40+ PATH="$T/bin:$PATH" FORGE_PRIVATE_PORT=$PRIV_PORT "$FORGE" server caddyfile "http://127.0.0.1:$cport"41+} | awk -v up="127.0.0.1:$gport" '42+ /reverse_proxy unix\/\/run\/fcgiwrap\.socket/ { sub(/unix\/\/run\/fcgiwrap\.socket/, up); print; next }43+ /transport fastcgi \{/ { fc = 1; next }44+ fc && /^[ \t]*\}$/ { fc = 0; next }45+ fc { sub(/env /, "header_up X-Cgi-"); print; next }46+ { print }' >"$T/Caddyfile.run"47+XDG_DATA_HOME=$T/xdg XDG_CONFIG_HOME=$T/xdg caddy run --config "$T/Caddyfile.run" --adapter caddyfile >"$T/caddy.log" 2>&1 &48+T_PIDS="$T_PIDS $!"49+c=http://127.0.0.1:$cport50+wait_http "$c/"51+wait_http "http://127.0.0.1:$gport/"52+ana=$(ssh_as ana token --repo dami/secret 2>/dev/null)53+ana_all=$(ssh_as ana token 2>/dev/null)54+ana_open=$(ssh_as ana token --repo dami/open 2>/dev/null)55+cat=$(ssh_as cat token 2>/dev/null)56+revoked=$(ssh_as ana token 2>"$T/err")57+ok ssh_as ana tokens rm "$(sed -n 's/^token \([0-9a-f]*\) .*/\1/p' "$T/err")"58+export GIT_TERMINAL_PROMPT=059+hclone() { git clone -q "$@"; }60+61+ok hclone "$c/dami/open.git" "$T/c/open"62+eq "$(git -C "$T/c/open" log -1 --format=%s)" "first open"63+ok hclone "http://ana:$ana@127.0.0.1:$cport/dami/secret.git" "$T/c/one"64+eq "$(git -C "$T/c/one" log -1 --format=%s)" "first secret"65+ok hclone "http://ana:$ana_all@127.0.0.1:$cport/dami/secret.git" "$T/c/all"66+# The token alone, as the user name.67+ok hclone "http://$ana@127.0.0.1:$cport/dami/secret.git" "$T/c/user"68+# A credential helper, as git would keep the token.69+ok git -c credential.helper= -c "credential.helper=!f() { echo username=ana; echo password=$ana; }; f" clone -q "$c/dami/secret.git" "$T/c/helper"70+ok git -C "$T/c/one" fetch -q71+fails hclone "$c/dami/secret.git" "$T/c/anon"72+fails hclone "http://cat:$cat@127.0.0.1:$cport/dami/secret.git" "$T/c/cat"73+fails hclone "http://ana:$ana_open@127.0.0.1:$cport/dami/secret.git" "$T/c/scope"74+fails hclone "http://ana:$revoked@127.0.0.1:$cport/dami/secret.git" "$T/c/revoked"75+missing "$T/c/anon"76+77+# What the client sees: the same for a private repo and a missing one.78+refs=/info/refs?service=git-upload-pack79+same() {80+ response "$c/dami/nope.git$refs" "$@" >"$T/missing"81+ response "$c/dami/secret.git$refs" "$@" >"$T/got"82+ cmp -s "$T/missing" "$T/got" || { diff "$T/missing" "$T/got" >&2 || true; fail "dami/secret and dami/nope differ: $*"; }83+}84+same85+has "$T/got" '^HTTP/1.1 401'86+has "$T/got" '^Www-Authenticate: Basic realm="forge"'87+same -u "cat:$cat"88+has "$T/got" '^HTTP/1.1 404'89+same -u "ana:$ana_open"90+same -u "ana:$revoked"91+has "$T/got" '^HTTP/1.1 401'92+# Nothing else in a private repo is served, token or not.93+eq "$(curl -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$c/dami/secret.git/config")" 40494+eq "$(curl -s -o /dev/null -w '%{http_code}' -u "ana:$ana" "$c/dami/secret.git/git-receive-pack")" 404