defmodule GitGudWeb.Plugs.GitSmartHttp do
@moduledoc """
Plug that serves `git fetch`/`git push` over HTTPS by proxying to the
bundled `git http-backend` CGI program.
Routes handled (all under `/:owner/:name.git/`):
* `GET /info/refs?service=git-upload-pack|git-receive-pack`
* `POST /git-upload-pack`
* `POST /git-receive-pack`
Auth model:
* Public repos: anonymous fetch is allowed; push always requires
auth.
* Private/internal: both directions require auth.
Push completion fires the on-disk `post-receive` hook (installed by
`GitGud.Repositories`), which then drives the cache backfill.
"""
@behaviour Plug
import Plug.Conn
alias GitGud.Accounts
alias GitGud.Repositories
alias GitGud.Repositories.Repository
alias GitGud.Workflows.RunnerJwt
@service_path_info ["info", "refs"]
@upload_pack "git-upload-pack"
@receive_pack "git-receive-pack"
@impl true
def init(opts), do: opts
@impl true
def call(%Plug.Conn{path_info: path_info} = conn, _opts) do
case split_repo_path(path_info) do
{:ok, owner, name, rest} ->
dispatch(conn, owner, name, rest)
:error ->
send_resp(conn, 404, "")
end
end
# /:owner/:name[.git]/<rest...> — the `.git` suffix is optional; git
# clients clone with or without it (GitRouter only routes real git
# service paths here, so a bare name is safe). Strip a trailing `.git`
# if present.
defp split_repo_path([owner, name | rest]) when owner != "" and name != "" do
{:ok, owner, String.replace_suffix(name, ".git", ""), rest}
end
defp split_repo_path(_), do: :error
defp dispatch(conn, owner, name, @service_path_info) do
service = conn.query_params["service"] || conn.params["service"]
handle_info_refs(conn, owner, name, service)
end
defp dispatch(conn, owner, name, [@upload_pack]),
do: handle_rpc(conn, owner, name, @upload_pack)
defp dispatch(conn, owner, name, [@receive_pack]),
do: handle_rpc(conn, owner, name, @receive_pack)
defp dispatch(conn, owner, name, ["info", "lfs", "objects", "batch"]),
do: handle_lfs_batch(conn, owner, name)
defp dispatch(conn, owner, name, ["info", "lfs", "verify"]),
do: handle_lfs_verify(conn, owner, name)
defp dispatch(conn, _owner, _name, _), do: send_resp(conn, 404, "")
defp handle_info_refs(conn, owner, name, service)
when service in [@upload_pack, @receive_pack] do
with {:ok, repo} <- find_repo(owner, name),
{:ok, conn} <- authorize(conn, repo, service) do
# Don't send_chunked here — the CGI emits its own status + headers
# (Content-Type, etc.) which stream_cgi/relay_response apply before
# chunking. Chunking first makes apply_cgi_headers raise
# Plug.Conn.AlreadySentError and resets the HTTP/2 stream.
conn
|> put_resp_header("cache-control", "no-cache")
|> stream_cgi(repo, ["http-backend"], cgi_env(conn, repo, service, "GET", true))
else
{:error, :not_found} -> send_resp(conn, 404, "")
{:error, :unauthorized} -> request_auth(conn)
{:error, :forbidden} -> send_resp(conn, 403, "")
end
end
defp handle_info_refs(conn, _owner, _name, _), do: send_resp(conn, 400, "")
defp handle_rpc(conn, owner, name, service) do
with {:ok, repo} <- find_repo(owner, name),
{:ok, conn} <- authorize(conn, repo, service) do
# See handle_info_refs: let the CGI's headers land before chunking.
conn
|> put_resp_header("cache-control", "no-cache")
|> stream_cgi(repo, ["http-backend"], cgi_env(conn, repo, service, "POST", false))
else
{:error, :not_found} -> send_resp(conn, 404, "")
{:error, :unauthorized} -> request_auth(conn)
{:error, :forbidden} -> send_resp(conn, 403, "")
end
end
# ── LFS ──────────────────────────────────────────────────────────────
defp handle_lfs_batch(%Plug.Conn{method: "POST"} = conn, owner, name) do
with {:ok, repo} <- find_repo(owner, name),
{:ok, doc, conn} <- read_lfs_body(conn),
{:ok, conn} <- authorize_lfs(conn, repo, doc) do
operation = Map.get(doc, "operation", "download")
objects = Map.get(doc, "objects", [])
response = GitGud.Lfs.batch(repo, operation, objects)
conn
|> put_resp_content_type("application/vnd.git-lfs+json", nil)
|> send_resp(200, Jason.encode!(response))
else
{:error, :not_found} -> send_resp(conn, 404, "")
{:error, :unauthorized} -> request_auth(conn)
{:error, :forbidden} -> send_resp(conn, 403, "")
{:error, :bad_request} -> send_resp(conn, 400, "")
end
end
defp handle_lfs_batch(conn, _, _), do: send_resp(conn, 405, "")
defp handle_lfs_verify(%Plug.Conn{method: "POST"} = conn, owner, name) do
with {:ok, repo} <- find_repo(owner, name),
{:ok, doc, conn} <- read_lfs_body(conn),
{:ok, conn} <- authorize_lfs(conn, repo, %{"operation" => "upload"}),
%{"oid" => oid, "size" => size} when is_binary(oid) and is_integer(size) <- doc,
{:ok, _} <- GitGud.Lfs.verify(repo, oid, size) do
conn
|> put_resp_content_type("application/vnd.git-lfs+json", nil)
|> send_resp(200, "{}")
else
{:error, :not_found} -> send_resp(conn, 404, "")
{:error, :unauthorized} -> request_auth(conn)
{:error, :forbidden} -> send_resp(conn, 403, "")
{:error, :unknown_oid} -> send_resp(conn, 404, "")
{:error, :size_mismatch} -> send_resp(conn, 422, "size_mismatch")
_ -> send_resp(conn, 400, "")
end
end
defp handle_lfs_verify(conn, _, _), do: send_resp(conn, 405, "")
# LFS auth: download maps to upload-pack (read), upload to receive-pack (write).
defp authorize_lfs(conn, repo, %{"operation" => "upload"}),
do: authorize(conn, repo, @receive_pack)
defp authorize_lfs(conn, repo, _),
do: authorize(conn, repo, @upload_pack)
defp read_lfs_body(conn) do
case Plug.Conn.read_body(conn, length: 10_000_000) do
{:ok, body, conn} ->
case Jason.decode(body) do
{:ok, doc} -> {:ok, doc, conn}
_ -> {:error, :bad_request}
end
_ ->
{:error, :bad_request}
end
end
# ── repo lookup ──────────────────────────────────────────────────────
defp find_repo(owner_handle, name) do
try do
{:ok, Repositories.get_repository_by_path!(owner_handle, name)}
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
end
# ── authn / authz ────────────────────────────────────────────────────
defp authorize(conn, %Repository{visibility: "public"} = _repo, @upload_pack), do: {:ok, conn}
defp authorize(conn, %Repository{} = repo, service) do
case authenticate(conn) do
{:user, user} ->
if can?(user, repo, service),
do: {:ok, assign(conn, :git_pusher_id, user.id)},
else: {:error, :forbidden}
# Per-job runner token (what `actions/checkout` sends as
# `x-access-token:<github.token>`). Read-only, and only its own
# repo — enough to check the source out, nothing more.
{:job, pid} ->
if service == @upload_pack and pid == repo.id,
do: {:ok, conn},
else: {:error, :forbidden}
:error ->
{:error, :unauthorized}
end
end
# Resolve Basic-auth credentials to a principal:
# {:user, %User{}} — forge password OR a Personal Access Token
# {:job, repo_id} — a per-job RunnerJwt
# :error
# The token is carried in the password field (the username is whatever
# the client sent — e.g. `x-access-token` — and is ignored for tokens).
defp authenticate(conn) do
with [hdr | _] <- get_req_header(conn, "authorization"),
[scheme, b64] <- String.split(hdr, " ", parts: 2),
true <- String.downcase(scheme) == "basic",
{:ok, decoded} <- Base.decode64(b64),
[username, secret] <- String.split(decoded, ":", parts: 2) do
case RunnerJwt.verify(secret) do
{:ok, %{"pid" => pid}} ->
{:job, pid}
_ ->
case Accounts.verify_personal_access_token(secret) do
{:ok, user, _scopes} ->
{:user, user}
:error ->
case Accounts.get_user_by_email_and_password(username, secret) do
%_{} = user -> {:user, user}
_ -> :error
end
end
end
else
_ -> :error
end
end
# Owner can do anything. Write access requires ownership for now;
# team-based perms land in a later phase.
defp can?(user, %Repository{owner_id: owner_id}, @receive_pack),
do: user.id == owner_id
defp can?(user, %Repository{owner_id: owner_id, visibility: vis}, @upload_pack) do
user.id == owner_id or vis in ["public", "internal"]
end
defp request_auth(conn) do
conn
|> put_resp_header("www-authenticate", ~s(Basic realm="GitGud"))
|> send_resp(401, "")
end
# ── CGI streaming ────────────────────────────────────────────────────
defp cgi_env(conn, %Repository{disk_path: path}, service, method, info_refs?) do
base = [
{"GIT_PROJECT_ROOT", path},
{"GIT_HTTP_EXPORT_ALL", "1"},
{"PATH_INFO", path_info(service, info_refs?)},
{"REQUEST_METHOD", method},
{"QUERY_STRING", conn.query_string || ""},
{"CONTENT_TYPE", first_header(conn, "content-type")},
# git http-backend reads the POST body (the upload-pack/receive-pack
# request) as a CGI script: it consumes CONTENT_LENGTH bytes from
# stdin. Without this it reads nothing, upload-pack gets an empty
# request, and the fetch returns an empty pack ("remote end hung up
# unexpectedly"). HTTP_CONTENT_ENCODING lets it inflate a gzipped
# request body. Both are nil on GET (info/refs) and get dropped.
{"CONTENT_LENGTH", first_header(conn, "content-length")},
{"HTTP_CONTENT_ENCODING", first_header(conn, "content-encoding")},
{"REMOTE_USER", "gitgud"},
{"REMOTE_ADDR", remote_ip_string(conn)},
# Attribute the runs this push triggers to the authenticated
# pusher (set by `authorize/3`). nil for anonymous reads → dropped.
{"GITGUD_PUSHER_ID", pusher_id_env(conn)}
]
base
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
end
defp pusher_id_env(conn) do
case conn.assigns[:git_pusher_id] do
id when is_integer(id) -> Integer.to_string(id)
_ -> nil
end
end
defp path_info(_service, true), do: "/info/refs"
defp path_info(service, false), do: "/" <> service
defp first_header(conn, name) do
case get_req_header(conn, name) do
[v | _] -> v
_ -> nil
end
end
defp remote_ip_string(%Plug.Conn{remote_ip: ip}), do: :inet.ntoa(ip) |> to_string()
@doc false
def stream_cgi(conn, _repo, _args, env) do
port =
Port.open(
{:spawn_executable, System.find_executable("git")},
[
:binary,
:stream,
:exit_status,
:use_stdio,
# Do NOT merge stderr into stdout: stdout carries the binary git
# protocol (pack stream); any stderr text would corrupt it and
# the client errors with "remote end hung up" / "bad pack".
# http-backend's stderr lands on the pod log instead.
args: ["http-backend"],
env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
]
)
{:ok, conn} = stream_request_body(conn, port)
Port.command(port, <<>>)
relay_response(conn, port, :headers, %{})
end
defp stream_request_body(conn, port) do
case read_body(conn, length: 1_000_000, read_length: 1_000_000) do
{:ok, body, conn} ->
if byte_size(body) > 0, do: Port.command(port, body)
{:ok, conn}
{:more, body, conn} ->
if byte_size(body) > 0, do: Port.command(port, body)
stream_request_body(conn, port)
{:error, _} = err ->
err
end
end
# The CGI emits headers terminated by a blank line, then the body. We
# buffer until the header block is complete, lift relevant headers
# onto the conn, then stream the body chunks straight through.
defp relay_response(conn, port, :headers, buf) do
receive do
{^port, {:data, chunk}} ->
accumulated = Map.get(buf, :acc, <<>>) <> chunk
case :binary.split(accumulated, "\r\n\r\n") do
[headers, rest] ->
conn = apply_cgi_headers(conn, headers)
relay_response_send(conn, port, rest)
[_no_split] ->
relay_response(conn, port, :headers, Map.put(buf, :acc, accumulated))
end
{^port, {:exit_status, _}} ->
# CGI exited before emitting a header block — send a real error
# instead of returning an unsent conn (Bandit would reject it).
send_resp(conn, 500, "git http-backend produced no output")
after
60_000 ->
send_resp(conn, 504, "git http-backend timed out")
end
end
defp relay_response(conn, port, :body, _) do
receive do
{^port, {:data, chunk}} ->
{:ok, conn} = Plug.Conn.chunk(conn, chunk)
relay_response(conn, port, :body, nil)
{^port, {:exit_status, _}} ->
conn
after
60_000 -> conn
end
end
defp relay_response_send(conn, port, initial) do
# Now that the CGI's status + headers are on the conn, start the
# chunked response (using the CGI status if it set one, else 200),
# then stream the body.
conn = send_chunked(conn, conn.status || 200)
conn =
if byte_size(initial) > 0 do
{:ok, conn} = Plug.Conn.chunk(conn, initial)
conn
else
conn
end
relay_response(conn, port, :body, nil)
end
defp apply_cgi_headers(conn, headers_bin) do
headers_bin
|> String.split("\r\n", trim: true)
|> Enum.reduce(conn, fn line, c ->
case String.split(line, ":", parts: 2) do
[k, v] ->
k_lc = k |> String.trim() |> String.downcase()
v = String.trim(v)
if k_lc in ["status"], do: put_status_from_cgi(c, v), else: put_resp_header(c, k_lc, v)
_ ->
c
end
end)
end
defp put_status_from_cgi(conn, status_line) do
case Integer.parse(status_line) do
{code, _} -> %{conn | status: code}
_ -> conn
end
end
end
14.5 KiB · text
5af55d9