- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
326 lines
9.1 KiB
Elixir
326 lines
9.1 KiB
Elixir
defmodule ToweropsWeb.Api.V1.DevicesController do
|
|
@moduledoc """
|
|
API controller for managing devices.
|
|
|
|
All endpoints require API token authentication and operations are scoped
|
|
to the organization associated with the token.
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
|
|
|
alias Towerops.Devices
|
|
alias Towerops.Devices.Device
|
|
alias Towerops.Sites.Site
|
|
alias Towerops.Workers.DiscoveryWorker
|
|
alias ToweropsWeb.ScopedResource
|
|
|
|
@doc """
|
|
GET /api/v1/devices
|
|
|
|
Lists all devices for the authenticated organization.
|
|
|
|
Query params:
|
|
- site_id: Filter by site (optional)
|
|
|
|
Response:
|
|
{
|
|
"devices": [
|
|
{
|
|
"id": "uuid",
|
|
"name": "Core Router",
|
|
"ip_address": "192.168.1.1",
|
|
"site_id": "uuid",
|
|
"monitoring_enabled": true,
|
|
"snmp_enabled": true,
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
def index(conn, params) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
devices =
|
|
organization_id
|
|
|> Devices.list_organization_devices(params)
|
|
|> Enum.map(&format_device/1)
|
|
|
|
json(conn, %{devices: devices})
|
|
end
|
|
|
|
@doc """
|
|
POST /api/v1/devices
|
|
|
|
Creates a new device. The device can be assigned to a site or directly to the organization.
|
|
|
|
Request body:
|
|
{
|
|
"device": {
|
|
"site_id": "uuid", # optional - if not provided, device is assigned directly to organization
|
|
"organization_id": "uuid", # optional - defaults to authenticated organization if not provided
|
|
"name": "Core Router",
|
|
"ip_address": "192.168.1.1",
|
|
"description": "Main router", # optional
|
|
"monitoring_enabled": true, # optional, default true
|
|
"snmp_enabled": true, # optional, default true
|
|
"snmp_version": "2c", # optional, default "2c" (can be "1", "2c", or "3")
|
|
"snmp_community": "public", # optional, inherits from site/org if not set
|
|
"snmp_port": 161, # optional, default 161
|
|
# SNMPv3 fields (only used when snmp_version is "3"):
|
|
"snmpv3_security_level": "authPriv", # optional, one of: noAuthNoPriv, authNoPriv, authPriv
|
|
"snmpv3_username": "snmpuser", # optional
|
|
"snmpv3_auth_protocol": "SHA-256", # optional, one of: MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512
|
|
"snmpv3_auth_password": "authpass", # optional
|
|
"snmpv3_priv_protocol": "AES", # optional, one of: DES, AES, AES-192, AES-256
|
|
"snmpv3_priv_password": "privpass" # optional
|
|
}
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"id": "uuid",
|
|
"name": "Core Router",
|
|
"ip_address": "192.168.1.1",
|
|
"site_id": "uuid",
|
|
"organization_id": "uuid",
|
|
"monitoring_enabled": true,
|
|
"snmp_enabled": true,
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
"""
|
|
def create(conn, %{"device" => device_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
current_user = conn.assigns[:current_user]
|
|
|
|
device_params = default_organization_id(device_params, organization_id)
|
|
|
|
case verify_site_access(device_params["site_id"], organization_id) do
|
|
:ok ->
|
|
create_and_discover_device(conn, device_params, current_user)
|
|
|
|
{:error, reason} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: reason})
|
|
end
|
|
end
|
|
|
|
def create(conn, _params) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing 'device' parameter"})
|
|
end
|
|
|
|
@doc """
|
|
GET /api/v1/devices/:id
|
|
|
|
Gets a single device by ID.
|
|
|
|
Response:
|
|
{
|
|
"id": "uuid",
|
|
"name": "Core Router",
|
|
"ip_address": "192.168.1.1",
|
|
"site_id": "uuid",
|
|
"description": "Main router",
|
|
"monitoring_enabled": true,
|
|
"check_interval_seconds": 300,
|
|
"snmp_enabled": true,
|
|
"snmp_version": "2c",
|
|
"snmp_port": 161,
|
|
"inserted_at": "2026-01-15T19:44:25Z"
|
|
}
|
|
"""
|
|
def show(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch(Device, id, organization_id) do
|
|
{:ok, device} ->
|
|
json(conn, format_device_details(device))
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this device"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Device not found"})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
PATCH /api/v1/devices/:id
|
|
|
|
Updates a device.
|
|
|
|
Request body:
|
|
{
|
|
"device": {
|
|
"name": "Updated Name",
|
|
"ip_address": "192.168.1.2"
|
|
}
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"id": "uuid",
|
|
"name": "Updated Name",
|
|
"ip_address": "192.168.1.2",
|
|
...
|
|
}
|
|
"""
|
|
def update(conn, %{"id" => id, "device" => device_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch(Device, id, organization_id) do
|
|
{:ok, device} ->
|
|
case Devices.update_device(device, device_params) do
|
|
{:ok, updated_device} ->
|
|
json(conn, format_device_details(updated_device))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this device"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Device not found"})
|
|
end
|
|
end
|
|
|
|
def update(conn, _params) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing 'device' parameter"})
|
|
end
|
|
|
|
@doc """
|
|
DELETE /api/v1/devices/:id
|
|
|
|
Deletes a device.
|
|
|
|
Response:
|
|
{
|
|
"success": true
|
|
}
|
|
"""
|
|
def delete(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case ScopedResource.fetch(Device, id, organization_id) do
|
|
{:ok, device} ->
|
|
case Devices.delete_device(device) do
|
|
{:ok, _device} ->
|
|
json(conn, %{success: true})
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
{:error, :forbidden} ->
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Access denied to this device"})
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Device not found"})
|
|
end
|
|
end
|
|
|
|
defp default_organization_id(device_params, organization_id) do
|
|
Map.put(device_params, "organization_id", organization_id)
|
|
end
|
|
|
|
defp create_and_discover_device(conn, device_params, current_user) do
|
|
opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: []
|
|
|
|
case Devices.create_device(device_params, opts) do
|
|
{:ok, device} ->
|
|
maybe_enqueue_discovery(device)
|
|
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(format_device(device))
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
end
|
|
|
|
defp maybe_enqueue_discovery(device) do
|
|
if device.snmp_enabled do
|
|
DiscoveryWorker.enqueue(device.id)
|
|
end
|
|
end
|
|
|
|
defp verify_site_access(nil, _organization_id), do: :ok
|
|
|
|
defp verify_site_access(site_id, organization_id) do
|
|
case ScopedResource.fetch(Site, site_id, organization_id) do
|
|
{:ok, _site} -> :ok
|
|
{:error, :forbidden} -> {:error, "Access denied to this site"}
|
|
{:error, :not_found} -> {:error, "Site not found"}
|
|
end
|
|
end
|
|
|
|
defp format_device(device) do
|
|
%{
|
|
id: device.id,
|
|
name: device.name,
|
|
ip_address: device.ip_address,
|
|
site_id: device.site_id,
|
|
organization_id: device.organization_id,
|
|
monitoring_enabled: device.monitoring_enabled,
|
|
snmp_enabled: device.snmp_enabled,
|
|
snmp_version: device.snmp_version,
|
|
snmp_port: device.snmp_port,
|
|
snmpv3_security_level: device.snmpv3_security_level,
|
|
snmpv3_username: device.snmpv3_username,
|
|
snmpv3_auth_protocol: device.snmpv3_auth_protocol,
|
|
snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password),
|
|
snmpv3_priv_protocol: device.snmpv3_priv_protocol,
|
|
snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password),
|
|
inserted_at: device.inserted_at
|
|
}
|
|
end
|
|
|
|
defp format_device_details(device) do
|
|
%{
|
|
id: device.id,
|
|
name: device.name,
|
|
ip_address: device.ip_address,
|
|
site_id: device.site_id,
|
|
organization_id: device.organization_id,
|
|
description: device.description,
|
|
monitoring_enabled: device.monitoring_enabled,
|
|
check_interval_seconds: device.check_interval_seconds,
|
|
snmp_enabled: device.snmp_enabled,
|
|
snmp_version: device.snmp_version,
|
|
snmp_port: device.snmp_port,
|
|
snmpv3_security_level: device.snmpv3_security_level,
|
|
snmpv3_username: device.snmpv3_username,
|
|
snmpv3_auth_protocol: device.snmpv3_auth_protocol,
|
|
snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password),
|
|
snmpv3_priv_protocol: device.snmpv3_priv_protocol,
|
|
snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password),
|
|
inserted_at: device.inserted_at
|
|
}
|
|
end
|
|
end
|