defmodule GitGud.Ssh.Server do
@moduledoc """
Embedded SSH daemon for git push/pull.
Started under the app supervisor when configured `enabled: true`.
Host key is generated to `host_key_dir` on first boot if not present
(ed25519 + rsa) — the key is gitignored; treat the directory like a
TLS cert directory.
Configure:
config :git_gud, GitGud.Ssh.Server,
enabled: true,
port: 2222,
host_key_dir: Path.expand("priv/ssh")
Run on a privileged port via setcap or fronting with sshd
ProxyCommand; for dev, port 2222 is fine.
"""
use GenServer
require Logger
alias GitGud.Ssh.Channel
alias GitGud.Ssh.KeyApi
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(_opts) do
config = Application.get_env(:git_gud, __MODULE__, [])
cond do
not Keyword.get(config, :enabled, false) ->
Logger.info("SSH daemon disabled (config :git_gud, GitGud.Ssh.Server, enabled: true)")
:ignore
true ->
:ok = Application.ensure_started(:ssh)
do_start(config)
end
end
defp do_start(config) do
port = Keyword.get(config, :port, 2222)
host_key_dir = Keyword.get(config, :host_key_dir) |> resolve_dir!()
ensure_host_keys!(host_key_dir)
opts =
[
system_dir: String.to_charlist(host_key_dir),
key_cb: KeyApi,
auth_methods: ~c"publickey",
subsystems: [],
# Channel module receives exec / shell requests and bidirectional
# data via handle_ssh_msg. We refuse shells inside the channel.
ssh_cli: {Channel, []},
max_sessions: 256,
no_auth_needed: false
]
# Bind the daemon. `:ssh.daemon` expects a charlist for IP literals.
case :ssh.daemon(port, opts) do
{:ok, ref} ->
Logger.info("SSH daemon listening on port #{port}")
{:ok, %{ref: ref, port: port}}
{:error, reason} ->
Logger.error("Failed to start SSH daemon: #{inspect(reason)}")
{:stop, {:ssh_daemon_failed, reason}}
end
end
defp resolve_dir!(nil), do: resolve_dir!("priv/ssh")
defp resolve_dir!(dir), do: Path.expand(dir)
# Generate host keys if they don't already exist. The daemon expects
# OpenSSH-format files: `ssh_host_ed25519_key` / `ssh_host_rsa_key`.
defp ensure_host_keys!(dir) do
File.mkdir_p!(dir)
ed = Path.join(dir, "ssh_host_ed25519_key")
rsa = Path.join(dir, "ssh_host_rsa_key")
if !File.exists?(ed) and !File.exists?(rsa) do
Logger.info("Generating SSH host key in #{dir}…")
case System.cmd("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", ed, "-q"],
stderr_to_stdout: true
) do
{_, 0} ->
File.chmod!(ed, 0o600)
{out, code} ->
raise """
Could not generate host key. ssh-keygen exited #{code}.
#{out}
Provide one manually at #{ed} or pre-create the directory.
"""
end
end
end
@impl true
def terminate(_reason, %{ref: ref}) do
:ssh.stop_daemon(ref)
:ok
end
def terminate(_, _), do: :ok
end
3.1 KiB · text
5af55d9