defmodule GitGud.Workflows.Runners do
@moduledoc """
Runner registration + token handling.
Two registration paths:
1. **Per-scope token** — repo or org admins mint a token via the
settings UI; the resulting runner is scoped to that repo or org.
Stored as a SHA-256 hash on `repositories.runner_reg_token_hash`
or `organizations.runner_reg_token_hash`. Regenerating shifts the
hash, invalidating the old token in one step.
2. **Global env token** (`GITGUD_RUNNER_REG_TOKEN`) — legacy path
for operator-managed pool runners. Resulting runner has neither
repository_id nor organization_id set and serves any repo's jobs.
Runner-side tokens (issued by `register/2`) are random 32 bytes; we
store only the SHA-256 hash. The bare token is shown once and never
re-rendered.
"""
import Ecto.Query, warn: false
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories.Repository
alias GitGud.Workflows.Runner
@registration_env "GITGUD_RUNNER_REG_TOKEN"
# ── global token ──────────────────────────────────────────────────────
def registration_token do
System.get_env(@registration_env) ||
Application.get_env(:git_gud, __MODULE__, [])[:registration_token]
end
defp global_token_valid?(token) when is_binary(token) do
case registration_token() do
nil -> false
expected -> Plug.Crypto.secure_compare(expected, token)
end
end
defp global_token_valid?(_), do: false
@doc """
Resolve a registration token to its scope. Returns:
* `{:ok, :global}` — legacy global token from env / config
* `{:ok, %Repository{}}` — repo-scoped token
* `{:ok, %Organization{}}` — org-scoped token
* `:error` — unknown / invalid token
"""
def consume_registration_token(token) when is_binary(token) and token != "" do
cond do
global_token_valid?(token) ->
{:ok, :global}
repo = lookup_repo_token(token) ->
{:ok, repo}
org = lookup_org_token(token) ->
{:ok, org}
true ->
:error
end
end
def consume_registration_token(_), do: :error
defp lookup_repo_token(token) do
hash = token_hash(token)
Repo.get_by(Repository, runner_reg_token_hash: hash)
end
defp lookup_org_token(token) do
hash = token_hash(token)
Repo.get_by(Organization, runner_reg_token_hash: hash)
end
defp token_hash(token), do: :crypto.hash(:sha256, token)
@doc """
Generate (or regenerate) a registration token for a scope. Returns
`{:ok, bare_token}` — the bare token is **shown once**, only the
hash is persisted.
Regenerating is destructive — any previously-distributed token for
that scope stops working immediately.
"""
def mint_registration_token(%Repository{} = repo) do
token = mint_token()
hash = token_hash(token)
repo
|> Ecto.Changeset.change(runner_reg_token_hash: hash)
|> Repo.update()
|> case do
{:ok, _} -> {:ok, token}
err -> err
end
end
def mint_registration_token(%Organization{} = org) do
token = mint_token()
hash = token_hash(token)
org
|> Ecto.Changeset.change(runner_reg_token_hash: hash)
|> Repo.update()
|> case do
{:ok, _} -> {:ok, token}
err -> err
end
end
defp mint_token, do: :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false)
@doc "True if `scope` currently has a registration token minted."
def has_token?(%Repository{runner_reg_token_hash: h}), do: not is_nil(h)
def has_token?(%Organization{runner_reg_token_hash: h}), do: not is_nil(h)
def has_token?(_), do: false
# ── runner CRUD ──────────────────────────────────────────────────────
@doc """
Register a new runner. `attrs` must include `:name`. `scope` is the
value returned by `consume_registration_token/1` and decides whether
the runner ends up repo-scoped, org-scoped, or global.
Returns `{:ok, runner, bare_token}` on success; bare token is the
runner's bearer credential, shown once.
"""
def register(%{name: _} = attrs, scope \\ :global) do
raw = :crypto.strong_rand_bytes(20)
hex_token = Base.encode16(raw, case: :lower)
hash = :crypto.hash(:sha256, raw)
scope_attrs = scope_attrs(scope)
%Runner{token_hash: hash}
|> Runner.changeset(
Map.merge(
%{
name: Map.fetch!(attrs, :name),
version: Map.get(attrs, :version),
labels: Map.get(attrs, :labels, %{"labels" => ["ubuntu-latest"]}),
# Forgejo runners (v12+) validate that this is a UUID string —
# send a real one, not stringified integer id.
uuid: Ecto.UUID.generate()
},
scope_attrs
)
)
|> Repo.insert()
|> case do
{:ok, runner} -> {:ok, runner, hex_token}
err -> err
end
end
defp scope_attrs(:global), do: %{}
defp scope_attrs(%Repository{id: id}), do: %{repository_id: id}
defp scope_attrs(%Organization{id: id}), do: %{organization_id: id}
defp scope_attrs(_), do: %{}
@doc """
Server-side ("offline") runner mint. Creates the Runner row before
the runner process ever calls us, generates a UUID + hex-encoded
20-byte token, and returns `{:ok, runner, uuid, hex_token}` for the
operator to paste into `config.yml`'s
`server.connections.<name>` block:
server:
connections:
gitgud:
url: https://gg.neiam.co/
uuid: <uuid>
token: <hex_token>
Storage uses sha256 of the decoded token bytes so the schema layout
is identical to the legacy online flow — only the wire encoding
differs (hex vs base64url).
"""
def create_offline(attrs, scope \\ :global) when is_map(attrs) do
raw = :crypto.strong_rand_bytes(20)
hex_token = Base.encode16(raw, case: :lower)
hash = :crypto.hash(:sha256, raw)
scope_attrs = scope_attrs(scope)
%Runner{token_hash: hash, uuid: Ecto.UUID.generate()}
|> Runner.changeset(
Map.merge(
%{
name: Map.fetch!(attrs, :name),
labels: Map.get(attrs, :labels, %{"labels" => ["ubuntu-latest"]})
},
scope_attrs
)
)
|> Repo.insert()
|> case do
{:ok, runner} -> {:ok, runner, runner.uuid, hex_token}
err -> err
end
end
@doc """
Build a complete `config.yml` for a `forgejo-runner` (v6+) wired to
this instance via the offline `server.connections` block, configured
to run jobs in **rootless Podman**.
`attrs` keys:
* `:url` — instance base URL (no trailing slash)
* `:uuid` — the runner UUID minted by `create_offline/2`
* `:token` — the bare hex token (shown once)
* `:labels` — list of advertised labels (defaults to
`["self-hosted", "ubuntu-latest"]`)
The Podman wiring lives in `container.docker_host`: it points at the
rootless Podman API socket so job containers run under Podman instead
of Docker. The UID in the socket path defaults to 1000 — operators on
a different UID edit one line (or export `DOCKER_HOST`).
"""
def config_yaml(%{url: url, uuid: uuid, token: token} = attrs) do
labels =
case Map.get(attrs, :labels) do
[_ | _] = ls -> ls
_ -> ["self-hosted", "ubuntu-latest"]
end
label_lines = labels |> Enum.map(&" - #{&1}") |> Enum.join("\n")
"""
# forgejo-runner config — runs jobs in rootless Podman.
#
# First bring up the rootless Podman API socket and keep it alive:
# systemctl --user enable --now podman.socket
# loginctl enable-linger "$USER"
# It then lives at $XDG_RUNTIME_DIR/podman/podman.sock — set the UID
# in container.docker_host below to match `id -u`, or leave it empty
# and export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock.
#
# Start with: forgejo-runner daemon --config config.yml
log:
level: info
runner:
capacity: 1
labels:
#{label_lines}
container:
# Run job containers under rootless Podman instead of Docker.
docker_host: unix:///run/user/1000/podman/podman.sock
# Rootless Podman + SELinux: keep the bind-mounted workspace usable.
options: --security-opt label=disable
force_pull: false
network: ""
valid_volumes: []
server:
connections:
gitgud:
url: #{url}/
uuid: #{uuid}
token: #{token}
"""
|> String.trim_trailing()
end
@doc """
Hex-token lookup. Modern forgejo-runner (v6+) sends the bare hex
token in `x-runner-token`; we decode, sha256, and match against
`token_hash`. Legacy base64url-encoded tokens are no longer
accepted — re-mint any runners still on the old flow with
`create_offline/2`.
"""
def find_by_token(token) when is_binary(token) do
with {:ok, raw} <- Base.decode16(token, case: :mixed) do
hash = :crypto.hash(:sha256, raw)
Repo.get_by(Runner, token_hash: hash)
else
_ -> nil
end
end
def find_by_token(_), do: nil
@doc """
Validate a runner by both `(uuid, hex_token)`. Faster than
`find_by_token/1` when the caller already has the UUID (lookup hits
a unique index instead of the token-hash index), and adds a
constant-time identity check on top of the hash match.
"""
def find_by_uuid_and_token(uuid, hex_token)
when is_binary(uuid) and is_binary(hex_token) do
with {:ok, raw} <- Base.decode16(hex_token, case: :mixed),
%Runner{} = runner <- Repo.get_by(Runner, uuid: uuid),
expected_hash <- runner.token_hash,
hash <- :crypto.hash(:sha256, raw),
true <- byte_size(expected_hash) == byte_size(hash),
true <- Plug.Crypto.secure_compare(expected_hash, hash) do
runner
else
_ -> nil
end
end
def find_by_uuid_and_token(_, _), do: nil
def touch_last_seen(%Runner{} = runner) do
runner
|> Ecto.Changeset.change(last_seen_at: DateTime.utc_now(:second))
|> Repo.update()
end
@doc """
Delete a runner. Caller is responsible for authorization (repo admin
for repo-scoped, org admin for org-scoped, app admin for global).
"""
def delete_runner(%Runner{} = runner), do: Repo.delete(runner)
@doc """
List runners visible at `scope`.
* `%Repository{}` — runners scoped to that repo + that repo's org
(if any) + global runners
* `%Organization{}` — runners scoped to that org + global
* `:global` — only global runners
"""
def list_for_scope(%Repository{id: rid, organization_id: oid}) do
base =
from r in Runner,
order_by: [desc: r.last_seen_at]
cond do
is_integer(oid) ->
Repo.all(
from r in base,
where:
r.repository_id == ^rid or
r.organization_id == ^oid or
(is_nil(r.repository_id) and is_nil(r.organization_id))
)
true ->
Repo.all(
from r in base,
where:
r.repository_id == ^rid or
(is_nil(r.repository_id) and is_nil(r.organization_id))
)
end
end
def list_for_scope(%Organization{id: oid}) do
Repo.all(
from r in Runner,
where:
r.organization_id == ^oid or
(is_nil(r.repository_id) and is_nil(r.organization_id)),
order_by: [desc: r.last_seen_at]
)
end
def list_for_scope(:global) do
Repo.all(
from r in Runner,
where: is_nil(r.repository_id) and is_nil(r.organization_id),
order_by: [desc: r.last_seen_at]
)
end
end
11.5 KiB · text
5af55d9