From b141ee78162af525b6c7e085f907add41f536896 Mon Sep 17 00:00:00 2001 From: Carsten Date: Tue, 21 Apr 2026 22:02:24 +0200 Subject: [PATCH] feat(server): host schema, context, auth, status transitions --- server/config/test.exs | 2 + server/lib/server/hosts.ex | 66 +++++++++++++++++++ server/lib/server/schema/host.ex | 32 +++++++++ .../20260421200116_create_hosts.exs | 19 ++++++ server/test/server/hosts_test.exs | 55 ++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 server/lib/server/hosts.ex create mode 100644 server/lib/server/schema/host.ex create mode 100644 server/priv/repo/migrations/20260421200116_create_hosts.exs create mode 100644 server/test/server/hosts_test.exs diff --git a/server/config/test.exs b/server/config/test.exs index 70f1b90..91ee1f4 100644 --- a/server/config/test.exs +++ b/server/config/test.exs @@ -26,3 +26,5 @@ config :phoenix, :plug_init_mode, :runtime # Enable helpful, but potentially expensive runtime checks config :phoenix_live_view, enable_expensive_runtime_checks: true + +config :bcrypt_elixir, :log_rounds, 4 diff --git a/server/lib/server/hosts.ex b/server/lib/server/hosts.ex new file mode 100644 index 0000000..b08135f --- /dev/null +++ b/server/lib/server/hosts.ex @@ -0,0 +1,66 @@ +defmodule Server.Hosts do + @moduledoc "Host registration, authentication, status tracking." + + alias Server.Repo + alias Server.Schema.Host + + @spec create_host(String.t()) :: {:ok, {Host.t(), String.t()}} | {:error, Ecto.Changeset.t()} + def create_host(name) do + token = generate_token() + hash = Bcrypt.hash_pwd_salt(token) + + %Host{} + |> Host.create_changeset(%{name: name, token_hash: hash}) + |> Repo.insert() + |> case do + {:ok, host} -> {:ok, {host, token}} + {:error, cs} -> {:error, cs} + end + end + + @spec authenticate(String.t(), String.t()) :: + {:ok, Host.t()} | {:error, :unknown_host | :invalid_token} + def authenticate(name, token) when is_binary(name) and is_binary(token) do + case Repo.get_by(Host, name: name) do + nil -> + Bcrypt.no_user_verify() + {:error, :unknown_host} + + host -> + if Bcrypt.verify_pass(token, host.token_hash) do + {:ok, host} + else + {:error, :invalid_token} + end + end + end + + @spec mark_online(Host.t(), String.t() | nil) :: {:ok, Host.t()} | {:error, Ecto.Changeset.t()} + def mark_online(%Host{} = host, agent_version) do + host + |> Host.status_changeset(%{ + status: "online", + last_seen_at: DateTime.utc_now(), + agent_version: agent_version + }) + |> Repo.update() + end + + @spec mark_offline(Host.t()) :: {:ok, Host.t()} | {:error, Ecto.Changeset.t()} + def mark_offline(%Host{} = host) do + host + |> Host.status_changeset(%{status: "offline"}) + |> Repo.update() + end + + @doc "Mark every host offline — called on server boot to clear stale online flags." + @spec mark_all_offline() :: {integer(), nil} + def mark_all_offline do + import Ecto.Query + Repo.update_all(from(h in Host), set: [status: "offline", updated_at: DateTime.utc_now()]) + end + + defp generate_token do + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + end +end diff --git a/server/lib/server/schema/host.ex b/server/lib/server/schema/host.ex new file mode 100644 index 0000000..a2bd68f --- /dev/null +++ b/server/lib/server/schema/host.ex @@ -0,0 +1,32 @@ +defmodule Server.Schema.Host do + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w(never_connected online offline) + + schema "hosts" do + field :name, :string + field :token_hash, :string + field :agent_version, :string + field :proxmox_version, :string + field :zfs_version, :string + field :status, :string, default: "never_connected" + field :last_seen_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + def create_changeset(host, attrs) do + host + |> cast(attrs, [:name, :token_hash]) + |> validate_required([:name, :token_hash]) + |> validate_length(:name, min: 1, max: 100) + |> unique_constraint(:name) + end + + def status_changeset(host, attrs) do + host + |> cast(attrs, [:status, :last_seen_at, :agent_version]) + |> validate_inclusion(:status, @statuses) + end +end diff --git a/server/priv/repo/migrations/20260421200116_create_hosts.exs b/server/priv/repo/migrations/20260421200116_create_hosts.exs new file mode 100644 index 0000000..1b35a89 --- /dev/null +++ b/server/priv/repo/migrations/20260421200116_create_hosts.exs @@ -0,0 +1,19 @@ +defmodule Server.Repo.Migrations.CreateHosts do + use Ecto.Migration + + def change do + create table(:hosts) do + add :name, :string, null: false + add :token_hash, :string, null: false + add :agent_version, :string + add :proxmox_version, :string + add :zfs_version, :string + add :status, :string, null: false, default: "never_connected" + add :last_seen_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:hosts, [:name]) + end +end diff --git a/server/test/server/hosts_test.exs b/server/test/server/hosts_test.exs new file mode 100644 index 0000000..7c542aa --- /dev/null +++ b/server/test/server/hosts_test.exs @@ -0,0 +1,55 @@ +defmodule Server.HostsTest do + use Server.DataCase, async: true + + alias Server.Hosts + + describe "create_host/1" do + test "returns host and a plaintext token on success" do + assert {:ok, {host, token}} = Hosts.create_host("pve-01") + assert host.name == "pve-01" + assert host.status == "never_connected" + assert is_binary(token) and byte_size(token) >= 32 + refute host.token_hash == token + end + + test "rejects duplicate names" do + {:ok, _} = Hosts.create_host("pve-01") + assert {:error, changeset} = Hosts.create_host("pve-01") + assert %{name: ["has already been taken"]} = errors_on(changeset) + end + end + + describe "authenticate/2" do + test "returns host for valid name+token" do + {:ok, {host, token}} = Hosts.create_host("pve-01") + assert {:ok, found} = Hosts.authenticate("pve-01", token) + assert found.id == host.id + end + + test "returns :invalid_token for wrong token" do + {:ok, {_host, _token}} = Hosts.create_host("pve-01") + assert {:error, :invalid_token} = Hosts.authenticate("pve-01", "wrong") + end + + test "returns :unknown_host when name does not exist" do + assert {:error, :unknown_host} = Hosts.authenticate("nope", "whatever") + end + end + + describe "mark_online/2 and mark_offline/1" do + test "mark_online stamps status, last_seen_at, agent_version" do + {:ok, {host, _}} = Hosts.create_host("pve-01") + assert {:ok, updated} = Hosts.mark_online(host, "0.1.0") + assert updated.status == "online" + assert updated.agent_version == "0.1.0" + assert updated.last_seen_at != nil + end + + test "mark_offline sets status to offline" do + {:ok, {host, _}} = Hosts.create_host("pve-01") + {:ok, online} = Hosts.mark_online(host, "0.1.0") + assert {:ok, offline} = Hosts.mark_offline(online) + assert offline.status == "offline" + end + end +end