defmodule GitGudWeb.SshController do
@moduledoc """
Endpoint the SSH wrapper (`priv/scripts/gitgud-ssh`) POSTs to in
order to map an incoming SSH command to an on-disk repo path and an
authorization decision.
Request:
POST /_ssh/resolve (loopback only — bind in runtime.exs)
{"key_id": "12", "repo": "owner/name.git", "cmd": "git-upload-pack"}
Response is a single ASCII line consumed by `sh`:
OK /absolute/path/to/owner/name.git
DENY <reason>
Bind this endpoint to 127.0.0.1 in production (separate `Bandit`
instance, or use a reverse proxy) so it's never exposed to the
internet.
"""
use GitGudWeb, :controller
alias GitGud.DeployKeys
alias GitGud.Repositories
alias GitGud.SshKeys
def resolve(conn, %{"key_id" => key_arg, "repo" => repo_path, "cmd" => cmd}) do
case decide(key_arg, repo_path, cmd) do
{:ok, disk_path} ->
text(conn, "OK " <> disk_path <> "\n")
{:deny, reason} ->
text(conn, "DENY " <> reason <> "\n")
end
end
defp decide(key_arg, repo_arg, cmd) do
with {:ok, kind, id} <- parse_key_arg(key_arg),
{:ok, owner, name} <- parse_repo_arg(repo_arg) do
try do
repo = Repositories.get_repository_by_path!(owner, name) |> GitGud.Repo.preload(:owner)
authorize(kind, id, repo, cmd)
rescue
Ecto.NoResultsError -> {:deny, "repository not found"}
end
else
_ -> {:deny, "invalid request"}
end
end
# Legacy: bare integer key id means user key.
defp parse_key_arg("u:" <> id), do: {:ok, :user, String.to_integer(id)}
defp parse_key_arg("d:" <> id), do: {:ok, :deploy, String.to_integer(id)}
defp parse_key_arg(s) when is_binary(s) do
case Integer.parse(s) do
{n, ""} -> {:ok, :user, n}
_ -> :error
end
end
defp parse_repo_arg(arg) do
case arg
|> String.trim_trailing(".git")
|> String.trim_leading("/")
|> String.split("/", parts: 2) do
[owner, name] when owner != "" and name != "" -> {:ok, owner, name}
_ -> :error
end
end
defp authorize(:user, id, repo, cmd) do
case SshKeys.get_key!(id) do
key ->
if allow_user?(key, repo, cmd) do
_ = SshKeys.touch(key)
{:ok, repo.disk_path}
else
{:deny, "permission denied"}
end
end
rescue
Ecto.NoResultsError -> {:deny, "key not found"}
end
defp authorize(:deploy, id, repo, cmd) do
case DeployKeys.get_key!(id) do
key ->
cond do
key.repository_id != repo.id ->
{:deny, "deploy key for a different repo"}
allow_deploy?(key, cmd) ->
_ = DeployKeys.touch(key)
{:ok, repo.disk_path}
true ->
{:deny, "permission denied"}
end
end
rescue
Ecto.NoResultsError -> {:deny, "key not found"}
end
defp allow_user?(key, repo, "git-upload-pack"),
do: repo.visibility in ["public", "internal"] or key.user_id == repo.owner_id
defp allow_user?(key, repo, "git-receive-pack"), do: key.user_id == repo.owner_id
defp allow_user?(_, _, _), do: false
defp allow_deploy?(_key, "git-upload-pack"), do: true
defp allow_deploy?(%{read_write: true}, "git-receive-pack"), do: true
defp allow_deploy?(_, _), do: false
end
3.2 KiB · text
5af55d9