Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
224 lines
7.2 KiB
Elixir
224 lines
7.2 KiB
Elixir
defmodule TarakanWeb.GitHTTP do
|
|
@moduledoc """
|
|
Git smart-HTTP endpoint for Tarakan-hosted repositories.
|
|
|
|
Serves `https://…/:owner/:name.git` - `info/refs` advertisements plus the
|
|
`git-upload-pack` (clone/fetch) and `git-receive-pack` (push) RPCs. Mounted
|
|
in the endpoint ahead of `Plug.Parsers` so request bodies stream straight
|
|
to the git subprocess instead of being buffered and size-capped as form
|
|
input; every other request passes through to the router untouched.
|
|
|
|
Authentication is HTTP Basic with a scoped API credential as the password;
|
|
the username is ignored. Anonymous clients may clone listed repositories.
|
|
Authorization always goes through `Tarakan.Policy` (`:clone_repository`,
|
|
`:push_repository`). Failures follow the platform's anti-oracle rule: an
|
|
unauthenticated client gets a 401 challenge so git prompts for
|
|
credentials; an authenticated-but-unauthorized client gets the same 404 as
|
|
a repository that does not exist.
|
|
|
|
Rate limits: anonymous traffic buckets per client IP (IPv6 truncated to
|
|
its /64 prefix), authenticated traffic per account, and failed Basic
|
|
authentications per client IP against a smaller budget whose overflow is
|
|
answered with 429 rather than another 401 challenge.
|
|
"""
|
|
|
|
@behaviour Plug
|
|
|
|
import Plug.Conn
|
|
|
|
alias Tarakan.Accounts
|
|
alias Tarakan.HostedRepositories
|
|
alias Tarakan.Policy
|
|
alias Tarakan.RateLimiter
|
|
alias Tarakan.Repositories.Repository
|
|
alias TarakanWeb.GitHTTP.Service
|
|
alias TarakanWeb.Plugs.ClientIp
|
|
|
|
@services ~w(git-upload-pack git-receive-pack)
|
|
|
|
@impl true
|
|
def init(opts), do: opts
|
|
|
|
@impl true
|
|
def call(%Plug.Conn{path_info: [owner, name_dot_git | rest]} = conn, _opts) do
|
|
case repository_name(name_dot_git) do
|
|
{:ok, name} -> dispatch(conn, owner, name, rest)
|
|
:error -> conn
|
|
end
|
|
end
|
|
|
|
def call(conn, _opts), do: conn
|
|
|
|
defp repository_name(segment) do
|
|
case String.split(segment, ".git", parts: 2) do
|
|
[name, ""] when name != "" -> {:ok, name}
|
|
_other -> :error
|
|
end
|
|
end
|
|
|
|
defp dispatch(%{method: "GET"} = conn, owner, name, ["info", "refs"]) do
|
|
conn = fetch_query_params(conn)
|
|
|
|
case conn.query_params["service"] do
|
|
service when service in @services ->
|
|
serve(conn, owner, name, service, :advertise)
|
|
|
|
_missing ->
|
|
# The dumb-HTTP protocol is not served; git falls back here only
|
|
# when it cannot speak smart HTTP.
|
|
halt_plain(conn, 400, "smart HTTP only")
|
|
end
|
|
end
|
|
|
|
defp dispatch(%{method: "POST"} = conn, owner, name, [service])
|
|
when service in @services do
|
|
serve(conn, owner, name, service, :rpc)
|
|
end
|
|
|
|
defp dispatch(conn, _owner, _name, _rest) do
|
|
halt_plain(conn, 404, "not found")
|
|
end
|
|
|
|
defp serve(conn, owner, name, service, mode) do
|
|
with {:ok, conn, scope} <- authenticate(conn),
|
|
:ok <- rate_limit(conn, scope),
|
|
%Repository{} = repository <- HostedRepositories.resolve(owner, name),
|
|
:ok <- authorize(scope, service, repository) do
|
|
:telemetry.execute([:tarakan, :git, :http, :request], %{count: 1}, %{
|
|
service: service,
|
|
mode: mode,
|
|
repository_id: repository.id,
|
|
authenticated: scope != nil
|
|
})
|
|
|
|
case mode do
|
|
:advertise -> Service.advertise_refs(conn, repository, service)
|
|
:rpc -> Service.rpc(conn, repository, service, scope)
|
|
end
|
|
else
|
|
{:unauthenticated, conn} ->
|
|
challenge(conn)
|
|
|
|
{:error, :rate_limited} ->
|
|
halt_plain(conn, 429, "too many requests")
|
|
|
|
# An unauthenticated client that fails authorization is challenged so
|
|
# git retries with credentials; anything else is indistinguishable
|
|
# from a missing repository.
|
|
{:error, :unauthorized, nil} ->
|
|
challenge(conn)
|
|
|
|
{:error, :over_quota} ->
|
|
halt_plain(conn, 413, "repository is over its storage quota")
|
|
|
|
_denied ->
|
|
halt_plain(conn, 404, "not found")
|
|
end
|
|
end
|
|
|
|
defp authorize(scope, "git-upload-pack", repository) do
|
|
case Policy.authorize(scope, :clone_repository, repository) do
|
|
:ok -> :ok
|
|
{:error, :unauthorized} -> {:error, :unauthorized, scope}
|
|
end
|
|
end
|
|
|
|
# Authorization strictly precedes the quota check so an over-quota
|
|
# response can never disclose an invisible repository.
|
|
defp authorize(scope, "git-receive-pack", repository) do
|
|
case Policy.authorize(scope, :push_repository, repository) do
|
|
:ok -> ensure_not_over_quota(repository)
|
|
{:error, :unauthorized} -> {:error, :unauthorized, scope}
|
|
end
|
|
end
|
|
|
|
defp ensure_not_over_quota(repository) do
|
|
if HostedRepositories.over_quota?(repository), do: {:error, :over_quota}, else: :ok
|
|
end
|
|
|
|
# Basic auth with the API credential as password. A present-but-invalid
|
|
# credential is challenged rather than downgraded to anonymous. Failed
|
|
# attempts count against a small per-IP budget, so once an IP exceeds it
|
|
# the answer is a 429 instead of an endless 401 challenge.
|
|
defp authenticate(conn) do
|
|
case Plug.BasicAuth.parse_basic_auth(conn) do
|
|
{_user, password} ->
|
|
case Accounts.ApiCredentials.authenticate(password) do
|
|
{:ok, account, credential} ->
|
|
scope =
|
|
Accounts.scope_for_account(account,
|
|
token_id: credential.id,
|
|
token_scopes: credential.scopes,
|
|
token_repository_id: credential.repository_id,
|
|
authentication_method: :api_credential
|
|
)
|
|
|
|
{:ok, conn, scope}
|
|
|
|
:error ->
|
|
failed_authentication(conn)
|
|
end
|
|
|
|
:error ->
|
|
case get_req_header(conn, "authorization") do
|
|
[] -> {:ok, conn, nil}
|
|
_malformed -> failed_authentication(conn)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp failed_authentication(conn) do
|
|
{limit, window} = config(:auth_failure_rate_limit, {10, 60})
|
|
key = {:git_http_auth_ip, ClientIp.remote_ip_bucket(conn)}
|
|
|
|
case RateLimiter.check(key, limit, window) do
|
|
:ok -> {:unauthenticated, conn}
|
|
{:error, _reason, _retry_after} -> {:error, :rate_limited}
|
|
end
|
|
end
|
|
|
|
defp rate_limit(_conn, %{platform_role: role}) when role in ["admin", "moderator"], do: :ok
|
|
|
|
defp rate_limit(_conn, %{account: %{platform_role: role}})
|
|
when role in ["admin", "moderator"],
|
|
do: :ok
|
|
|
|
defp rate_limit(conn, scope) do
|
|
{key, limit, window} = rate_limit_bucket(conn, scope)
|
|
|
|
case RateLimiter.check(key, limit, window) do
|
|
:ok -> :ok
|
|
{:error, _reason, _retry_after} -> {:error, :rate_limited}
|
|
end
|
|
end
|
|
|
|
defp rate_limit_bucket(conn, nil) do
|
|
limits = config(:anonymous_rate_limit, {60, 60})
|
|
{limit, window} = limits
|
|
{{:git_http_ip, ClientIp.remote_ip_bucket(conn)}, limit, window}
|
|
end
|
|
|
|
defp rate_limit_bucket(_conn, scope) do
|
|
{limit, window} = config(:account_rate_limit, {240, 60})
|
|
{{:git_http_account, scope.account_id}, limit, window}
|
|
end
|
|
|
|
defp challenge(conn) do
|
|
conn
|
|
|> put_resp_header("www-authenticate", ~s(Basic realm="tarakan"))
|
|
|> halt_plain(401, "authentication required")
|
|
end
|
|
|
|
defp halt_plain(conn, status, message) do
|
|
conn
|
|
|> put_resp_content_type("text/plain")
|
|
|> send_resp(status, message)
|
|
|> halt()
|
|
end
|
|
|
|
defp config(key, default) do
|
|
:tarakan
|
|
|> Application.get_env(__MODULE__, [])
|
|
|> Keyword.get(key, default)
|
|
end
|
|
end
|