From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree. --- test | 320 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100755 test (limited to 'test') diff --git a/test b/test new file mode 100755 index 0000000..bd4c2bc --- /dev/null +++ b/test @@ -0,0 +1,320 @@ +#!/bin/sh +# smoke: build the image, run it against a temporary volume, and check what +# only breaks in the real thing. +# +# Needs docker, git, curl, lynx, openssl, python3, and timeout here. +set -eu + +IMAGE=${IMAGE:-git-forge:smoke} +PORT=${PORT:-18080} +BASE=http://127.0.0.1:$PORT +NAME=git-forge-smoke + +WORK=$(mktemp -d) +VOLUME="" +cleanup() { + docker rm -f "$NAME" >/dev/null 2>&1 || true + if [ -n "$VOLUME" ]; then + docker volume rm "$VOLUME" >/dev/null 2>&1 || true + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +ok() { printf 'ok %s\n' "$1"; } +no() { printf 'FAIL %s\n' "$1" >&2; exit 1; } + +# want +want() { grep -q -- "$2" "$3" || no "$1"; ok "$1"; } +# lack +lack() { grep -q -- "$2" "$3" && no "$1"; ok "$1"; } +# code +code() { + description=$1 expected=$2 + shift 2 + got=$(curl -s -o "$WORK/body" -w '%{http_code}' "$@") + [ "$got" = "$expected" ] || no "$description: got $got, wanted $expected" + ok "$description" +} +# csrf is the form token on a fetched page +csrf() { grep -o 'name="csrf" value="[^"]*"' "$1" | head -1 | cut -d'"' -f4; } +# field is a hidden field on a fetched page +field() { grep -o "name=\"$2\" value=\"[^\"]*\"" "$1" | head -1 | cut -d'"' -f4; } + +# The authenticator secret is the base32 of the ASCII "12345678901234567890", +# so a code can be computed with openssl alone and no base32 decoder. The +# counter goes in as eight bytes, spelled in octal because POSIX printf has +# \ooo and not \xhh. A code works once, so a second call waits for the next +# step; the step is kept in a file because $(totp) runs in a subshell. +SECRET=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ +KEYHEX=3132333435363738393031323334353637383930 +totp() { + last=$(cat "$WORK/step" 2>/dev/null || echo 0) + step=$(( $(date -u +%s) / 30 )) + while [ "$step" -le "$last" ]; do + sleep 1 + step=$(( $(date -u +%s) / 30 )) + done + echo "$step" > "$WORK/step" + msg=$(printf '\\%03o' \ + $(( (step >> 56) & 255 )) $(( (step >> 48) & 255 )) \ + $(( (step >> 40) & 255 )) $(( (step >> 32) & 255 )) \ + $(( (step >> 24) & 255 )) $(( (step >> 16) & 255 )) \ + $(( (step >> 8) & 255 )) $(( step & 255 ))) + digest=$(printf "$msg" | openssl dgst -sha1 -mac HMAC -macopt hexkey:$KEYHEX | sed 's/.*= *//') + offset=$(( 0x$(printf %s "$digest" | cut -c40) * 2 + 1 )) + chunk=$(printf %s "$digest" | cut -c${offset}-$((offset + 7))) + printf '%06d' $(( (0x$chunk & 0x7fffffff) % 1000000 )) +} + +export GIT_TERMINAL_PROMPT=0 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null +export GIT_AUTHOR_NAME=Smoke GIT_AUTHOR_EMAIL=smoke@example.com +export GIT_COMMITTER_NAME=Smoke GIT_COMMITTER_EMAIL=smoke@example.com + +alice="-c $WORK/alice -b $WORK/alice" +bob="-c $WORK/bob -b $WORK/bob" + +echo "== build and start" +docker build -q -f Containerfile -t "$IMAGE" . >/dev/null +VOLUME=$(docker volume create) +docker run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \ + -v "$VOLUME:/var/lib/git-forge" -e GIT_FORGE_BASE_URL="$BASE" "$IMAGE" >/dev/null + +for _ in 1 2 3 4 5 6 7 8 9 10; do + sleep 1 + curl -sf "$BASE/healthz" >/dev/null 2>&1 && break +done +curl -sf "$BASE/healthz" >/dev/null || no "the container never answered /healthz" +ok "/healthz answers" + +echo "== the first admin" +docker logs "$NAME" > "$WORK/log" 2>&1 +TOKEN=$(grep -o 'token: .*' "$WORK/log" | cut -d' ' -f2) +[ -n "$TOKEN" ] || no "no setup token in the log" +curl -s $alice "$BASE/setup" > "$WORK/p" +want "the setup page is served" "Setup token" "$WORK/p" +code "the setup form makes the first admin" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")&token=$TOKEN&name=alice&password=hunter2hunter2&again=hunter2hunter2" "$BASE/setup" +code "and then /setup is gone" 404 "$BASE/setup" + +echo "== the admin has to hold a second factor" +code "an admin with no authenticator is sent to enrol" 303 $alice -o /dev/null "$BASE/admin" +curl -s $alice "$BASE/settings/totp" > "$WORK/p" +# Enrolling costs the password too. +curl -s $alice -o /dev/null -w '%{http_code}' \ + -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)" "$BASE/settings/totp" > "$WORK/nopass" +grep -qx 401 "$WORK/nopass" || no "enrolling a second factor did not ask for the password" +ok "enrolling a second factor asks for the password" +curl -s $alice -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)¤t=hunter2hunter2" \ + "$BASE/settings/totp" > "$WORK/enrolled" +want "enrolling shows recovery codes once" "recovery codes" "$WORK/enrolled" +code "and now the admin pages open" 200 $alice "$BASE/admin" + +echo "== repositories" +curl -s $alice "$BASE/admin" > "$WORK/p" +code "a repository in a section" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")§ion=infra&name=dns&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members" \ + "$BASE/admin/repo/create" +code "a public repository" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")&name=notes&desc=Notes&visibility=public&issues=disabled&wiki=disabled" \ + "$BASE/admin/repo/create" + +# Not an exact list: gc changes the shape over a repository's life. What must +# never appear is a file the forge wrote. +docker exec "$NAME" ls -A /var/lib/git-forge/repos/infra/dns.git | sort > "$WORK/bare" +grep -qvE '^(HEAD|ORIG_HEAD|branches|config|description|hooks|info|logs|objects|packed-refs|refs|shallow|worktrees)$' "$WORK/bare" && + no "the repository holds something that is not git's: $(tr '\n' ' ' < "$WORK/bare")" +ok "the repository holds nothing but git's own files" +docker exec "$NAME" cat /var/lib/git-forge/repos/infra/dns.git/config > "$WORK/repoconfig" +lack "and nothing of the forge's in its config" "unpackLimit" "$WORK/repoconfig" + +docker exec "$NAME" git -C /var/lib/git-forge/config log --format='%an %s' > "$WORK/audit" +want "the change is in the audit log, authored by the admin" "alice repos: create infra/dns" "$WORK/audit" + +echo "== a second account, through the one time link" +curl -s $alice "$BASE/admin" > "$WORK/p" +curl -s $alice -d "csrf=$(csrf "$WORK/p")&name=bob&email=bob@example.com&code=$(totp)" "$BASE/admin/user/create" > "$WORK/made" +# Off the page as the template wrote it, so the separators are escaped. +LINK=$(sed -n 's|.*
\('"$BASE"'/set-password?[^<]*\)
.*|\1|p' "$WORK/made" | sed 's/&/\&/g') +[ -n "$LINK" ] || no "no set-password link on the admin page" +ok "creating an account shows a set-password link" +sleep 1 +docker logs "$NAME" > "$WORK/log" 2>&1 +want "mail is off, so the log names the message it dropped" 'dropped "\[git-forge\] set a password for bob"' "$WORK/log" +lack "and never carries the link" "set-password?" "$WORK/log" +curl -s $bob "$LINK" > "$WORK/p" +code "following it sets the password and confirms the address" 303 $bob -o /dev/null \ + --data-urlencode "csrf=$(csrf "$WORK/p")" \ + --data-urlencode "name=$(field "$WORK/p" name)" \ + --data-urlencode "expires=$(field "$WORK/p" expires)" \ + --data-urlencode "t=$(field "$WORK/p" t)" \ + --data-urlencode "new=bobbobbob1" --data-urlencode "again=bobbobbob1" "$BASE/set-password" +code "and the link does not work twice" 404 "$LINK" + +echo "== grants and push tokens" +curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p" +code "bob is granted write" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")&account=bob&level=write&code=$(totp)" "$BASE/infra/dns/settings/grant" +sleep 1 +docker logs "$NAME" > "$WORK/log" 2>&1 +want "the grant change is a message, dropped by name" "access to infra/dns changed" "$WORK/log" + +curl -s $bob "$BASE/settings" > "$WORK/p" +curl -s $bob -d "csrf=$(csrf "$WORK/p")&repo=infra/dns" "$BASE/settings/tokens" > "$WORK/minted" +PUSH=$(sed -n 's/.*
\(.*\)<\/pre>.*/\1/p' "$WORK/minted")
+[ -n "$PUSH" ] || no "no push token was minted"
+ok "bob mints a push token"
+
+echo "== git over http"
+cd "$WORK"
+# 2>/dev/null only silences git's note about the repository being empty; a
+# clone that actually fails still stops the script.
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.git" dns 2>/dev/null
+printf 'example.com. IN A 192.0.2.1\n' > dns/zone.txt
+printf 'package main\n\n// the zone loader\nfunc main() { n := 42; _ = n }\n' > dns/load.go
+printf '# infra/dns\n\nThe *zones*.\n' > dns/README.md
+# A page that would like to run on this origin must come back as text.
+printf '\n' > dns/page.html
+git -C dns add -A
+git -C dns commit -qm "dns: add a zone"
+git -C dns push -q origin master
+ok "push over http with the token"
+
+git clone -q "$BASE/notes.git" notes 2>/dev/null
+ok "a public repository clones anonymously"
+git clone -q "$BASE/infra/dns.git" nope 2>"$WORK/refused" && no "a private repository cloned anonymously"
+code "a private repository refuses anonymous access with 401" 401 "$BASE/infra/dns.git/info/refs?service=git-upload-pack"
+
+echo "== issues"
+curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p"
+curl -s $alice -o /dev/null -d "csrf=$(csrf "$WORK/p")&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members&labels=bug
+docs" "$BASE/infra/dns/settings"
+curl -s $alice "$BASE/infra/dns/issues/new" > "$WORK/p"
+code "alice opens an issue" 303 $alice -o /dev/null \
+	--data-urlencode "csrf=$(csrf "$WORK/p")" \
+	--data-urlencode "title=Zone transfer fails on the secondary" \
+	--data-urlencode "body=The **secondary** does not answer AXFR." \
+	--data-urlencode "label=bug" "$BASE/infra/dns/issues"
+curl -s $alice "$BASE/infra/dns/issues/1" > "$WORK/issue"
+want "the issue renders its markdown" "secondary" "$WORK/issue"
+want "and its label" "bug" "$WORK/issue"
+code "alice comments" 303 $alice -o /dev/null \
+	--data-urlencode "csrf=$(csrf "$WORK/issue")" \
+	--data-urlencode "body=Confirmed on ns2 as well." "$BASE/infra/dns/issues/1/comment"
+curl -s $alice "$BASE/infra/dns/issues?q=ns2" > "$WORK/search"
+want "search finds it through the comment" "Zone transfer fails" "$WORK/search"
+
+sleep 1
+docker logs "$NAME" > "$WORK/log" 2>&1
+want "the new issue is a message to the grantee" "infra/dns#1 opened" "$WORK/log"
+
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.issues.git" issues
+git -C issues show master:issues/1.json | python3 -m json.tool > /dev/null || no "the tracker's json does not parse"
+ok "the tracker clones and its json parses"
+git -C issues commit -q --allow-empty -m "issues: hand written"
+git -C issues push origin master > "$WORK/pushfail" 2>&1 && no "the tracker accepted a push"
+want "the tracker refuses a push, with a reason" "written by the forge" "$WORK/pushfail"
+
+echo "== wiki"
+curl -s $alice "$BASE/infra/dns/wiki?edit" > "$WORK/p"
+curl -s $alice -o /dev/null --data-urlencode "csrf=$(csrf "$WORK/p")" --data-urlencode "base=" \
+	--data-urlencode "body=# Zones
+
+Written in the browser." --data-urlencode "action=save" "$BASE/infra/dns/wiki/"
+curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p"
+want "a wiki page written in the browser renders" "Written in the browser." "$WORK/p"
+
+git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.wiki.git" wiki
+printf '# Zones\n\nEdited by push.\n' > wiki/index.md
+git -C wiki add -A
+git -C wiki commit -qm "wiki: edit by push"
+git -C wiki push -q origin master
+curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p"
+want "and a push to the wiki changes the rendered page" "Edited by push." "$WORK/p"
+
+echo "== browsing"
+curl -s "$BASE/cgit.css" > "$WORK/style"
+curl -s $alice "$BASE/infra/dns/" > "$WORK/summary"
+want "the forge's header is injected into a cgit page" 'id="header"' "$WORK/summary"
+want "with the description from repos.json" "Authoritative DNS zones" "$WORK/summary"
+want "and cgit's own tabs under it" "class='tabs'" "$WORK/summary"
+curl -s $alice "$BASE/infra/dns/issues" > "$WORK/forgepage"
+want "the same header is on the forge's own pages" 'id="header"' "$WORK/forgepage"
+
+# Without cgit's own stylesheet, at the path cgit links to, every page is
+# unstyled.
+code "the one stylesheet is served where cgit looks for it" 200 "$BASE/cgit.css"
+want "and carries the forge's as well as cgit's" "highlight's classes" "$WORK/style"
+want "and cgit's pages ask for it rather than for a second one" "/cgit.css" "$WORK/summary"
+lack "which is the only stylesheet there is" "static/forge.css" "$WORK/summary"
+# cgit has no tab called issues, so finding one means the forge's were injected
+# into cgit's own row rather than sitting in a second menu.
+want "the forge's sections are inside cgit's own tab row" ">issues" "$WORK/summary"
+lack "and there is only one tab row" "
//p' "$1" | + grep -o '>[a-z]*' | sed 's/>\(.*\)<\/a>/\1/' | tr '\n' ' ' +} +if [ "$(tablist "$WORK/summary")" != "$(tablist "$WORK/forgepage")" ]; then + printf 'FAIL the forge'"'"'s tab row has drifted from cgit'"'"'s\n cgit: %s\n forge: %s\n' \ + "$(tablist "$WORK/summary")" "$(tablist "$WORK/forgepage")" >&2 + exit 1 +fi +ok "the forge's copy of cgit's tab row still matches cgit's" +want "with the decorations that make a log line readable" "a.deco" "$WORK/style" + +curl -s $alice "$BASE/infra/dns/tree/load.go" > "$WORK/blob" +want "a source blob comes back with highlight's markup" 'class="hl ' "$WORK/blob" +curl -s $alice "$BASE/infra/dns/about/" > "$WORK/about" +want "and the about page through the markdown filter" "zones" "$WORK/about" +curl -s -D "$WORK/head" -o "$WORK/blobbody" $alice "$BASE/infra/dns/plain/page.html" +want "a blob comes back as text, never as something a browser runs" "text/plain" "$WORK/head" + +curl -s "$BASE/" > "$WORK/index" +want "the index shows a public repository to anonymous visitors" "notes" "$WORK/index" +want "with the same header on the index" 'id="header"' "$WORK/index" +want "and the columns it has" "Description" "$WORK/index" +lack "and hides a private one" "infra/dns" "$WORK/index" + +echo "== no javascript, and readable in lynx" +ROUTES="/ /login /reset /settings /admin /infra/dns/ /infra/dns/log/ \ +/infra/dns/tree/ /infra/dns/tree/load.go /infra/dns/refs/ /infra/dns/issues \ +/infra/dns/issues/1 /infra/dns/issues/new /infra/dns/wiki /infra/dns/wiki?edit \ +/infra/dns/settings /infra/dns.wiki/ /notes/ /nothing-here" +for route in $ROUTES; do + curl -s $alice "$BASE$route" > "$WORK/page" + grep -q ' "$WORK/text" 2>/dev/null || no "lynx could not render $route" + [ -s "$WORK/text" ] || no "$route renders empty in lynx" +done +ok "every route is script free and renders in lynx" + +lynx -dump -stdin -nolist < "$WORK/issue" | grep -q "Zone transfer fails on the secondary" || + no "the issue title is not readable in lynx" +ok "the issue reads correctly through lynx -dump" + +# The header says so to the browser too, on cgit's pages as much as the +# forge's. +curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/" +want "cgit's pages carry the script policy" "script-src 'none'" "$WORK/head" +curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/issues" +want "and so do the forge's" "script-src 'none'" "$WORK/head" + +echo "== one process to a datadir" +# timeout, so that a lock that is not taken fails this check rather than +# hanging it: without the lock the second process serves happily for ever. +timeout 30 docker run --rm -v "$VOLUME:/var/lib/git-forge" "$IMAGE" > "$WORK/second" 2>&1 && + no "a second process served a datadir that was already being served" +want "a second process refuses to start, and says why" "already being served" "$WORK/second" + +echo "== shutdown" +docker stop -t 10 "$NAME" >/dev/null +docker inspect -f '{{.State.ExitCode}}' "$NAME" | grep -qx 0 || no "SIGTERM did not exit zero" +ok "SIGTERM drains and exits zero" + +echo +echo "smoke: all checks passed" -- cgit v1.2.3