Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
224
lib/tarakan_web/git_http.ex
Normal file
224
lib/tarakan_web/git_http.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue