9.4 KiB · text 5af55d9
defmodule GitGud.Ssh.Channel do
@moduledoc """
`:ssh_server_channel` implementation that maps an authenticated SSH
session to a git plumbing subprocess (`git-upload-pack` or
`git-receive-pack`).
Flow:
1. Client opens an `exec` channel with a command like
`git-upload-pack 'owner/repo.git'`.
2. We extract the user from the connection (it's the handle that
authenticated; see `GitGud.Ssh.KeyApi`).
3. Parse the command + repo arg, look up the repo, check the
caller can read (`upload-pack`) or write (`receive-pack`).
4. Spawn the git subprocess via a port and shuttle bytes between
the SSH channel and stdin/stdout.
The result mirrors the smart-HTTP transport but goes through SSH
with our DB-backed key auth — no system `git` user, no
`authorized_keys` file.
"""
@behaviour :ssh_server_channel
require Logger
alias GitGud.Repositories
alias GitGud.Repositories.Repository
defmodule State do
@moduledoc false
defstruct [
:cm,
:channel_id,
:user,
:port,
:git_cmd,
:repo_disk_path,
:exit_status
]
end
# ── ssh_server_channel callbacks ────────────────────────────────────
@impl true
def init(_args) do
{:ok, %State{}}
end
@impl true
def handle_msg({:ssh_channel_up, channel_id, cm}, state) do
user =
case :ssh.connection_info(cm, [:user]) do
[user: u] when is_list(u) -> to_string(u)
[user: u] when is_binary(u) -> u
_ -> nil
end
{:ok, %{state | channel_id: channel_id, cm: cm, user: user}}
end
def handle_msg({port, {:data, data}}, %State{port: port} = state) do
# Forward git's stdout straight to the SSH client.
:ssh_connection.send(state.cm, state.channel_id, data)
{:ok, state}
end
def handle_msg({port, {:exit_status, status}}, %State{port: port} = state) do
:ssh_connection.exit_status(state.cm, state.channel_id, status)
:ssh_connection.send_eof(state.cm, state.channel_id)
:ssh_connection.close(state.cm, state.channel_id)
{:stop, state.channel_id, %{state | exit_status: status}}
end
def handle_msg(_msg, state), do: {:ok, state}
@impl true
def handle_ssh_msg({:ssh_cm, _cm, {:data, _ch, _type, data}}, %State{port: port} = state)
when port != nil do
# Bytes from the SSH client go to git's stdin.
Port.command(port, data)
{:ok, state}
end
def handle_ssh_msg({:ssh_cm, _cm, {:eof, _ch}}, %State{port: port} = state) when port != nil do
Port.command(port, "")
{:ok, state}
end
def handle_ssh_msg(
{:ssh_cm, cm, {:exec, ch, want_reply, command}},
%State{} = state
) do
cmd_str = IO.iodata_to_binary(command)
Logger.info("ssh exec: user=#{inspect(state.user)} cmd=#{inspect(cmd_str)}")
case parse_command(cmd_str) do
{:ok, git_cmd, repo_arg} ->
case authorize(state.user, git_cmd, repo_arg) do
{:ok, disk_path, pusher_id} ->
Logger.info("ssh exec ok: #{git_cmd} #{disk_path}")
port = open_git_port(git_cmd, disk_path, pusher_id)
if want_reply, do: :ssh_connection.reply_request(cm, want_reply, :success, ch)
{:ok,
%{
state
| port: port,
git_cmd: git_cmd,
repo_disk_path: disk_path
}}
{:deny, reason} ->
Logger.warning("ssh exec deny: user=#{inspect(state.user)} reason=#{reason}")
deny(state, cm, ch, want_reply, reason)
end
:error ->
Logger.warning("ssh exec unsupported: #{inspect(cmd_str)}")
deny(state, cm, ch, want_reply, "unsupported command")
end
end
def handle_ssh_msg({:ssh_cm, cm, {:shell, ch, want_reply}}, state) do
deny(state, cm, ch, want_reply, "interactive shell not allowed")
end
def handle_ssh_msg({:ssh_cm, _cm, _other}, state), do: {:ok, state}
@impl true
def terminate(_reason, state) do
if state.port, do: Port.close(state.port)
:ok
rescue
_ -> :ok
end
# ── parsing ─────────────────────────────────────────────────────────
defp parse_command(s) do
s = String.trim(s)
cond do
String.starts_with?(s, "git-upload-pack ") ->
{:ok, "git-upload-pack", extract_arg(s, "git-upload-pack ")}
String.starts_with?(s, "git-receive-pack ") ->
{:ok, "git-receive-pack", extract_arg(s, "git-receive-pack ")}
String.starts_with?(s, "git-upload-archive ") ->
{:ok, "git-upload-archive", extract_arg(s, "git-upload-archive ")}
true ->
:error
end
end
defp extract_arg(s, prefix) do
s
|> String.replace_prefix(prefix, "")
|> String.trim()
|> String.trim_leading("'")
|> String.trim_trailing("'")
end
# ── authorize ───────────────────────────────────────────────────────
defp authorize(nil, _git_cmd, _repo_arg), do: {:deny, "no user on connection"}
defp authorize(user_handle, git_cmd, repo_arg) do
with {:ok, owner, name} <- parse_repo(repo_arg),
{:ok, repo} <- find_repo(owner, name),
{:ok, user} <- find_user(user_handle),
:ok <- check_access(user, repo, git_cmd) do
{:ok, repo.disk_path, user.id}
else
err ->
{:deny, format_deny(err)}
end
end
defp parse_repo(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, :bad_repo_arg}
end
end
defp find_repo(owner, name) do
try do
{:ok,
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])}
rescue
Ecto.NoResultsError -> {:error, :no_such_repo}
end
end
defp find_user(handle) do
import Ecto.Query, warn: false
case GitGud.Repo.one(
from u in GitGud.Accounts.User,
where: fragment("split_part(?, '@', 1)", u.email) == ^handle,
limit: 1
) do
nil -> {:error, :no_such_user}
user -> {:ok, user}
end
end
defp check_access(user, %Repository{} = repo, "git-upload-pack"),
do: if(reader?(user, repo), do: :ok, else: {:error, :forbidden})
defp check_access(user, %Repository{} = repo, "git-receive-pack"),
do: if(writer?(user, repo), do: :ok, else: {:error, :forbidden})
defp check_access(_user, _repo, _cmd), do: {:error, :forbidden}
defp reader?(_user, %Repository{visibility: vis}) when vis in ["public", "internal"], do: true
defp reader?(%{id: uid}, %Repository{owner_id: uid}), do: true
defp reader?(%{id: uid}, %Repository{organization_id: oid}) when not is_nil(oid),
do: GitGud.Organizations.org_admin?(%GitGud.Organizations.Organization{id: oid}, %GitGud.Accounts.User{id: uid})
defp reader?(_, _), do: false
defp writer?(%{id: uid}, %Repository{owner_id: uid}), do: true
defp writer?(%{id: uid}, %Repository{organization_id: oid}) when not is_nil(oid),
do: GitGud.Organizations.org_admin?(%GitGud.Organizations.Organization{id: oid}, %GitGud.Accounts.User{id: uid})
defp writer?(_, _), do: false
defp format_deny({:error, reason}), do: to_string(reason)
defp format_deny(other), do: inspect(other)
defp deny(state, cm, ch, want_reply, reason) do
if want_reply, do: :ssh_connection.reply_request(cm, want_reply, :failure, ch)
# Use SSH extended data (type 1 = stderr) so the message renders as
# "remote: ..." on the client and doesn't corrupt the pkt-line stream.
:ssh_connection.send(cm, ch, 1, "gitgud-ssh: " <> reason <> "\n")
:ssh_connection.exit_status(cm, ch, 1)
:ssh_connection.send_eof(cm, ch)
:ssh_connection.close(cm, ch)
{:stop, ch, state}
end
# ── git subprocess ──────────────────────────────────────────────────
defp open_git_port(git_cmd, disk_path, pusher_id) do
# `git-receive-pack` / `git-upload-pack` are exposed as standalone
# binaries (legacy dashed form), but invoking `git` with that string
# as the first arg fails: git treats it as a subcommand and reports
# "'git-receive-pack' is not a git command". Strip the prefix and
# use the subcommand form, which works on any modern git.
subcommand = String.replace_prefix(git_cmd, "git-", "")
git = System.find_executable("git") || "/usr/bin/git"
# Do NOT merge stderr into stdout — git's stdout carries the
# pkt-line protocol and any stray text on it ("fatal: ...", warnings)
# makes the client error with "bad line length character".
# Stderr lands on BEAM's stderr (the iex console) instead. A future
# refinement is to forward it to the SSH channel's stderr stream.
Port.open(
{:spawn_executable, git},
[
:binary,
:exit_status,
:use_stdio,
args: [subcommand, disk_path]
] ++ port_env(pusher_id)
)
end
# Pass the pusher's id into the git environment so the post-receive
# hook (`gitgud-hook`) can attribute the runs this push triggers.
defp port_env(nil), do: []
defp port_env(pusher_id),
do: [env: [{~c"GITGUD_PUSHER_ID", String.to_charlist(Integer.to_string(pusher_id))}]]
end