feat(server): host schema, context, auth, status transitions
This commit is contained in:
parent
bab31b7c4e
commit
b141ee7816
5 changed files with 174 additions and 0 deletions
|
|
@ -26,3 +26,5 @@ config :phoenix, :plug_init_mode, :runtime
|
||||||
# Enable helpful, but potentially expensive runtime checks
|
# Enable helpful, but potentially expensive runtime checks
|
||||||
config :phoenix_live_view,
|
config :phoenix_live_view,
|
||||||
enable_expensive_runtime_checks: true
|
enable_expensive_runtime_checks: true
|
||||||
|
|
||||||
|
config :bcrypt_elixir, :log_rounds, 4
|
||||||
|
|
|
||||||
66
server/lib/server/hosts.ex
Normal file
66
server/lib/server/hosts.ex
Normal file
|
|
@ -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
|
||||||
32
server/lib/server/schema/host.ex
Normal file
32
server/lib/server/schema/host.ex
Normal file
|
|
@ -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
|
||||||
19
server/priv/repo/migrations/20260421200116_create_hosts.exs
Normal file
19
server/priv/repo/migrations/20260421200116_create_hosts.exs
Normal file
|
|
@ -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
|
||||||
55
server/test/server/hosts_test.exs
Normal file
55
server/test/server/hosts_test.exs
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue