Модуль 2. Git Smart HTTP — exec.Command с нативным стримингом

Цель модуля: реализовать GitHandler на Go, который стримит бинарный packfile (100 МБ–1 ГБ) через exec.Command("git-http-backend") + io.Copy, без буферизации в память. Настроить CGI-окружение (GIT_PROJECT_ROOT, PATH_INFO, REQUEST_METHOD, QUERY_STRING), закрыть Directory Traversal и зафиксировать решение в ADR.

Перед началом: Модуль 1 (архитектура, boilerplate, bare-репозитории) Инструменты: Go os/exec, io, net/http, path/filepath, git-http-backend (CGI), k6 Трудоёмкость: ~5 ч (теория 1.5 ч · практика 3 ч · ADR 0.5 ч)

🔥 Инцидент: 500 Internal Server Error на первом clone

Студент поднял сервер (Модуль 1), зарегистрировал роут *.git/* и сразу попробовал клонировать:

bash
git clone http://localhost:8080/test.git

В ответ — fatal: unable to access 'http://localhost:8080/test.git/': The requested URL returned error: 500. В логах Go-сервера:

git-http-backend: fatal: No GIT_PROJECT_ROOT specified

На Symfony вам пришлось настраивать Nginx → CGI, отключать fastcgi_request_buffering, бороться с OOM. На Go стрим — тривиальный (cmd.Stdin = r.Body и io.Copy(w, cmd.Stdout)), но есть подвох: git-http-backend — это CGI-скрипт, и он читает конфигурацию не из аргументов, а из переменных окружения. Передали пустое окружение — 500. Не передали PATH_INFO — 404. Не передали REQUEST_METHOD — 500. Забыли QUERY_STRING — info/refs не работает.

На PHP переменные CGI прокидывает Nginx через fastcgi_param — это инфраструктура, а не код. На Go вы обязаны собрать окружение руками в cmd.Env. Это и есть «Go лучше PHP для стриминга, но CGI-окружение — это боль, которую нельзя пропустить».

📖 Теория: exec.Command, CGI-окружение, нативный стриминг, валидация имён

2.1. Проблема: PHP и большие тела запроса (которой на Go нет)

В курсе «Git Hosting на Symfony» Модуль 2 начинается с инцидента OOM: Request->getContent() загружает packfile в память, воркер падает на 500 МБ. На Go этой проблемы не существует — http.ResponseWriter и *http.Request.Body это io.Writer/io.Reader, и стриминг делается одной строкой io.Copy. Архитектурно Go не материализует тело запроса/ответа в память: r.Body — это поток, w — это поток.

Единственное, что нужно Go-разработчику для стриминга гигабайт — НЕ вызывать io.ReadAll(r.Body) (что скопирует всё в []byte) и НЕ json.NewDecoder(r.Body).Decode(&v) (та же ошибка). Использовать io.Copy напрямую.

2.2. Решение: exec.Command("git-http-backend") + io.Copy

git-http-backend — официальный CGI-скрипт из состава Git (/usr/lib/git-core/git-http-backend), стримящий packfile порциями через стандартный ввод/вывод CGI. На Go вызов выглядит так:

go
// internal/handler/git.go
const gitBackend = "/usr/lib/git-core/git-http-backend"

func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    repoName := h.extractRepo(r.URL.Path)        // "test" из "/test.git/info/refs"
    h.authorize(r, repoName)                     // (Модуль 7) — Bearer/ACL
    h.assertSafeRepo(repoName)                    // Directory Traversal (2.5)

    cmd := exec.Command(gitBackend)
    cmd.Env = append(os.Environ(),
        "GIT_PROJECT_ROOT="+h.root,               // /var/git
        "GIT_HTTP_EXPORT_ALL=1",
        "PATH_INFO=/"+repoName+".git"+r.URL.Path, // /test.git/info/refs
        "REQUEST_METHOD="+r.Method,               // GET / POST
        "QUERY_STRING="+r.URL.RawQuery,           // service=git-upload-pack
        "CONTENT_TYPE="+r.Header.Get("Content-Type"),
    )

    cmd.Stdin = r.Body                            // HTTP request body → process stdin (push)
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        http.Error(w, "git backend failed", http.StatusInternalServerError)
        return
    }
    cmd.Stderr = h.errorLog                        // лог ошибок git

    if err := cmd.Start(); err != nil {
        http.Error(w, "git backend start failed", http.StatusInternalServerError)
        return
    }

    // Стрим stdout процесса в HTTP-ответ (clone/fetch) — ноль аллокаций, буфер 32KB
    io.Copy(w, stdout)
    cmd.Wait()
}

Ключевые моменты:

  • cmd.Stdin = r.Body — тело HTTP-запроса (POST /git-receive-pack) напрямую течёт в stdin git-http-backend. Память не растёт.
  • cmd.StdoutPipe() + io.Copy(w, stdout) — stdout процесса порциями льётся в http.ResponseWriter. io.Copy использует буфер 32 КБ и не аллоцирует под всё тело.
  • cmd.Wait() в конце — дождаться завершения процесса, проверить exit code.

На Symfony то же самое — это proc_open + StreamedResponse + fread($pipes[1], 8192) в цикле. На Go — две строки (io.Copy) и ноль ручного управления буфером. Но окружение CGI нужно собирать руками в обоих случаях.

2.3. Настройка CGI-окружения: 5 переменных, без которых ничего не работает

git-http-backend читает конфигурацию из os.Environ(). Пять переменных обязательны:

ПеременнаяЗначениеПример
GIT_PROJECT_ROOTкорень bare-репозиториев/var/git
PATH_INFOпуть внутри root (= repo + URL)/test.git/info/refs
REQUEST_METHODметод HTTPGET (info/refs) / POST (upload-pack)
QUERY_STRINGquery string URLservice=git-upload-pack
CONTENT_TYPEзаголовок Content-Typeapplication/x-git-upload-pack-request

Дополнительно — GIT_HTTP_EXPORT_ALL=1, иначе git-http-backend требует файл git-daemon-export-ok в каждом репозитории (как git daemon).

Антипаттерн — передать cmd.Env = os.Environ() без дополнений: git-http-backend не найдёт GIT_PROJECT_ROOT и упадёт с 500. На Symfony эти переменные прокидывал Nginx (fastcgi_param), и про них легко забыть при миграции на Go. Обязательно — модульный тест на стриминг с заполненным окружением.

2.4. Content-Type: git-http-backend отдаёт специфичные типы

git-http-backend отдаёт не application/octet-stream, а специализированные Content-Type:

  • application/x-git-upload-pack-advertisement — ответ на info/refs?service=git-upload-pack
  • application/x-git-upload-pack-result — результат POST /git-upload-pack
  • application/x-git-receive-pack-result — результат POST /git-receive-pack

Сам git-http-backend пишет Content-Type в stdout перед телом, и io.Copy передаст эти заголовки как часть тела — это сломает HTTP-ответ. Решение: git-http-backend знает CGI-соглашение и выводит HTTP-заголовки (Content-Type: первой строкой), а Go должен их распарсить и выставить через w.Header() перед io.Copy.

Упрощённо (без полного CGI-парсера): первые строки stdout — заголовки \r\n, потом пустая строка, потом тело. Парсер:

go
// Читаем заголовки CGI из stdout, выставляем в w.Header()
scanner := bufio.NewReader(stdout)
for {
    line, err := scanner.ReadString('\n')
    if err != nil || strings.TrimSpace(line) == "" {
        break // пустая строка — конец заголовков
    }
    if idx := strings.Index(line, ":"); idx > 0 {
        w.Header().Set(strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]))
    }
}
// Далее — io.Copy(w, scanner) для тела

На Symfony об этом заботился StreamedResponse + header(). На Go CGI-парсинг — ваша ответственность. Альтернатива — библиотека github.com/icholy/cgi, но в курсе мы пишем минимальный парсер руками, чтобы понять механику.

2.5. Directory Traversal: валидация имён репозиториев

git-http-backend обращается к ФС по пути GIT_PROJECT_ROOT + PATH_INFO. Без валидации имени это открывает Directory Traversal — GET /..%2f..%2fetc%2fpasswd.git/info/refs раскроется в /var/git/../../etc/passwd.

Защита на Go — три уровня в assertSafeRepo():

go
func (h *GitHandler) assertSafeRepo(name string) error {
    // (1) Whitelist-регекс: никаких /, \, .., %2f
    if !repoNameRe.MatchString(name) {
        return fmt.Errorf("invalid repository name")
    }
    // (2) Clean + проверка, что путь внутри /var/git
    clean := filepath.Clean(filepath.Join(h.root, name+".git"))
    rel, err := filepath.Rel(h.root, clean)
    if err != nil || strings.HasPrefix(rel, "..") {
        return fmt.Errorf("directory traversal detected")
    }
    return nil
}

var repoNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)

На Go filepath.Clean делает нормализацию (a/../b → b), а filepath.Rel проверяет, что путь не выходит за корень. Это надёжнее realpath() из PHP, потому что Go не ходит на диск — работает чисто с строками. Но в продакшене дополнительно проверяют os.Stat на существование и запрет симлинков в GIT_PROJECT_ROOT.

2.6. Сравнение с Symfony: чего на Go НЕТ

| Что на Symfony | Что на Go | |---| | proc_open + fread в цикле по 8192 байт | io.Copy(w, cmd.StdoutPipe()) — 2 строки | | StreamedResponse(function () { ... }) | http.ResponseWriter — поток из коробки | | fastcgi_request_buffering off (Nginx config) | Не нужно — Go не буферизует r.Body | | client_max_body_size 0 (Nginx config) | Не нужно — r.Body читается лениво, лимита нет | | fastcgi_buffering off (Nginx config) | Не нужно — io.Copy не буферизует | | auth_request subrequest для авторизации | обычный middleware: h.authorize(r, repo) | | OCI-образ с PHP-FPM + Nginx + spawn-fcgi | один scratch-образ с Go-бинарником + git |

На Symfony 60 строк Nginx-конфига + PHP-адаптер + spawn-fcgi — это «модуль 2». На Go это exec.Command + io.Copy + парсер CGI-заголовков — всего ~80 строк Go-кода, без инфраструктуры. Это и есть «Go лучше PHP для Git-хостинга»: меньше слоёв, меньше настраиваемых директив, меньше точек отказа.

2.7. Компромиссы (Trade-offs)

ПодходПлюсМинусКогда выбирать
exec.Command + io.Copyнативный стрим, 0 аллокаций, длинный процессsubprocess на каждый запрос — overhead forkвсегда для Git Smart HTTP (базовый подход)
Pure Go (libgit2 bindings)нет subprocess, ниже latencyсложнее — C-зависимость, особый API Githigh-perf, экспериментально
io.ReadAll(r.Body) (антипаттерн)простотаOOM на packfile > 16MBNEVER для Git
Ручной CGI-парсер заголовковконтроль, понимание механики20 строк кодаMVP, курс
Библиотека icholy/cgiменьше кодалишняя зависимостьпродакшен

Вывод: стриминг через exec.Command на Go — не оптимизация, а единственный идиоматичный способ пропустить гигабайтный packfile через Go-процесс. Цена — fork-overhead на каждый запрос (решается Bulkhead в Модуле 3) и ручной CGI-парсинг заголовков.


🛠 Практические задания

Задача 1: Зарегистрировать Git-роуты и поднять GitHandler

В cmd/server/main.go добавить роут для *.git/* и реализовать GitHandler по шаблону из 2.2:

go
mux := http.NewServeMux()
mux.HandleFunc("/api/", apiHandler)
mux.HandleFunc("/api/health", healthHandler)
mux.Handle("/", gitHandler)      // fallback для *.git/* — должен идти после API

Проверка: curl http://localhost:8080/test.git/info/refs?service=git-upload-pack — должен вернуть 200 с Content-Type: application/x-git-upload-pack-advertisement.

Задача 2: Создать bare-репозиторий и сделать push/clone

bash
docker compose exec server bash -lc 'cd /var/git && git init --bare demo.git && chown -R appuser:appuser demo.git && chmod -R 0755 demo.git'

git clone http://localhost:8080/demo.git && cd demo && echo "hello" > README.md
git add . && git commit -m "init" && git push origin main
git clone http://localhost:8080/demo.git /tmp/demo2   # проверить, что push дошёл

Задача 3: Directory Traversal-тесты и тест на стриминг 100 МБ

Реализовать assertSafeRepo (2.5) и покрыть таблицей тестов: /etc/passwd, ..%2f, a/../b, repo;rm -rf, валидный my-repo_1.0. Локально — стрим 100 МБ репозитория:

bash
cd /tmp && git clone http://localhost:8080/big-repo.git
# во время стрима следим за RSS go-процесса: pprof или /metrics

runtime.MemStats в Go покажет, что память процесса не растёт выше ~10 МБ независимо от размера packfile — стриминг работает честно.

✅ Готово, когда:

  • git clone http://localhost:8080/demo.git → 200, а не 404
  • git push проходит, повторный clone содержит закоммиченные данные
  • Clone 100 МБ не повышает RSS Go-процесса выше ~10 МБ (стриминг работает, io.Copy)
  • Directory Traversal-тесты: .., %2f, ;, пробелы — отклоняются с 400
  • /var/git — appuser:appuser, права 0755 (без 0777); ADR-GG2 написан

🧩 Проверь себя

1. Что делает cmd.Stdin = r.Body в GitHandler?
2. Главное отличие стриминга на Go от Symfony?
3. Какой минимум защиты от Directory Traversal в имени репозитория на Go?
4. Какие 5 переменных окружения обязательно нужны git-http-backend?
5. Почему io.Copy(w, cmd.StdoutPipe()) не вызывает OOM на 1 ГБ packfile?

🔗 Мостик к Модулю 3

Clone работает, push проходит, память Go-процесса не растёт. Но теперь всплывает новая беда: 100 одновременных клонов репозиториев по 1 ГБ забивают все goroutines. Go не ограничивает количество goroutines автоматически — их может быть 100 000. CPU и IOPS диска исчерпаны, а API (просмотр кода) тормозит, потому что планировщик не успевает переключаться. На Symfony — два пула FPM (Модуль 3 Symfony-курса). На Go — одна конструкция: chan struct{}. В Модуле 3 мы применим паттерн Bulkhead (arch m5): gitSem := make(chan struct{}, 5) — максимум 5 concurrent git-операций, остальное → 503, но API продолжает отвечать за < 50 мс.