Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

224
lib/tarakan_web/git_http.ex Normal file
View file

@ -0,0 +1,224 @@
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