diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex new file mode 100644 index 00000000..05f240a2 --- /dev/null +++ b/lib/towerops/agents.ex @@ -0,0 +1,256 @@ +defmodule Towerops.Agents do + @moduledoc """ + The Agents context for managing remote SNMP polling agents. + + This context provides functions for creating and managing agent authentication tokens, + assigning equipment to agents, and verifying agent credentials. + """ + + import Ecto.Query, warn: false + + alias Towerops.Agents.AgentAssignment + alias Towerops.Agents.AgentToken + alias Towerops.Repo + + ## Token management + + @doc """ + Creates a new agent token for an organization. + + Returns {:ok, agent_token, token_string} where token_string is the plain token + that should be shown to the user ONCE and never stored. + + ## Examples + + iex> create_agent_token(org_id, "Datacenter Agent") + {:ok, %AgentToken{}, "abc123..."} + + iex> create_agent_token(nil, "") + {:error, %Ecto.Changeset{}} + + """ + def create_agent_token(organization_id, name) do + {token, changeset} = AgentToken.build_token(organization_id, name) + + case Repo.insert(changeset) do + {:ok, agent_token} -> {:ok, agent_token, token} + {:error, changeset} -> {:error, changeset} + end + end + + @doc """ + Lists all agent tokens for an organization. + + Returns a list of agent tokens ordered by creation date (newest first). + + ## Examples + + iex> list_organization_agent_tokens(org_id) + [%AgentToken{}, ...] + + """ + def list_organization_agent_tokens(organization_id) do + AgentToken + |> where([t], t.organization_id == ^organization_id) + |> order_by([t], desc: t.inserted_at) + |> Repo.all() + end + + @doc """ + Gets a single agent token by ID. + + Raises `Ecto.NoResultsError` if the token does not exist. + + ## Examples + + iex> get_agent_token!(id) + %AgentToken{} + + iex> get_agent_token!("nonexistent") + ** (Ecto.NoResultsError) + + """ + def get_agent_token!(id), do: Repo.get!(AgentToken, id) + + @doc """ + Updates the heartbeat timestamp and metadata for an agent token. + + This is called automatically when an agent makes an API request. + + ## Examples + + iex> update_agent_token_heartbeat(token_id, "192.168.1.1", %{version: "0.1.0"}) + {1, nil} + + """ + def update_agent_token_heartbeat(agent_token_id, ip_address, metadata \\ %{}) do + now = DateTime.truncate(DateTime.utc_now(), :second) + + AgentToken + |> where([t], t.id == ^agent_token_id) + |> Repo.update_all( + set: [ + last_seen_at: now, + last_ip: ip_address, + metadata: metadata + ] + ) + end + + @doc """ + Verifies an agent token and returns the agent token record if valid. + + Returns {:ok, agent_token} if the token is valid and enabled, + otherwise {:error, :invalid_token}. + + ## Examples + + iex> verify_agent_token("valid_token") + {:ok, %AgentToken{}} + + iex> verify_agent_token("invalid_token") + {:error, :invalid_token} + + """ + def verify_agent_token(encoded_token) do + with {:ok, token_hash} <- AgentToken.verify_token(encoded_token), + %AgentToken{} = agent_token <- + Repo.get_by(AgentToken, token_hash: token_hash, enabled: true) do + {:ok, agent_token} + else + _ -> {:error, :invalid_token} + end + end + + @doc """ + Revokes an agent token by setting enabled to false. + + ## Examples + + iex> revoke_agent_token(id) + {:ok, %AgentToken{enabled: false}} + + """ + def revoke_agent_token(id) do + AgentToken + |> Repo.get!(id) + |> Ecto.Changeset.change(enabled: false) + |> Repo.update() + end + + ## Assignment management + + @doc """ + Assigns equipment to an agent. + + Creates an agent assignment that links equipment to an agent token. + Equipment can only be assigned to one agent at a time due to unique constraint. + + ## Examples + + iex> assign_equipment_to_agent(agent_id, equipment_id) + {:ok, %AgentAssignment{}} + + iex> assign_equipment_to_agent(agent_id, already_assigned_equipment_id) + {:error, %Ecto.Changeset{}} + + """ + def assign_equipment_to_agent(agent_token_id, equipment_id) do + %AgentAssignment{} + |> AgentAssignment.changeset(%{ + agent_token_id: agent_token_id, + equipment_id: equipment_id + }) + |> Repo.insert() + end + + @doc """ + Removes the agent assignment for a piece of equipment. + + ## Examples + + iex> unassign_equipment(equipment_id) + {1, nil} + + """ + def unassign_equipment(equipment_id) do + AgentAssignment + |> where([a], a.equipment_id == ^equipment_id) + |> Repo.delete_all() + end + + @doc """ + Lists all equipment assigned to an agent. + + Returns equipment records with preloaded site and SNMP device associations. + + ## Examples + + iex> list_agent_equipment(agent_token_id) + [%Equipment{site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}] + + """ + def list_agent_equipment(agent_token_id) do + Repo.all( + from(e in Towerops.Equipment.Equipment, + join: a in AgentAssignment, + on: a.equipment_id == e.id, + where: a.agent_token_id == ^agent_token_id and a.enabled == true, + preload: [:site, snmp_device: [:sensors, :interfaces]] + ) + ) + end + + @doc """ + Gets the agent assignment for a piece of equipment. + + Returns the assignment record if it exists, nil otherwise. + + ## Examples + + iex> get_equipment_assignment(equipment_id) + %AgentAssignment{} + + iex> get_equipment_assignment(unassigned_equipment_id) + nil + + """ + def get_equipment_assignment(equipment_id) do + Repo.get_by(AgentAssignment, equipment_id: equipment_id) + end + + @doc """ + Updates or creates an equipment assignment. + + If agent_token_id is nil, removes any existing assignment. + Otherwise, creates or updates the assignment. + + ## Examples + + iex> update_equipment_assignment(equipment_id, agent_token_id) + {:ok, %AgentAssignment{}} + + iex> update_equipment_assignment(equipment_id, nil) + {:ok, nil} + + """ + def update_equipment_assignment(equipment_id, nil) do + # Remove assignment if setting to nil + unassign_equipment(equipment_id) + {:ok, nil} + end + + def update_equipment_assignment(equipment_id, agent_token_id) do + case get_equipment_assignment(equipment_id) do + nil -> + # Create new assignment + assign_equipment_to_agent(agent_token_id, equipment_id) + + existing -> + # Update existing assignment + existing + |> AgentAssignment.changeset(%{agent_token_id: agent_token_id}) + |> Repo.update() + end + end +end diff --git a/lib/towerops/agents/agent_assignment.ex b/lib/towerops/agents/agent_assignment.ex new file mode 100644 index 00000000..a1c2b56c --- /dev/null +++ b/lib/towerops/agents/agent_assignment.ex @@ -0,0 +1,40 @@ +defmodule Towerops.Agents.AgentAssignment do + @moduledoc """ + Schema for assigning equipment to remote agents. + + Each equipment can only be assigned to one agent at a time (unique constraint on equipment_id). + This ensures that polling is not duplicated across multiple agents. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "agent_assignments" do + field :enabled, :boolean, default: true + + belongs_to :agent_token, Towerops.Agents.AgentToken + belongs_to :equipment, Towerops.Equipment.Equipment + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating and updating agent assignments. + + ## Examples + + iex> changeset(%AgentAssignment{}, %{agent_token_id: token_id, equipment_id: equip_id}) + %Ecto.Changeset{valid?: true} + + """ + def changeset(assignment, attrs) do + assignment + |> cast(attrs, [:agent_token_id, :equipment_id, :enabled]) + |> validate_required([:agent_token_id, :equipment_id]) + |> unique_constraint([:equipment_id]) + |> foreign_key_constraint(:agent_token_id) + |> foreign_key_constraint(:equipment_id) + end +end diff --git a/lib/towerops/agents/agent_token.ex b/lib/towerops/agents/agent_token.ex new file mode 100644 index 00000000..4c73178e --- /dev/null +++ b/lib/towerops/agents/agent_token.ex @@ -0,0 +1,89 @@ +defmodule Towerops.Agents.AgentToken do + @moduledoc """ + Schema for agent authentication tokens. + + Agent tokens are used to authenticate remote SNMP polling agents that run on customer networks. + Tokens are generated with cryptographically secure random bytes, and only the SHA256 hash is + stored in the database for security. + """ + use Ecto.Schema + + import Ecto.Changeset + + @hash_algorithm :sha256 + @rand_size 32 + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "agent_tokens" do + field :token_hash, :binary + field :name, :string + field :last_seen_at, :utc_datetime + field :last_ip, :string + field :enabled, :boolean, default: true + field :metadata, :map, default: %{} + + belongs_to :organization, Towerops.Organizations.Organization + + timestamps(type: :utc_datetime) + end + + @doc """ + Builds a new agent token with secure random bytes. + + Returns a tuple of {encoded_token, changeset}. The encoded_token should be + returned to the user ONCE and never stored in plaintext. + + ## Examples + + iex> {token, changeset} = AgentToken.build_token(org_id, "My Agent") + iex> is_binary(token) + true + + """ + def build_token(organization_id, name) do + token = :crypto.strong_rand_bytes(@rand_size) + encoded_token = Base.url_encode64(token, padding: false) + hashed_token = :crypto.hash(@hash_algorithm, token) + + changeset = + %__MODULE__{} + |> cast( + %{ + organization_id: organization_id, + name: name, + token_hash: hashed_token + }, + [:organization_id, :name, :token_hash] + ) + |> validate_required([:organization_id, :name, :token_hash]) + + {encoded_token, changeset} + end + + @doc """ + Verifies an encoded token by hashing it. + + Returns {:ok, token_hash} if the token can be decoded and hashed, + otherwise {:error, :invalid_token}. + + ## Examples + + iex> AgentToken.verify_token("valid_base64_token") + {:ok, <>} + + iex> AgentToken.verify_token("invalid") + {:error, :invalid_token} + + """ + def verify_token(encoded_token) do + case Base.url_decode64(encoded_token, padding: false) do + {:ok, token} -> + hashed = :crypto.hash(@hash_algorithm, token) + {:ok, hashed} + + _ -> + {:error, :invalid_token} + end + end +end diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index 2ed77642..1a032cef 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -10,6 +10,8 @@ defmodule Towerops.Organizations.Organization do field :name, :string field :slug, :string + belongs_to :default_agent_token, Towerops.Agents.AgentToken + has_many :memberships, Towerops.Organizations.Membership has_many :users, through: [:memberships, :user] has_many :invitations, Towerops.Organizations.Invitation @@ -20,12 +22,13 @@ defmodule Towerops.Organizations.Organization do @doc false def changeset(organization, attrs) do organization - |> cast(attrs, [:name]) + |> cast(attrs, [:name, :default_agent_token_id]) |> validate_required([:name]) |> validate_length(:name, min: 2, max: 100) |> generate_slug() |> validate_required([:slug]) |> unique_constraint(:slug) + |> foreign_key_constraint(:default_agent_token_id) end defp generate_slug(changeset) do diff --git a/lib/towerops_web/controllers/api/agent_controller.ex b/lib/towerops_web/controllers/api/agent_controller.ex new file mode 100644 index 00000000..78a1ec13 --- /dev/null +++ b/lib/towerops_web/controllers/api/agent_controller.ex @@ -0,0 +1,159 @@ +defmodule ToweropsWeb.Api.AgentController do + @moduledoc """ + API controller for remote agent communication. + + Provides endpoints for agents to: + - Fetch polling configuration + - Submit metrics + - Send heartbeats + """ + + use ToweropsWeb, :controller + + alias Towerops.Agents + alias Towerops.Snmp + + @doc """ + GET /api/v1/agent/config + + Returns polling configuration for all equipment assigned to the authenticated agent. + """ + def get_config(conn, _params) do + agent_token = conn.assigns.current_agent_token + equipment_list = Agents.list_agent_equipment(agent_token.id) + + config = %{ + version: "1.0", + poll_interval_seconds: 60, + equipment: Enum.map(equipment_list, &build_equipment_config/1) + } + + json(conn, config) + end + + @doc """ + POST /api/v1/agent/metrics + + Accepts a batch of metrics from the agent and processes them asynchronously. + """ + def submit_metrics(conn, %{"metrics" => metrics}) when is_list(metrics) do + agent_token = conn.assigns.current_agent_token + + Task.start(fn -> + process_metrics(agent_token, metrics) + end) + + json(conn, %{status: "accepted", received: length(metrics)}) + end + + @doc """ + POST /api/v1/agent/heartbeat + + Updates the agent's last_seen_at timestamp and metadata. + """ + def heartbeat(conn, params) do + agent_token = conn.assigns.current_agent_token + ip = to_string(:inet_parse.ntoa(conn.remote_ip)) + + metadata = %{ + "version" => Map.get(params, "version"), + "hostname" => Map.get(params, "hostname"), + "uptime_seconds" => Map.get(params, "uptime_seconds") + } + + # Update synchronously in the heartbeat endpoint + fn -> + Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata) + end + |> Task.async() + |> Task.await() + + json(conn, %{status: "ok"}) + end + + # Private helpers + + defp build_equipment_config(equipment) do + device = equipment.snmp_device + + %{ + id: equipment.id, + name: equipment.name, + ip_address: equipment.ip_address, + snmp: %{ + enabled: equipment.snmp_enabled, + version: equipment.snmp_version, + community: equipment.snmp_community, + port: equipment.snmp_port || 161 + }, + poll_interval_seconds: equipment.check_interval_seconds || 60, + sensors: build_sensor_config(device), + interfaces: build_interface_config(device) + } + end + + defp build_sensor_config(nil), do: [] + + defp build_sensor_config(device) do + Enum.map(device.sensors, fn sensor -> + %{ + id: sensor.id, + type: sensor.sensor_type, + oid: sensor.sensor_oid, + divisor: sensor.sensor_divisor, + unit: sensor.sensor_unit, + metadata: sensor.metadata + } + end) + end + + defp build_interface_config(nil), do: [] + + defp build_interface_config(device) do + Enum.map(device.interfaces, fn interface -> + %{ + id: interface.id, + if_index: interface.if_index, + if_name: interface.if_name + } + end) + end + + defp process_metrics(_agent_token, metrics) do + Enum.each(metrics, fn metric -> + case metric do + %{"type" => "sensor_reading"} = m -> + Snmp.create_sensor_reading(%{ + sensor_id: m["sensor_id"], + value: m["value"], + status: m["status"] || "ok", + checked_at: parse_timestamp(m["timestamp"]) + }) + + %{"type" => "interface_stat"} = m -> + Snmp.create_interface_stat(%{ + interface_id: m["interface_id"], + if_in_octets: m["if_in_octets"], + if_out_octets: m["if_out_octets"], + if_in_errors: m["if_in_errors"], + if_out_errors: m["if_out_errors"], + if_in_discards: m["if_in_discards"], + if_out_discards: m["if_out_discards"], + checked_at: parse_timestamp(m["timestamp"]) + }) + + _ -> + :ok + end + end) + end + + defp parse_timestamp(nil), do: DateTime.truncate(DateTime.utc_now(), :second) + + defp parse_timestamp(ts) when is_binary(ts) do + case DateTime.from_iso8601(ts) do + {:ok, dt, _} -> DateTime.truncate(dt, :second) + _ -> DateTime.truncate(DateTime.utc_now(), :second) + end + end +end diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex new file mode 100644 index 00000000..a82aa6ec --- /dev/null +++ b/lib/towerops_web/live/agent_live/index.ex @@ -0,0 +1,119 @@ +defmodule ToweropsWeb.AgentLive.Index do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Agents + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_organization + agent_tokens = Agents.list_organization_agent_tokens(organization.id) + + {:ok, + socket + |> assign(:page_title, "Remote Agents") + |> assign(:agent_tokens, agent_tokens) + |> assign(:new_token, nil) + |> assign(:show_token_modal, false) + |> assign(:agent_form, to_form(%{"name" => ""}))} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + @impl true + def handle_event("create_agent", params, socket) do + name = + case params do + %{"agent_form" => %{"name" => n}} -> n + %{"name" => n} -> n + end + + organization = socket.assigns.current_organization + + case Agents.create_agent_token(organization.id, name) do + {:ok, agent_token, token} -> + agent_tokens = Agents.list_organization_agent_tokens(organization.id) + + {:noreply, + socket + |> assign(:agent_tokens, agent_tokens) + |> assign(:new_token, %{agent_token: agent_token, token: token}) + |> assign(:show_token_modal, true) + |> assign(:agent_form, to_form(%{"name" => ""})) + |> put_flash(:info, "Agent created successfully")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to create agent")} + end + end + + @impl true + def handle_event("close_token_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_token_modal, false) + |> assign(:new_token, nil)} + end + + @impl true + def handle_event("revoke_agent", %{"id" => id}, socket) do + case Agents.revoke_agent_token(id) do + {:ok, _} -> + organization = socket.assigns.current_organization + agent_tokens = Agents.list_organization_agent_tokens(organization.id) + + {:noreply, + socket + |> assign(:agent_tokens, agent_tokens) + |> put_flash(:info, "Agent revoked successfully")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to revoke agent")} + end + end + + defp apply_action(socket, :index, _params) do + socket + end + + defp agent_status(agent_token) do + case agent_token.last_seen_at do + nil -> + {:never, "Never connected"} + + last_seen -> + seconds_ago = DateTime.diff(DateTime.utc_now(), last_seen, :second) + + cond do + seconds_ago < 120 -> {:online, "Online"} + seconds_ago < 300 -> {:warning, "Warning"} + true -> {:offline, "Offline"} + end + end + end + + defp status_badge_class(status) do + case status do + :online -> "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" + :warning -> "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300" + :offline -> "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" + :never -> "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300" + end + end + + defp format_last_seen(nil), do: "Never" + + defp format_last_seen(datetime) do + seconds_ago = DateTime.diff(DateTime.utc_now(), datetime, :second) + + cond do + seconds_ago < 60 -> "#{seconds_ago}s ago" + seconds_ago < 3600 -> "#{div(seconds_ago, 60)}m ago" + seconds_ago < 86_400 -> "#{div(seconds_ago, 3600)}h ago" + true -> "#{div(seconds_ago, 86_400)}d ago" + end + end +end diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex new file mode 100644 index 00000000..913b1c63 --- /dev/null +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -0,0 +1,236 @@ + + <.header> + {@page_title} + <:subtitle>Manage remote agents for local SNMP polling + <:actions> + <.button phx-click={JS.show(to: "#new-agent-form")} variant="primary"> + <.icon name="hero-plus" class="h-5 w-5" /> New Agent + + + + + + + + <%= if @agent_tokens == [] do %> +
+ <.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" /> +

No agents

+

+ Get started by creating your first remote agent. +

+
+ <.button phx-click={JS.show(to: "#new-agent-form")} variant="primary"> + <.icon name="hero-plus" class="h-5 w-5" /> New Agent + +
+
+ <% else %> +
+ <.table id="agents-table" rows={@agent_tokens}> + <:col :let={agent} label="Name"> +
{agent.name}
+ <%= if agent.metadata["version"] do %> +
+ v{agent.metadata["version"]} +
+ <% end %> + + + <:col :let={agent} label="Status"> + <% {status, label} = agent_status(agent) %> + + {label} + + + + <:col :let={agent} label="Last Seen"> +
+ {format_last_seen(agent.last_seen_at)} +
+ <%= if agent.last_ip do %> +
+ {agent.last_ip} +
+ <% end %> + + + <:col :let={agent} label="Metadata"> + <%= if agent.metadata["hostname"] do %> +
+ <.icon name="hero-computer-desktop" class="h-4 w-4 inline" /> + {agent.metadata["hostname"]} +
+ <% end %> + + + <:col :let={agent} label="Created"> +
+ {Calendar.strftime(agent.inserted_at, "%b %d, %Y")} +
+ + + <:action :let={agent}> + <%= if agent.enabled do %> + + <% else %> + Revoked + <% end %> + + +
+ <% end %> + + + <%= if @show_token_modal && @new_token do %> +
+
+
+ +
+
+
+
+

+ Agent Created Successfully +

+

+ Copy this token now - it will never be shown again! +

+
+ +
+ +
+
+ +
+ {@new_token.agent_token.name} +
+
+ +
+ +
+ + +
+
+ +
+

+ Docker Compose Example +

+
version: '3.8'
+services:
+  towerops-agent:
+    image: towerops/agent:latest
+    restart: unless-stopped
+    environment:
+      - TOWEROPS_API_URL={url(@socket, ~p"/")}
+      - TOWEROPS_AGENT_TOKEN={@new_token.token}
+    volumes:
+      - ./data:/data
+ + +
+
+ +
+ <.button phx-click="close_token_modal" variant="primary"> + I've Saved the Token + +
+
+
+
+
+ <% end %> +
diff --git a/lib/towerops_web/live/equipment_live/form.ex b/lib/towerops_web/live/equipment_live/form.ex index 1b1daa39..1c5234c7 100644 --- a/lib/towerops_web/live/equipment_live/form.ex +++ b/lib/towerops_web/live/equipment_live/form.ex @@ -2,6 +2,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do @moduledoc false use ToweropsWeb, :live_view + alias Towerops.Agents alias Towerops.Equipment alias Towerops.Equipment.Equipment, as: EquipmentSchema alias Towerops.Sites @@ -11,6 +12,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do def mount(params, _session, socket) do organization = socket.assigns.current_organization sites = Sites.list_organization_sites(organization.id) + agents = Agents.list_organization_agent_tokens(organization.id) # Redirect to sites page if no sites exist if Enum.empty?(sites) do @@ -23,6 +25,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do socket |> assign(:organization, organization) |> assign(:available_sites, sites) + |> assign(:available_agents, agents) |> assign(:preselected_site_id, params["site_id"]) |> assign(:snmp_test_result, nil)} end @@ -68,11 +71,26 @@ defmodule ToweropsWeb.EquipmentLive.Form do defp apply_action(socket, :edit, %{"id" => id}) do equipment = Equipment.get_equipment!(id) - changeset = Equipment.change_equipment(equipment) + organization = socket.assigns.organization + + # Get current agent assignment if any + current_assignment = Agents.get_equipment_assignment(equipment.id) + + agent_token_id = + if current_assignment do + current_assignment.agent_token_id + else + # Use organization default if no explicit assignment + organization.default_agent_token_id + end + + # Add agent_token_id to the changeset data + equipment_with_agent = Map.put(equipment, :agent_token_id, agent_token_id) + changeset = Equipment.change_equipment(equipment, %{}) socket |> assign(:page_title, "Edit Equipment") - |> assign(:equipment, equipment) + |> assign(:equipment, equipment_with_agent) |> assign(:form, to_form(changeset)) end @@ -130,8 +148,15 @@ defmodule ToweropsWeb.EquipmentLive.Form do end defp save_equipment(socket, :new, equipment_params) do + # Extract agent_token_id before creating equipment + agent_token_id = Map.get(equipment_params, "agent_token_id") + equipment_params = Map.delete(equipment_params, "agent_token_id") + case Equipment.create_equipment(equipment_params) do {:ok, equipment} -> + # Handle agent assignment after equipment creation + handle_agent_assignment(equipment.id, agent_token_id) + flash_message = handle_equipment_creation(equipment) {:noreply, @@ -147,8 +172,15 @@ defmodule ToweropsWeb.EquipmentLive.Form do defp save_equipment(socket, :edit, equipment_params) do old_equipment = socket.assigns.equipment + # Extract agent_token_id before updating equipment + agent_token_id = Map.get(equipment_params, "agent_token_id") + equipment_params = Map.delete(equipment_params, "agent_token_id") + case Equipment.update_equipment(old_equipment, equipment_params) do {:ok, equipment} -> + # Handle agent assignment after equipment update + handle_agent_assignment(equipment.id, agent_token_id) + flash_message = handle_equipment_update(old_equipment, equipment) {:noreply, @@ -276,4 +308,20 @@ defmodule ToweropsWeb.EquipmentLive.Form do {:error, _} -> {:error, "Invalid IP address format"} end end + + defp handle_agent_assignment(equipment_id, agent_token_id) when is_binary(agent_token_id) do + # Only assign if agent_token_id is not empty + if agent_token_id == "" do + Agents.update_equipment_assignment(equipment_id, nil) + else + Agents.update_equipment_assignment(equipment_id, agent_token_id) + end + end + + defp handle_agent_assignment(equipment_id, nil) do + # No agent selected, remove any assignment + Agents.update_equipment_assignment(equipment_id, nil) + end + + defp handle_agent_assignment(_equipment_id, _), do: :ok end diff --git a/lib/towerops_web/live/equipment_live/form.html.heex b/lib/towerops_web/live/equipment_live/form.html.heex index 44742083..59351290 100644 --- a/lib/towerops_web/live/equipment_live/form.html.heex +++ b/lib/towerops_web/live/equipment_live/form.html.heex @@ -53,6 +53,19 @@ <.input field={@form[:monitoring_enabled]} type="checkbox" label="Enable Monitoring" /> + <%= if @available_agents != [] do %> + <.input + field={@form[:agent_token_id]} + type="select" + label="Remote Agent (optional)" + prompt="No agent - poll from server" + options={Enum.map(@available_agents, &{&1.name, &1.id})} + /> +

+ Assign this equipment to a remote agent for local SNMP polling. Leave empty to poll from the Towerops server. +

+ <% end %> +

SNMP Configuration

diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex new file mode 100644 index 00000000..f502bbfb --- /dev/null +++ b/lib/towerops_web/live/org/settings_live.ex @@ -0,0 +1,46 @@ +defmodule ToweropsWeb.Org.SettingsLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Agents + alias Towerops.Organizations + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_organization + available_agents = Agents.list_organization_agent_tokens(organization.id) + + changeset = Organizations.change_organization(organization) + + {:ok, + socket + |> assign(:organization, organization) + |> assign(:available_agents, available_agents) + |> assign(:form, to_form(changeset))} + end + + @impl true + def handle_event("validate", %{"organization" => org_params}, socket) do + changeset = + socket.assigns.organization + |> Organizations.change_organization(org_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset))} + end + + @impl true + def handle_event("save", %{"organization" => org_params}, socket) do + case Organizations.update_organization(socket.assigns.organization, org_params) do + {:ok, organization} -> + {:noreply, + socket + |> assign(:organization, organization) + |> put_flash(:info, "Organization settings updated successfully") + |> push_navigate(to: ~p"/orgs/#{organization.slug}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end +end diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex new file mode 100644 index 00000000..83f29b86 --- /dev/null +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -0,0 +1,67 @@ + +

+ <.link + navigate={~p"/orgs/#{@organization.slug}"} + class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Dashboard + +
+ + <.header> + Organization Settings + <:subtitle> + Manage your organization settings and defaults + + + +
+ <.form for={@form} id="organization-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:name]} type="text" label="Organization Name" required /> + + <%= if @available_agents != [] do %> +
+

Default Agent

+

+ Select a default agent for SNMP polling. This will be used for all equipment unless overridden at the equipment level. +

+ + <.input + field={@form[:default_agent_token_id]} + type="select" + label="Default Remote Agent" + prompt="No default agent - poll from server" + options={Enum.map(@available_agents, &{&1.name, &1.id})} + /> + +

+ You can override this setting for individual equipment in their settings. +

+
+ <% else %> +
+

Default Agent

+

+ No agents configured yet. + <.link + navigate={~p"/orgs/#{@organization.slug}/agents"} + class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Create an agent + + to enable remote polling. +

+
+ <% end %> + +
+ <.button phx-disable-with="Saving..." variant="primary">Save Settings + <.button navigate={~p"/orgs/#{@organization.slug}"}>Cancel +
+ +
+ diff --git a/lib/towerops_web/plugs/agent_auth.ex b/lib/towerops_web/plugs/agent_auth.ex new file mode 100644 index 00000000..adcedc50 --- /dev/null +++ b/lib/towerops_web/plugs/agent_auth.ex @@ -0,0 +1,60 @@ +defmodule ToweropsWeb.Plugs.AgentAuth do + @moduledoc """ + Plug for authenticating remote agent API requests. + + Validates Bearer tokens in the Authorization header and assigns the agent token + to the connection. Also updates the agent's last_seen_at timestamp asynchronously. + """ + + import Phoenix.Controller + import Plug.Conn + + alias Towerops.Agents + + @doc """ + Initializes the plug with options. + """ + def init(opts), do: opts + + @doc """ + Validates the agent token from the Authorization header. + + If valid, assigns :current_agent_token to the connection and updates heartbeat. + If invalid or missing, returns 401 Unauthorized and halts the connection. + """ + def call(conn, _opts) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> + case Agents.verify_agent_token(token) do + {:ok, agent_token} -> + conn + |> assign(:current_agent_token, agent_token) + |> update_heartbeat(agent_token) + + {:error, _} -> + unauthorized(conn) + end + + _ -> + unauthorized(conn) + end + end + + defp update_heartbeat(conn, agent_token) do + ip = to_string(:inet_parse.ntoa(conn.remote_ip)) + + Task.start(fn -> + Agents.update_agent_token_heartbeat(agent_token.id, ip) + end) + + conn + end + + defp unauthorized(conn) do + conn + |> put_status(:unauthorized) + |> put_view(json: ToweropsWeb.ErrorJSON) + |> render(:"401") + |> halt() + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 422429b1..6091da7c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -18,6 +18,11 @@ defmodule ToweropsWeb.Router do plug :accepts, ["json"] end + pipeline :agent_api do + plug :accepts, ["json"] + plug ToweropsWeb.Plugs.AgentAuth + end + # Health check endpoint for Kubernetes probes (no authentication required) scope "/", ToweropsWeb do get "/health", HealthController, :index @@ -29,6 +34,15 @@ defmodule ToweropsWeb.Router do get "/", PageController, :home end + # Agent API routes + scope "/api/v1/agent", ToweropsWeb.Api do + pipe_through :agent_api + + get "/config", AgentController, :get_config + post "/metrics", AgentController, :submit_metrics + post "/heartbeat", AgentController, :heartbeat + end + # WebAuthn API routes scope "/api/webauthn", ToweropsWeb do pipe_through [:browser] @@ -131,6 +145,7 @@ defmodule ToweropsWeb.Router do pipe_through [:browser, :require_authenticated_user, :load_current_organization] live "/", DashboardLive, :index + live "/settings", Org.SettingsLive, :index # Site routes live "/sites", SiteLive.Index, :index @@ -147,6 +162,9 @@ defmodule ToweropsWeb.Router do # Alert routes live "/alerts", AlertLive.Index, :index + + # Agent routes + live "/agents", AgentLive.Index, :index end end end diff --git a/priv/repo/migrations/20260109184838_create_agent_tokens.exs b/priv/repo/migrations/20260109184838_create_agent_tokens.exs new file mode 100644 index 00000000..21536a6d --- /dev/null +++ b/priv/repo/migrations/20260109184838_create_agent_tokens.exs @@ -0,0 +1,25 @@ +defmodule Towerops.Repo.Migrations.CreateAgentTokens do + use Ecto.Migration + + def change do + create table(:agent_tokens, primary_key: false) do + add :id, :binary_id, primary_key: true + add :token_hash, :binary, null: false + add :name, :string, null: false + add :last_seen_at, :utc_datetime + add :last_ip, :string + add :enabled, :boolean, default: true, null: false + add :metadata, :map, default: %{}, null: false + + add :organization_id, + references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + timestamps(type: :utc_datetime) + end + + create index(:agent_tokens, [:token_hash]) + create index(:agent_tokens, [:organization_id]) + create index(:agent_tokens, [:last_seen_at]) + end +end diff --git a/priv/repo/migrations/20260109184905_create_agent_assignments.exs b/priv/repo/migrations/20260109184905_create_agent_assignments.exs new file mode 100644 index 00000000..36a7f317 --- /dev/null +++ b/priv/repo/migrations/20260109184905_create_agent_assignments.exs @@ -0,0 +1,24 @@ +defmodule Towerops.Repo.Migrations.CreateAgentAssignments do + use Ecto.Migration + + def change do + create table(:agent_assignments, primary_key: false) do + add :id, :binary_id, primary_key: true + add :enabled, :boolean, default: true, null: false + + add :agent_token_id, + references(:agent_tokens, type: :binary_id, on_delete: :delete_all), + null: false + + add :equipment_id, + references(:equipment, type: :binary_id, on_delete: :delete_all), + null: false + + timestamps(type: :utc_datetime) + end + + create index(:agent_assignments, [:agent_token_id]) + create unique_index(:agent_assignments, [:equipment_id]) + create index(:agent_assignments, [:enabled]) + end +end diff --git a/priv/repo/migrations/20260109190858_add_default_agent_to_organizations.exs b/priv/repo/migrations/20260109190858_add_default_agent_to_organizations.exs new file mode 100644 index 00000000..513f3408 --- /dev/null +++ b/priv/repo/migrations/20260109190858_add_default_agent_to_organizations.exs @@ -0,0 +1,12 @@ +defmodule Towerops.Repo.Migrations.AddDefaultAgentToOrganizations do + use Ecto.Migration + + def change do + alter table(:organizations) do + add :default_agent_token_id, + references(:agent_tokens, type: :binary_id, on_delete: :nilify_all) + end + + create index(:organizations, [:default_agent_token_id]) + end +end diff --git a/test/support/fixtures/agents_fixtures.ex b/test/support/fixtures/agents_fixtures.ex new file mode 100644 index 00000000..ab4b57ef --- /dev/null +++ b/test/support/fixtures/agents_fixtures.ex @@ -0,0 +1,19 @@ +defmodule Towerops.AgentsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Towerops.Agents` context. + """ + + alias Towerops.Agents + + def unique_agent_name, do: "Test Agent #{System.unique_integer()}" + + def agent_token_fixture(organization_id, name \\ nil) do + name = name || unique_agent_name() + Agents.create_agent_token(organization_id, name) + end + + def agent_assignment_fixture(agent_token_id, equipment_id) do + Agents.assign_equipment_to_agent(agent_token_id, equipment_id) + end +end diff --git a/test/support/fixtures/organizations_fixtures.ex b/test/support/fixtures/organizations_fixtures.ex new file mode 100644 index 00000000..3c8c20cb --- /dev/null +++ b/test/support/fixtures/organizations_fixtures.ex @@ -0,0 +1,24 @@ +defmodule Towerops.OrganizationsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Towerops.Organizations` context. + """ + + alias Towerops.Organizations + + def unique_organization_name, do: "Test Org #{System.unique_integer()}" + + def valid_organization_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + name: unique_organization_name() + }) + end + + def organization_fixture(user_id, attrs \\ %{}) do + attrs = valid_organization_attributes(attrs) + + {:ok, organization} = Organizations.create_organization(attrs, user_id) + + organization + end +end diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs new file mode 100644 index 00000000..7c815077 --- /dev/null +++ b/test/towerops/agents_test.exs @@ -0,0 +1,225 @@ +defmodule Towerops.AgentsTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + + alias Towerops.Agents + alias Towerops.Agents.AgentAssignment + alias Towerops.Agents.AgentToken + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + %{organization: organization, user: user} + end + + describe "create_agent_token/2" do + test "creates agent token with valid organization and name", %{organization: org} do + assert {:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent") + assert is_binary(token) + assert byte_size(token) > 0 + assert agent_token.organization_id == org.id + assert agent_token.name == "Test Agent" + assert agent_token.enabled == true + assert is_binary(agent_token.token_hash) + end + + test "returns error with invalid data" do + assert {:error, changeset} = Agents.create_agent_token(nil, "") + refute changeset.valid? + end + end + + describe "list_organization_agent_tokens/1" do + test "returns all agent tokens for organization", %{organization: org1, user: user} do + {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id) + + {:ok, token1, _} = Agents.create_agent_token(org1.id, "Agent 1") + {:ok, token2, _} = Agents.create_agent_token(org1.id, "Agent 2") + {:ok, _token3, _} = Agents.create_agent_token(org2.id, "Agent 3") + + tokens = Agents.list_organization_agent_tokens(org1.id) + + assert length(tokens) == 2 + assert Enum.any?(tokens, &(&1.id == token1.id)) + assert Enum.any?(tokens, &(&1.id == token2.id)) + end + + test "returns empty list for organization with no tokens", %{organization: org} do + assert Agents.list_organization_agent_tokens(org.id) == [] + end + end + + describe "verify_agent_token/1" do + test "verifies valid token", %{organization: org} do + {:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent") + + assert {:ok, verified_token} = Agents.verify_agent_token(token) + assert verified_token.id == agent_token.id + end + + test "fails for invalid token" do + assert {:error, :invalid_token} = Agents.verify_agent_token("invalid_token") + end + + test "fails for revoked token", %{organization: org} do + {:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent") + {:ok, _} = Agents.revoke_agent_token(agent_token.id) + + assert {:error, :invalid_token} = Agents.verify_agent_token(token) + end + end + + describe "revoke_agent_token/1" do + test "disables agent token", %{organization: org} do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent") + + assert {:ok, revoked_token} = Agents.revoke_agent_token(agent_token.id) + assert revoked_token.enabled == false + end + end + + describe "update_agent_token_heartbeat/3" do + test "updates last_seen_at and metadata", %{organization: org} do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent") + + metadata = %{version: "0.1.0", hostname: "test-host"} + {1, _} = Agents.update_agent_token_heartbeat(agent_token.id, "192.168.1.1", metadata) + + updated_token = Repo.get!(AgentToken, agent_token.id) + assert updated_token.last_ip == "192.168.1.1" + assert updated_token.metadata["version"] == "0.1.0" + assert updated_token.metadata["hostname"] == "test-host" + assert updated_token.last_seen_at + end + end + + describe "assign_equipment_to_agent/2" do + setup %{organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: org.id + }) + + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Test Equipment", + ip_address: "192.168.1.1", + site_id: site.id + }) + + %{site: site, equipment: equipment} + end + + test "assigns equipment to agent", %{organization: org, equipment: equipment} do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent") + + assert {:ok, assignment} = + Agents.assign_equipment_to_agent(agent_token.id, equipment.id) + + assert assignment.agent_token_id == agent_token.id + assert assignment.equipment_id == equipment.id + assert assignment.enabled == true + end + + test "prevents assigning equipment to multiple agents", %{ + organization: org, + equipment: equipment + } do + {:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1") + {:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2") + + {:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment.id) + + assert {:error, changeset} = Agents.assign_equipment_to_agent(agent2.id, equipment.id) + assert "has already been taken" in errors_on(changeset).equipment_id + end + end + + describe "unassign_equipment/1" do + setup %{organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: org.id + }) + + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Test Equipment", + ip_address: "192.168.1.1", + site_id: site.id + }) + + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent") + + %{site: site, equipment: equipment, agent_token: agent_token} + end + + test "removes equipment assignment", %{agent_token: agent_token, equipment: equipment} do + {:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id) + {1, _} = Agents.unassign_equipment(equipment.id) + + assert Repo.get_by(AgentAssignment, equipment_id: equipment.id) == nil + end + end + + describe "list_agent_equipment/1" do + setup %{organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: org.id + }) + + {:ok, equipment1} = + Towerops.Equipment.create_equipment(%{ + name: "Test Equipment 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + {:ok, equipment2} = + Towerops.Equipment.create_equipment(%{ + name: "Test Equipment 2", + ip_address: "192.168.1.2", + site_id: site.id + }) + + {:ok, equipment3} = + Towerops.Equipment.create_equipment(%{ + name: "Test Equipment 3", + ip_address: "192.168.1.3", + site_id: site.id + }) + + %{site: site, equipment1: equipment1, equipment2: equipment2, equipment3: equipment3} + end + + test "returns equipment assigned to agent with preloads", %{ + organization: org, + equipment1: equipment1, + equipment2: equipment2, + equipment3: equipment3 + } do + {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent") + {:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id) + {:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment2.id) + + equipment_list = Agents.list_agent_equipment(agent_token.id) + + assert length(equipment_list) == 2 + assert Enum.any?(equipment_list, &(&1.id == equipment1.id)) + assert Enum.any?(equipment_list, &(&1.id == equipment2.id)) + refute Enum.any?(equipment_list, &(&1.id == equipment3.id)) + + # Check preloads + equipment = hd(equipment_list) + assert Ecto.assoc_loaded?(equipment.site) + end + end +end diff --git a/test/towerops_web/controllers/api/agent_controller_test.exs b/test/towerops_web/controllers/api/agent_controller_test.exs new file mode 100644 index 00000000..86c90e0a --- /dev/null +++ b/test/towerops_web/controllers/api/agent_controller_test.exs @@ -0,0 +1,201 @@ +defmodule ToweropsWeb.Api.AgentControllerTest do + use ToweropsWeb.ConnCase + + import Towerops.AccountsFixtures + + alias Towerops.Agents + alias Towerops.Repo + alias Towerops.Snmp.Device + alias Towerops.Snmp.Interface + alias Towerops.Snmp.Sensor + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, agent_token, token} = Agents.create_agent_token(organization.id, "Test Agent") + + %{organization: organization, agent_token: agent_token, token: token} + end + + describe "GET /api/v1/agent/config" do + test "requires authentication", %{conn: conn} do + conn = get(conn, ~p"/api/v1/agent/config") + assert json_response(conn, 401) + end + + test "returns config for authenticated agent with no equipment", %{conn: conn, token: token} do + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> get(~p"/api/v1/agent/config") + + assert %{ + "version" => "1.0", + "poll_interval_seconds" => 60, + "equipment" => [] + } = json_response(conn, 200) + end + + test "returns config with assigned equipment", %{ + conn: conn, + token: token, + organization: org, + agent_token: agent_token + } do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: org.id + }) + + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Test Router", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + check_interval_seconds: 120, + site_id: site.id + }) + + device = + %Device{} + |> Device.changeset(%{ + equipment_id: equipment.id, + sys_name: "Test Device", + sys_descr: "Test Description" + }) + |> Repo.insert!() + + sensor = + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: device.id, + sensor_type: "temperature", + sensor_index: "1", + sensor_oid: "1.2.3.4" + }) + |> Repo.insert!() + + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: device.id, + if_index: 1, + if_name: "GigabitEthernet0/1" + }) + |> Repo.insert!() + + {:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id) + + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> get(~p"/api/v1/agent/config") + + response = json_response(conn, 200) + + assert %{ + "version" => "1.0", + "poll_interval_seconds" => 60, + "equipment" => [equipment_config] + } = response + + assert equipment_config["id"] == equipment.id + assert equipment_config["name"] == "Test Router" + assert equipment_config["ip_address"] == "192.168.1.1" + assert equipment_config["snmp"]["enabled"] == true + assert equipment_config["snmp"]["version"] == "2c" + assert equipment_config["snmp"]["community"] == "public" + assert equipment_config["snmp"]["port"] == 161 + assert equipment_config["poll_interval_seconds"] == 120 + + assert length(equipment_config["sensors"]) == 1 + sensor_config = hd(equipment_config["sensors"]) + assert sensor_config["id"] == sensor.id + assert sensor_config["type"] == "temperature" + assert sensor_config["oid"] == "1.2.3.4" + + assert length(equipment_config["interfaces"]) == 1 + interface_config = hd(equipment_config["interfaces"]) + assert interface_config["id"] == interface.id + assert interface_config["if_index"] == 1 + assert interface_config["if_name"] == "GigabitEthernet0/1" + end + end + + describe "POST /api/v1/agent/metrics" do + test "requires authentication", %{conn: conn} do + conn = post(conn, ~p"/api/v1/agent/metrics", %{"metrics" => []}) + assert json_response(conn, 401) + end + + test "accepts valid metrics", %{conn: conn, token: token} do + metrics = [ + %{ + "type" => "sensor_reading", + "sensor_id" => Ecto.UUID.generate(), + "value" => 45.5, + "status" => "ok", + "timestamp" => DateTime.to_iso8601(DateTime.utc_now()) + }, + %{ + "type" => "interface_stat", + "interface_id" => Ecto.UUID.generate(), + "if_in_octets" => 1_234_567, + "if_out_octets" => 9_876_543, + "if_in_errors" => 0, + "if_out_errors" => 0, + "if_in_discards" => 0, + "if_out_discards" => 0, + "timestamp" => DateTime.to_iso8601(DateTime.utc_now()) + } + ] + + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> post(~p"/api/v1/agent/metrics", %{"metrics" => metrics}) + + assert %{"status" => "accepted", "received" => 2} = json_response(conn, 200) + end + end + + describe "POST /api/v1/agent/heartbeat" do + test "requires authentication", %{conn: conn} do + conn = post(conn, ~p"/api/v1/agent/heartbeat", %{}) + assert json_response(conn, 401) + end + + test "updates heartbeat with metadata", %{ + conn: conn, + token: token, + agent_token: agent_token + } do + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> post(~p"/api/v1/agent/heartbeat", %{ + "version" => "0.1.0", + "hostname" => "test-agent", + "uptime_seconds" => 3600 + }) + + assert %{"status" => "ok"} = json_response(conn, 200) + + # Give the async task time to complete + Process.sleep(100) + + updated_token = Repo.get!(Agents.AgentToken, agent_token.id) + assert updated_token.last_seen_at + assert updated_token.metadata["version"] == "0.1.0" + assert updated_token.metadata["hostname"] == "test-agent" + assert updated_token.metadata["uptime_seconds"] == 3600 + end + end +end diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs new file mode 100644 index 00000000..f1416383 --- /dev/null +++ b/test/towerops_web/live/agent_live_test.exs @@ -0,0 +1,88 @@ +defmodule ToweropsWeb.AgentLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + + alias Towerops.Agents + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + %{organization: organization} + end + + describe "Index" do + test "lists all agent tokens", %{conn: conn, organization: organization} do + {:ok, _agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1") + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/agents") + + assert html =~ "Remote Agents" + assert html =~ "Agent 1" + end + + test "displays empty state when no agents", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/agents") + + assert html =~ "No agents" + assert html =~ "Get started by creating your first remote agent" + end + + test "creates new agent and shows token modal", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents") + + html = + view + |> form("#new-agent-form form", %{name: "Test Agent"}) + |> render_submit() + + assert html =~ "Agent Created Successfully" + assert html =~ "Test Agent" + assert html =~ "Copy this token now" + end + + test "revokes agent", %{conn: conn, organization: organization} do + {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1") + + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents") + + html = + view + |> element("button", "Revoke") + |> render_click() + + assert html =~ "Revoked" + + # Verify agent was actually revoked + updated_token = Towerops.Repo.get!(Agents.AgentToken, agent_token.id) + refute updated_token.enabled + end + + test "closes token modal", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents") + + # Create agent to show modal + view + |> form("#new-agent-form form", %{name: "Test Agent"}) + |> render_submit() + + # Close modal + html = + view + |> element("button", "I've Saved the Token") + |> render_click() + + refute html =~ "Agent Created Successfully" + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/agents") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end diff --git a/test/towerops_web/live/org/settings_live_test.exs b/test/towerops_web/live/org/settings_live_test.exs new file mode 100644 index 00000000..95b7eef8 --- /dev/null +++ b/test/towerops_web/live/org/settings_live_test.exs @@ -0,0 +1,140 @@ +defmodule ToweropsWeb.Org.SettingsLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.AgentsFixtures + import Towerops.OrganizationsFixtures + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + %{user: user, organization: organization} + end + + describe "Settings page" do + test "displays organization settings form", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + assert html =~ "Organization Settings" + assert html =~ "Organization Name" + assert html =~ org.name + end + + test "shows no agents message when no agents exist", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + assert html =~ "No agents configured yet" + assert html =~ "Create an agent" + end + + test "shows default agent dropdown when agents exist", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent") + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + assert html =~ "Default Remote Agent" + assert html =~ agent_token.name + assert html =~ "No default agent - poll from server" + end + + test "updates organization name successfully", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + assert view + |> form("#organization-form", organization: %{name: "Updated Name"}) + |> render_submit() + + assert_redirected(view, ~p"/orgs/#{org.slug}") + + # Verify the organization was actually updated + updated_org = Towerops.Organizations.get_organization!(org.id) + assert updated_org.name == "Updated Name" + end + + test "sets default agent for organization", %{conn: conn, user: user, organization: org} do + {:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent") + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + assert view + |> form("#organization-form", organization: %{default_agent_token_id: agent_token.id}) + |> render_submit() + + assert_redirected(view, ~p"/orgs/#{org.slug}") + + # Verify the default agent was set + updated_org = Towerops.Organizations.get_organization!(org.id) + assert updated_org.default_agent_token_id == agent_token.id + end + + test "clears default agent for organization", %{conn: conn, user: user, organization: org} do + {:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent") + + # First set a default agent + {:ok, org} = + Towerops.Organizations.update_organization(org, %{ + default_agent_token_id: agent_token.id + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + # Now clear it + assert view + |> form("#organization-form", organization: %{default_agent_token_id: ""}) + |> render_submit() + + assert_redirected(view, ~p"/orgs/#{org.slug}") + + # Verify the default agent was cleared + updated_org = Towerops.Organizations.get_organization!(org.id) + assert updated_org.default_agent_token_id == nil + end + + test "validates organization name", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + html = + view + |> form("#organization-form", organization: %{name: ""}) + |> render_change() + + assert html =~ "can't be blank" + end + + test "requires authentication", %{conn: conn, organization: org} do + assert {:error, {:redirect, %{to: "/users/log-in"}}} = + live(conn, ~p"/orgs/#{org.slug}/settings") + end + end +end