This commit is contained in:
Graham McIntire 2026-01-16 13:15:59 -06:00
parent 6908661bd1
commit 3539e36f12
No known key found for this signature in database
14 changed files with 857 additions and 413 deletions

View file

@ -68,7 +68,7 @@ const SensorChart: SensorChartHook = {
const unit = this.el.dataset.unit || '%'
const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true'
const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true'
const chartType = unit === 'bps' ? 'bar' : 'line'
const isTrafficChart = unit === 'bps'
// Generate colors for each dataset
const colors = [
@ -83,18 +83,20 @@ const SensorChart: SensorChartHook = {
]
const datasets: ChartDataset[] = data.datasets.map((dataset, index) => {
const color = colors[index % colors.length]
const baseConfig: ChartDataset = {
...dataset,
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length].replace('rgb', 'rgba').replace(')', ', 0.5)'),
borderWidth: chartType === 'bar' ? 0 : 2,
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
borderWidth: 2,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
}
// Add line-specific properties
if (chartType === 'line') {
baseConfig.tension = 0.4
baseConfig.pointRadius = 0
baseConfig.pointHoverRadius = 4
// Add fill for traffic charts to create area effect
if (isTrafficChart) {
baseConfig.fill = 'origin' // Fill to the zero line
}
return baseConfig
@ -210,14 +212,11 @@ const SensorChart: SensorChartHook = {
const twentyFourHoursAgo = now - (24 * 60 * 60 * 1000)
this.chart = new Chart(ctx, {
type: chartType,
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
// Make bars connect with no gaps for traffic charts
barPercentage: chartType === 'bar' ? 1.0 : undefined,
categoryPercentage: chartType === 'bar' ? 1.0 : undefined,
interaction: {
mode: 'index',
intersect: false,

View file

@ -114,6 +114,11 @@ defmodule Towerops.Agent.Metric do
type: Towerops.Agent.InterfaceStat,
json_name: "interfaceStat",
oneof: 0
field :neighbor_discovery, 3,
type: Towerops.Agent.NeighborDiscovery,
json_name: "neighborDiscovery",
oneof: 0
end
defmodule Towerops.Agent.SensorReading do
@ -148,6 +153,27 @@ defmodule Towerops.Agent.InterfaceStat do
field :timestamp, 8, type: :int64
end
defmodule Towerops.Agent.NeighborDiscovery do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.NeighborDiscovery",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :interface_id, 1, type: :string, json_name: "interfaceId"
field :protocol, 2, type: :string
field :remote_chassis_id, 3, type: :string, json_name: "remoteChassisId"
field :remote_system_name, 4, type: :string, json_name: "remoteSystemName"
field :remote_system_description, 5, type: :string, json_name: "remoteSystemDescription"
field :remote_platform, 6, type: :string, json_name: "remotePlatform"
field :remote_port_id, 7, type: :string, json_name: "remotePortId"
field :remote_port_description, 8, type: :string, json_name: "remotePortDescription"
field :remote_address, 9, type: :string, json_name: "remoteAddress"
field :remote_capabilities, 10, repeated: true, type: :string, json_name: "remoteCapabilities"
field :timestamp, 11, type: :int64
end
defmodule Towerops.Agent.HeartbeatMetadata do
@moduledoc false

View file

@ -14,6 +14,7 @@ defmodule Towerops.Snmp do
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
@ -343,4 +344,55 @@ defmodule Towerops.Snmp do
|> InterfaceStat.changeset(attrs)
|> Repo.insert()
end
# Neighbor queries
@doc """
Lists all neighbors for a piece of equipment.
"""
def list_neighbors(equipment_id) do
Neighbor
|> where([n], n.equipment_id == ^equipment_id)
|> preload(:interface)
|> order_by([n], [n.protocol, n.remote_system_name])
|> Repo.all()
end
@doc """
Gets a specific neighbor.
"""
def get_neighbor(neighbor_id) do
Repo.get(Neighbor, neighbor_id)
end
@doc """
Creates or updates a neighbor record.
"""
def upsert_neighbor(attrs) do
case Repo.get_by(Neighbor,
interface_id: attrs.interface_id,
remote_chassis_id: attrs.remote_chassis_id,
protocol: attrs.protocol
) do
nil ->
%Neighbor{}
|> Neighbor.changeset(attrs)
|> Repo.insert()
neighbor ->
neighbor
|> Neighbor.changeset(attrs)
|> Repo.update()
end
end
@doc """
Deletes stale neighbors that haven't been seen since the given datetime.
"""
def delete_stale_neighbors(equipment_id, cutoff_datetime) do
Neighbor
|> where([n], n.equipment_id == ^equipment_id)
|> where([n], n.last_discovered_at < ^cutoff_datetime)
|> Repo.delete_all()
end
end

View file

@ -19,6 +19,7 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Cisco
alias Towerops.Snmp.Profiles.Mikrotik
@ -104,7 +105,9 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
{:ok, sensors} <- discover_sensors(client_opts, profile),
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors) do
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, device.interfaces),
:ok <- save_neighbors(equipment.id, neighbors) do
update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
@ -311,4 +314,19 @@ defmodule Towerops.Snmp.Discovery do
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()
end
@spec save_neighbors(binary(), [map()]) :: :ok
defp save_neighbors(equipment_id, neighbors) do
# Delete stale neighbors (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Towerops.Snmp.delete_stale_neighbors(equipment_id, cutoff)
# Upsert each discovered neighbor
Enum.each(neighbors, fn neighbor_data ->
Towerops.Snmp.upsert_neighbor(neighbor_data)
end)
Logger.info("Saved #{length(neighbors)} neighbors for equipment #{equipment_id}")
:ok
end
end

View file

@ -0,0 +1,55 @@
defmodule Towerops.Snmp.Neighbor do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_neighbors" do
field :protocol, :string
field :remote_chassis_id, :string
field :remote_system_name, :string
field :remote_system_description, :string
field :remote_platform, :string
field :remote_port_id, :string
field :remote_port_description, :string
field :remote_address, :string
field :remote_capabilities, {:array, :string}, default: []
field :last_discovered_at, :utc_datetime
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :interface, Towerops.Snmp.Interface
timestamps(type: :utc_datetime)
end
@doc false
def changeset(neighbor, attrs) do
neighbor
|> cast(attrs, [
:equipment_id,
:interface_id,
:protocol,
:remote_chassis_id,
:remote_system_name,
:remote_system_description,
:remote_platform,
:remote_port_id,
:remote_port_description,
:remote_address,
:remote_capabilities,
:last_discovered_at
])
|> validate_required([
:equipment_id,
:interface_id,
:protocol,
:last_discovered_at
])
|> validate_inclusion(:protocol, ["lldp", "cdp"])
|> foreign_key_constraint(:equipment_id)
|> foreign_key_constraint(:interface_id)
|> unique_constraint([:interface_id, :remote_chassis_id, :protocol])
end
end

View file

@ -0,0 +1,301 @@
defmodule Towerops.Snmp.NeighborDiscovery do
@moduledoc """
Discovers network neighbors via LLDP and CDP protocols.
Supports:
- LLDP (Link Layer Discovery Protocol) via LLDP-MIB
- CDP (Cisco Discovery Protocol) via CISCO-CDP-MIB
"""
alias Towerops.Snmp.Client
require Logger
@type neighbor_data :: %{
required(:protocol) => String.t(),
required(:interface_id) => binary(),
required(:equipment_id) => binary(),
optional(:remote_chassis_id) => String.t(),
optional(:remote_system_name) => String.t(),
optional(:remote_system_description) => String.t(),
optional(:remote_platform) => String.t(),
optional(:remote_port_id) => String.t(),
optional(:remote_port_description) => String.t(),
optional(:remote_address) => String.t(),
optional(:remote_capabilities) => [String.t()],
required(:last_discovered_at) => DateTime.t()
}
@doc """
Discovers all neighbors (LLDP and CDP) for a device.
Returns {:ok, neighbors} where neighbors is a list of neighbor_data maps.
"""
@spec discover_neighbors(Client.connection_opts(), list(map())) ::
{:ok, [neighbor_data()]}
def discover_neighbors(client_opts, interfaces) do
lldp_neighbors = discover_lldp_neighbors(client_opts, interfaces)
cdp_neighbors = discover_cdp_neighbors(client_opts, interfaces)
{:ok, lldp_neighbors ++ cdp_neighbors}
end
# LLDP Discovery (IEEE 802.1AB)
@lldp_rem_table_oid "1.0.8802.1.1.2.1.4.1.1"
defp discover_lldp_neighbors(client_opts, interfaces) do
case Client.walk(client_opts, @lldp_rem_table_oid) do
{:ok, entries} when entries != [] ->
parse_lldp_neighbors(entries, interfaces)
{:ok, []} ->
Logger.debug("No LLDP neighbors found")
[]
{:error, reason} ->
Logger.debug("LLDP discovery failed: #{inspect(reason)}")
[]
end
end
defp parse_lldp_neighbors(entries, interfaces) do
# Group by local port index (time, local_port_num, rem_index)
# OIDs are in format: 1.0.8802.1.1.2.1.4.1.1.X.time.local_port.rem_index
grouped =
Enum.group_by(entries, fn %{oid: oid} ->
# Extract local port num (second to last index) and remote index (last index)
parts = String.split(oid, ".")
[rem_index, local_port, _time | _] = Enum.reverse(parts)
"#{local_port}.#{rem_index}"
end)
grouped
|> Enum.map(fn {_key, neighbor_entries} ->
build_lldp_neighbor(neighbor_entries, interfaces)
end)
|> Enum.reject(&is_nil/1)
end
defp build_lldp_neighbor(entries, interfaces) do
neighbor_map =
Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
parts = String.split(oid, ".")
field_oid = Enum.at(parts, 11)
parse_lldp_field(field_oid, value, oid, acc)
end)
build_neighbor_record("lldp", neighbor_map, interfaces, neighbor_map[:local_port])
end
defp parse_lldp_field("4", value, _oid, acc), do: Map.put(acc, :remote_chassis_id, format_chassis_id(value))
defp parse_lldp_field("5", value, _oid, acc), do: Map.put(acc, :remote_port_id, format_port_id(value))
defp parse_lldp_field("6", value, _oid, acc), do: Map.put(acc, :remote_port_description, to_string_safe(value))
defp parse_lldp_field("7", value, _oid, acc), do: Map.put(acc, :remote_system_name, to_string_safe(value))
defp parse_lldp_field("8", value, _oid, acc), do: Map.put(acc, :remote_system_description, to_string_safe(value))
defp parse_lldp_field("12", value, _oid, acc), do: Map.put(acc, :remote_capabilities, parse_lldp_capabilities(value))
defp parse_lldp_field(_field_oid, _value, oid, acc), do: extract_local_port(acc, oid)
defp extract_local_port(acc, oid) do
parts = String.split(oid, ".")
[_rem_index, local_port | _] = Enum.reverse(parts)
Map.put(acc, :local_port, String.to_integer(local_port))
end
defp build_neighbor_record(_protocol, _neighbor_map, _interfaces, nil), do: nil
defp build_neighbor_record(protocol, neighbor_map, interfaces, if_index) do
case find_interface_by_index(if_index, interfaces) do
nil ->
nil
interface ->
%{
protocol: protocol,
interface_id: interface.id,
equipment_id: interface.equipment_id,
remote_chassis_id: neighbor_map[:remote_chassis_id],
remote_system_name: neighbor_map[:remote_system_name],
remote_system_description: neighbor_map[:remote_system_description],
remote_platform: neighbor_map[:remote_platform],
remote_port_id: neighbor_map[:remote_port_id],
remote_port_description: neighbor_map[:remote_port_description],
remote_address: neighbor_map[:remote_address],
remote_capabilities: neighbor_map[:remote_capabilities] || [],
last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
}
end
end
# CDP Discovery (Cisco proprietary)
@cdp_cache_table_oid "1.3.6.1.4.1.9.9.23.1.2.1.1"
defp discover_cdp_neighbors(client_opts, interfaces) do
case Client.walk(client_opts, @cdp_cache_table_oid) do
{:ok, entries} when entries != [] ->
parse_cdp_neighbors(entries, interfaces)
{:ok, []} ->
Logger.debug("No CDP neighbors found")
[]
{:error, reason} ->
Logger.debug("CDP discovery failed: #{inspect(reason)}")
[]
end
end
defp parse_cdp_neighbors(entries, interfaces) do
# Group by interface index and device index
# OIDs are in format: 1.3.6.1.4.1.9.9.23.1.2.1.1.X.if_index.device_index
grouped =
Enum.group_by(entries, fn %{oid: oid} ->
parts = String.split(oid, ".")
# Get last two parts (interface index and device index)
[device_idx | rest] = Enum.reverse(parts)
[if_idx | _] = rest
"#{if_idx}.#{device_idx}"
end)
grouped
|> Enum.map(fn {_key, neighbor_entries} ->
build_cdp_neighbor(neighbor_entries, interfaces)
end)
|> Enum.reject(&is_nil/1)
end
defp build_cdp_neighbor(entries, interfaces) do
neighbor_map =
Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
parts = String.split(oid, ".")
field_oid = Enum.at(parts, 12)
parse_cdp_field(field_oid, value, oid, acc)
end)
build_neighbor_record("cdp", neighbor_map, interfaces, neighbor_map[:local_if_index])
end
defp parse_cdp_field("4", value, _oid, acc), do: Map.put(acc, :remote_address, format_ip_address(value))
defp parse_cdp_field("6", value, _oid, acc), do: Map.put(acc, :remote_system_name, to_string_safe(value))
defp parse_cdp_field("7", value, _oid, acc), do: Map.put(acc, :remote_port_id, to_string_safe(value))
defp parse_cdp_field("8", value, _oid, acc), do: Map.put(acc, :remote_platform, to_string_safe(value))
defp parse_cdp_field("9", value, _oid, acc), do: Map.put(acc, :remote_capabilities, parse_cdp_capabilities(value))
defp parse_cdp_field(_field_oid, _value, oid, acc), do: extract_cdp_local_interface(acc, oid)
defp extract_cdp_local_interface(acc, oid) do
parts = String.split(oid, ".")
[_device_idx, if_idx | _] = Enum.reverse(parts)
Map.put(acc, :local_if_index, String.to_integer(if_idx))
end
# Helper Functions
defp find_interface_by_index(nil, _interfaces), do: nil
defp find_interface_by_index(if_index, interfaces) do
Enum.find(interfaces, fn interface ->
interface.if_index == if_index
end)
end
defp format_chassis_id(value) when is_binary(value) do
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
defp format_chassis_id(value), do: to_string_safe(value)
defp format_port_id(value) when is_binary(value) do
# Try to convert to string first, if it fails use MAC format
if String.valid?(value) do
value
else
format_chassis_id(value)
end
end
defp format_port_id(value), do: to_string_safe(value)
defp format_ip_address(value) when is_binary(value) and byte_size(value) == 4 do
value
|> :binary.bin_to_list()
|> Enum.join(".")
end
defp format_ip_address(value), do: to_string_safe(value)
defp to_string_safe(value) when is_binary(value), do: String.trim(value)
defp to_string_safe(value) when is_integer(value), do: Integer.to_string(value)
defp to_string_safe(value), do: inspect(value)
# LLDP capabilities bitmap (from LLDP-MIB)
@lldp_cap_other 0x01
@lldp_cap_repeater 0x02
@lldp_cap_bridge 0x04
@lldp_cap_wlan_ap 0x08
@lldp_cap_router 0x10
@lldp_cap_telephone 0x20
@lldp_cap_docsis 0x40
@lldp_cap_station 0x80
defp parse_lldp_capabilities(value) when is_binary(value) do
<<caps::integer-size(16)>> = value
[]
|> maybe_add_cap(caps, @lldp_cap_other, "other")
|> maybe_add_cap(caps, @lldp_cap_repeater, "repeater")
|> maybe_add_cap(caps, @lldp_cap_bridge, "bridge")
|> maybe_add_cap(caps, @lldp_cap_wlan_ap, "wlan-ap")
|> maybe_add_cap(caps, @lldp_cap_router, "router")
|> maybe_add_cap(caps, @lldp_cap_telephone, "telephone")
|> maybe_add_cap(caps, @lldp_cap_docsis, "docsis")
|> maybe_add_cap(caps, @lldp_cap_station, "station")
end
defp parse_lldp_capabilities(_value), do: []
# CDP capabilities bitmap (from CISCO-CDP-MIB)
@cdp_cap_router 0x01
@cdp_cap_trans_bridge 0x02
@cdp_cap_source_bridge 0x04
@cdp_cap_switch 0x08
@cdp_cap_host 0x10
@cdp_cap_igmp 0x20
@cdp_cap_repeater 0x40
defp parse_cdp_capabilities(value) when is_binary(value) and byte_size(value) == 1 do
<<caps::integer-size(8)>> = value
[]
|> maybe_add_cap(caps, @cdp_cap_router, "router")
|> maybe_add_cap(caps, @cdp_cap_trans_bridge, "trans-bridge")
|> maybe_add_cap(caps, @cdp_cap_source_bridge, "source-bridge")
|> maybe_add_cap(caps, @cdp_cap_switch, "switch")
|> maybe_add_cap(caps, @cdp_cap_host, "host")
|> maybe_add_cap(caps, @cdp_cap_igmp, "igmp")
|> maybe_add_cap(caps, @cdp_cap_repeater, "repeater")
end
defp parse_cdp_capabilities(_value), do: []
defp maybe_add_cap(list, caps, bit_mask, name) do
if Bitwise.band(caps, bit_mask) == 0 do
list
else
[name | list]
end
end
end

View file

@ -228,32 +228,58 @@ defmodule ToweropsWeb.Api.AgentController do
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"])
})
Enum.each(metrics, &process_single_metric/1)
end
%{"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"])
})
defp process_single_metric(%{"type" => "sensor_reading"} = m) do
Snmp.create_sensor_reading(%{
sensor_id: m["sensor_id"],
value: m["value"],
status: m["status"] || "ok",
checked_at: parse_timestamp(m["timestamp"])
})
end
_ ->
:ok
end
end)
defp process_single_metric(%{"type" => "interface_stat"} = m) do
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"])
})
end
defp process_single_metric(%{"type" => "neighbor_discovery"} = m) do
process_neighbor_discovery(m)
end
defp process_single_metric(_), do: :ok
defp process_neighbor_discovery(m) do
case Snmp.get_interface(m["interface_id"]) do
nil ->
:ok
interface ->
Snmp.upsert_neighbor(%{
interface_id: m["interface_id"],
equipment_id: interface.equipment_id,
protocol: m["protocol"],
remote_chassis_id: m["remote_chassis_id"],
remote_system_name: m["remote_system_name"],
remote_system_description: m["remote_system_description"],
remote_platform: m["remote_platform"],
remote_port_id: m["remote_port_id"],
remote_port_description: m["remote_port_description"],
remote_address: m["remote_address"],
remote_capabilities: m["remote_capabilities"] || [],
last_discovered_at: parse_timestamp(m["timestamp"])
})
end
end
defp convert_protobuf_metrics(metrics) do
@ -282,6 +308,22 @@ defmodule ToweropsWeb.Api.AgentController do
"timestamp" => is.timestamp
}
{:neighbor_discovery, nd} ->
%{
"type" => "neighbor_discovery",
"interface_id" => nd.interface_id,
"protocol" => nd.protocol,
"remote_chassis_id" => nd.remote_chassis_id,
"remote_system_name" => nd.remote_system_name,
"remote_system_description" => nd.remote_system_description,
"remote_platform" => nd.remote_platform,
"remote_port_id" => nd.remote_port_id,
"remote_port_description" => nd.remote_port_description,
"remote_address" => nd.remote_address,
"remote_capabilities" => nd.remote_capabilities,
"timestamp" => nd.timestamp
}
_ ->
nil
end

View file

@ -283,15 +283,22 @@ defmodule ToweropsWeb.Api.MobileController do
defp format_alert(alert) do
%{
id: alert.id,
severity: alert.severity,
status: alert.status,
status: format_alert_status(alert),
message: alert.message,
equipment_name: alert.equipment && alert.equipment.name,
equipment_id: alert.equipment_id,
occurred_at: alert.occurred_at
occurred_at: alert.triggered_at
}
end
defp format_alert_status(alert) do
cond do
alert.resolved_at != nil -> "resolved"
alert.acknowledged_at != nil -> "acknowledged"
true -> "active"
end
end
defp format_uptime(equipment) do
if equipment.snmp_device && equipment.snmp_device.sys_uptime do
timeticks_to_string(equipment.snmp_device.sys_uptime)

View file

@ -52,6 +52,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do
recent_checks = Monitoring.list_equipment_checks(equipment_id, 50)
snmp_data = load_snmp_data(equipment_id)
events = Equipment.list_equipment_events(equipment_id, 100)
neighbors = Snmp.list_neighbors(equipment_id)
cpu_chart_data = load_sensor_chart_data(equipment_id, ["cpu_load"])
memory_chart_data = load_sensor_chart_data(equipment_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(equipment_id, ["disk_usage"])
@ -76,6 +77,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:neighbors, neighbors)
|> assign(:events, events)
|> assign(:cpu_chart_data, cpu_chart_data)
|> assign(:memory_chart_data, memory_chart_data)

View file

@ -51,6 +51,20 @@
</.link>
<% end %>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=neighbors"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "neighbors" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
end
]}
>
Neighbors
</.link>
<.link
patch={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}?tab=logs"}
class={[
@ -82,30 +96,30 @@
</h3>
</div>
<div class="p-4">
<dl class="space-y-3">
<dl class="space-y-1">
<%= if @snmp_device do %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">System Name</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_name || @equipment.name}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Resolved IP</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@equipment.ip_address}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Hardware</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.model || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Operating System</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{if @snmp_device.manufacturer && @snmp_device.firmware_version do
@ -116,42 +130,42 @@
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Object ID</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@snmp_device.sys_object_id || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Contact</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_contact || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Location</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@snmp_device.sys_location || "N/A"}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Uptime</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{format_uptime(@snmp_device.sys_uptime)}
</dd>
</div>
<% else %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Name</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{@equipment.name}
</dd>
</div>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">IP Address</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
{@equipment.ip_address}
@ -159,14 +173,14 @@
</div>
<% end %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Device Added</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{format_device_age(@equipment.inserted_at)}
</dd>
</div>
<div class="flex justify-between py-2">
<div class="flex justify-between py-1.5">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">Last Discovered</dt>
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{if @equipment.last_discovery_at do
@ -278,9 +292,9 @@
</h3>
</div>
<div class="p-4">
<dl class="space-y-3">
<dl class="space-y-1">
<%= for sensor <- @storage_sensors do %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
<.link
navigate={
@ -317,9 +331,9 @@
</h3>
</div>
<div class="p-4">
<dl class="space-y-3">
<dl class="space-y-1">
<%= for sensor <- @temperature_sensors do %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
<.link
navigate={
@ -354,9 +368,9 @@
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Voltage</h3>
</div>
<div class="p-4">
<dl class="space-y-3">
<dl class="space-y-1">
<%= for sensor <- @voltage_sensors do %>
<div class="flex justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
<.link
navigate={
@ -413,14 +427,21 @@
{interface.if_index}
</td>
<td class="px-4 py-3">
<div class="font-medium text-zinc-900 dark:text-zinc-100">
{interface.if_name || interface.if_descr}
</div>
<%= if interface.if_alias do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{interface.if_alias}
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/graph/traffic?interface_id=#{interface.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400"
>
<div class="font-medium text-zinc-900 dark:text-zinc-100 hover:underline">
{interface.if_name || interface.if_descr}
</div>
<% end %>
<%= if interface.if_alias do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{interface.if_alias}
</div>
<% end %>
</.link>
</td>
<td class="px-4 py-3">
<span class={[
@ -455,6 +476,96 @@
<p class="text-zinc-500 dark:text-zinc-400">No interfaces discovered</p>
</div>
<% end %>
<% "neighbors" -> %>
<%= if @neighbors && length(@neighbors) > 0 do %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Discovered Neighbors
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr class="text-xs text-zinc-600 dark:text-zinc-400">
<th class="px-4 py-3 text-left font-medium">Protocol</th>
<th class="px-4 py-3 text-left font-medium">Local Interface</th>
<th class="px-4 py-3 text-left font-medium">Remote Device</th>
<th class="px-4 py-3 text-left font-medium">Remote Port</th>
<th class="px-4 py-3 text-left font-medium">Platform</th>
<th class="px-4 py-3 text-left font-medium">Capabilities</th>
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for neighbor <- @neighbors do %>
<tr class="text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900">
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase",
neighbor.protocol == "lldp" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
neighbor.protocol == "cdp" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
]}>
{neighbor.protocol}
</span>
</td>
<td class="px-4 py-3 text-zinc-900 dark:text-zinc-100">
{neighbor.interface.if_name || neighbor.interface.if_descr}
</td>
<td class="px-4 py-3">
<div class="font-medium text-zinc-900 dark:text-zinc-100">
{neighbor.remote_system_name || "Unknown"}
</div>
<%= if neighbor.remote_address do %>
<div class="text-xs font-mono text-zinc-500 dark:text-zinc-400">
{neighbor.remote_address}
</div>
<% end %>
</td>
<td class="px-4 py-3">
<div class="text-zinc-900 dark:text-zinc-100">
{neighbor.remote_port_id || "-"}
</div>
<%= if neighbor.remote_port_description do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{neighbor.remote_port_description}
</div>
<% end %>
</td>
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{neighbor.remote_platform || "-"}
</td>
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
<%= if neighbor.remote_capabilities && length(neighbor.remote_capabilities) > 0 do %>
{Enum.join(neighbor.remote_capabilities, ", ")}
<% else %>
-
<% end %>
</td>
<td class="px-4 py-3 text-xs text-zinc-500 dark:text-zinc-400">
{time_ago(neighbor.last_discovered_at)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-12">
<div class="text-center">
<.icon name="hero-server-stack" class="mx-auto h-12 w-12 text-zinc-400" />
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No neighbors discovered
</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
This device doesn't have any LLDP or CDP neighbors, or neighbor discovery hasn't run yet.
</p>
</div>
</div>
<% end %>
<% "logs" -> %>
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">

View file

@ -17,12 +17,14 @@ defmodule ToweropsWeb.GraphLive.Show do
end
range = Map.get(params, "range", "24h")
interface_id = Map.get(params, "interface_id")
socket =
socket
|> assign(:equipment_id, equipment_id)
|> assign(:sensor_type, sensor_type)
|> assign(:range, range)
|> assign(:interface_id, interface_id)
|> load_graph_data()
{:noreply, socket}
@ -30,37 +32,56 @@ defmodule ToweropsWeb.GraphLive.Show do
@impl true
def handle_event("change_range", %{"range" => range}, socket) do
params = build_graph_params(socket.assigns, range)
{:noreply,
push_patch(socket,
to:
~p"/orgs/#{socket.assigns.current_organization.slug}/equipment/#{socket.assigns.equipment_id}/graph/#{socket.assigns.sensor_type}?range=#{range}"
~p"/orgs/#{socket.assigns.current_organization.slug}/equipment/#{socket.assigns.equipment_id}/graph/#{socket.assigns.sensor_type}?#{params}"
)}
end
defp build_graph_params(assigns, range) do
base_params = %{"range" => range}
if assigns.interface_id do
Map.put(base_params, "interface_id", assigns.interface_id)
else
base_params
end
end
# Private functions
defp load_graph_data(socket) do
equipment_id = socket.assigns.equipment_id
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
interface_id = socket.assigns.interface_id
equipment = Equipment.get_equipment!(equipment_id)
chart_data =
if sensor_type == "traffic" do
load_traffic_chart_data(equipment_id, range)
else
sensor_types = get_sensor_types_for_chart(sensor_type)
load_sensor_chart_data(equipment_id, sensor_types, range)
{chart_data, title_suffix} =
cond do
sensor_type == "traffic" && interface_id ->
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
sensor_type == "traffic" ->
{load_traffic_chart_data(equipment_id, range), nil}
true ->
sensor_types = get_sensor_types_for_chart(sensor_type)
{load_sensor_chart_data(equipment_id, sensor_types, range), nil}
end
{title, unit, auto_scale} = get_chart_config(sensor_type)
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
show_zero_line = sensor_type == "traffic"
socket
|> assign(:equipment, equipment)
|> assign(:page_title, "#{equipment.name} - #{title}")
|> assign(:chart_title, title)
|> assign(:page_title, "#{equipment.name} - #{chart_title}")
|> assign(:chart_title, chart_title)
|> assign(:chart_data, chart_data)
|> assign(:unit, unit)
|> assign(:auto_scale, auto_scale)
@ -151,82 +172,116 @@ defmodule ToweropsWeb.GraphLive.Show do
end
end
defp build_traffic_chart_json(device, range) do
if Enum.empty?(device.interfaces) do
nil
else
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
all_stats = aggregate_interface_stats(device.interfaces, since, limit)
if Enum.empty?(all_stats) do
nil
else
build_traffic_datasets(all_stats)
end
defp load_interface_traffic_chart_data(interface_id, range) do
case Snmp.get_interface(interface_id) do
nil -> nil
interface -> build_interface_traffic_chart_json(interface, range)
end
end
defp aggregate_interface_stats(interfaces, since, limit) do
interfaces
|> Enum.flat_map(fn interface ->
defp get_interface_name(interface_id) do
case Snmp.get_interface(interface_id) do
nil -> "Unknown Interface"
interface -> interface.if_name || interface.if_descr || "Interface #{interface.if_index}"
end
end
defp build_traffic_chart_json(device, range) do
if Enum.empty?(device.interfaces), do: nil, else: build_traffic_datasets(device, range)
end
defp build_traffic_datasets(device, range) do
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
active_interfaces = Enum.filter(device.interfaces, &(&1.if_oper_status == "up"))
if Enum.empty?(active_interfaces), do: nil, else: encode_interface_datasets(active_interfaces, since, limit)
end
defp encode_interface_datasets(interfaces, since, limit) do
datasets = build_per_interface_datasets(interfaces, since, limit)
if Enum.empty?(datasets), do: nil, else: Jason.encode!(%{datasets: datasets})
end
defp build_per_interface_datasets(interfaces, since, limit) do
Enum.flat_map(interfaces, fn interface ->
stats =
interface.id
|> Snmp.get_interface_stats(since: since, limit: limit)
|> Enum.reverse()
Enum.map(stats, &stat_to_tuple/1)
if Enum.empty?(stats) do
[]
else
interface_name = interface.if_name || interface.if_descr
in_data = calculate_interface_traffic_data(stats, :inbound)
out_data = calculate_interface_traffic_data(stats, :outbound)
[
%{label: "#{interface_name} Out", data: out_data},
%{label: "#{interface_name} In", data: in_data}
]
end
end)
|> Enum.group_by(&elem(&1, 0))
|> Enum.map(&sum_stats_by_timestamp/1)
|> Enum.sort_by(&elem(&1, 0), DateTime)
end
defp stat_to_tuple(stat) do
{stat.checked_at, stat.if_in_octets, stat.if_out_octets}
defp build_interface_traffic_chart_json(interface, range) do
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
stats =
interface.id
|> Snmp.get_interface_stats(since: since, limit: limit)
|> Enum.reverse()
if Enum.empty?(stats) do
nil
else
in_data = calculate_interface_traffic_data(stats, :inbound)
out_data = calculate_interface_traffic_data(stats, :outbound)
datasets = [
%{label: "Outbound", data: out_data},
%{label: "Inbound", data: in_data}
]
Jason.encode!(%{datasets: datasets})
end
end
defp sum_stats_by_timestamp({timestamp, stats}) do
total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end)
total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end)
{timestamp, total_in, total_out}
end
defp build_traffic_datasets(all_stats) do
in_data = calculate_traffic_data(all_stats, :inbound)
out_data = calculate_traffic_data(all_stats, :outbound)
datasets = [
%{label: "Outbound", data: out_data},
%{label: "Inbound", data: in_data}
]
Jason.encode!(%{datasets: datasets})
end
defp calculate_traffic_data(all_stats, :inbound) do
all_stats
defp calculate_interface_traffic_data(stats, :inbound) do
stats
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] ->
time_diff = t2 |> DateTime.diff(t1, :second) |> max(1)
bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2)
|> Enum.map(fn [stat1, stat2] ->
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
bps =
((stat2.if_in_octets - stat1.if_in_octets) * 8 / time_diff)
|> max(0)
|> :erlang.float()
|> Float.round(2)
%{
x: DateTime.to_unix(t2, :millisecond),
x: DateTime.to_unix(stat2.checked_at, :millisecond),
y: -bps
}
end)
end
defp calculate_traffic_data(all_stats, :outbound) do
all_stats
defp calculate_interface_traffic_data(stats, :outbound) do
stats
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] ->
time_diff = t2 |> DateTime.diff(t1, :second) |> max(1)
bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2)
|> Enum.map(fn [stat1, stat2] ->
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
bps =
((stat2.if_out_octets - stat1.if_out_octets) * 8 / time_diff)
|> max(0)
|> :erlang.float()
|> Float.round(2)
%{
x: DateTime.to_unix(t2, :millisecond),
x: DateTime.to_unix(stat2.checked_at, :millisecond),
y: bps
}
end)

View file

@ -1,285 +0,0 @@
# Pangolin Security Rules for Towerops
# Block common web attacks and malicious traffic
#
# Apply these rules to your Pangolin configuration to filter traffic before
# it reaches your application server.
#
# Documentation: https://docs.pangolin.net/
# ============================================================================
# SQL Injection Attempts
# ============================================================================
[[rules]]
name = "Block SQL Injection - UNION"
match.url.regex = "(?i)(union.*(all|select)|select.*from.*information_schema)"
action = "block"
log = true
[[rules]]
name = "Block SQL Injection - Common Patterns"
match.url.regex = "(?i)(\\bor\\b.*=.*|\\band\\b.*=.*|'.*or.*'|\".*or.*\"|;.*drop|;.*delete|;.*insert|;.*update)"
action = "block"
log = true
[[rules]]
name = "Block SQL Injection - Comments"
match.url.regex = "(?i)(/\\*.*\\*/|--.*$|#.*$)"
action = "block"
log = true
# ============================================================================
# Cross-Site Scripting (XSS)
# ============================================================================
[[rules]]
name = "Block XSS - Script Tags"
match.url.regex = "(?i)(<script|</script|javascript:|onerror=|onload=)"
action = "block"
log = true
[[rules]]
name = "Block XSS - Event Handlers"
match.url.regex = "(?i)(onclick|ondblclick|onmouseover|onmouseout|onkeydown|onkeyup|onchange|onsubmit|onfocus|onblur)\\s*="
action = "block"
log = true
# ============================================================================
# Path Traversal
# ============================================================================
[[rules]]
name = "Block Path Traversal - Directory Traversal"
match.url.regex = "(\\.\\./|\\.\\.\\\\/|%2e%2e%2f|%2e%2e/|..%2f|%2e%2e%5c)"
action = "block"
log = true
[[rules]]
name = "Block Path Traversal - Absolute Paths"
match.url.regex = "(?i)(^/etc/|^/proc/|^/sys/|^/var/|^/usr/|^/bin/|^/sbin/|c:\\\\|c:/)"
action = "block"
log = true
# ============================================================================
# Common Exploit Paths (WordPress, PHPMyAdmin, etc.)
# ============================================================================
[[rules]]
name = "Block WordPress Admin"
match.url.path = "/wp-admin"
action = "block"
log = true
[[rules]]
name = "Block WordPress Login"
match.url.path = "/wp-login.php"
action = "block"
log = true
[[rules]]
name = "Block WordPress XML-RPC"
match.url.path = "/xmlrpc.php"
action = "block"
log = true
[[rules]]
name = "Block PHPMyAdmin"
match.url.regex = "(?i)/(phpmyadmin|pma|myadmin|mysql|phpmy|sqladmin)"
action = "block"
log = true
[[rules]]
name = "Block Common Admin Panels"
match.url.regex = "(?i)/(admin|administrator|cpanel|webmail|plesk|directadmin|phppgadmin)"
action = "block"
log = true
# ============================================================================
# Malicious Bots and Scanners
# ============================================================================
[[rules]]
name = "Block Vulnerability Scanners"
match.headers."User-Agent".regex = "(?i)(nikto|nessus|nmap|masscan|zap|burp|sqlmap|metasploit|acunetix|w3af|havij)"
action = "block"
log = true
[[rules]]
name = "Block Scrapers and Crawlers"
match.headers."User-Agent".regex = "(?i)(scrapy|webcopier|httrack|teleport|wget|curl.*bot|python-requests.*bot)"
action = "block"
log = false
[[rules]]
name = "Block Empty User Agent"
match.headers."User-Agent".exact = ""
action = "block"
log = false
# ============================================================================
# Shell Injection Attempts
# ============================================================================
[[rules]]
name = "Block Shell Commands"
match.url.regex = "(?i)(\\|.*ls|\\|.*cat|\\|.*wget|\\|.*curl|;.*ls|;.*cat|;.*wget|;.*curl|`.*`|\\$\\(.*\\))"
action = "block"
log = true
[[rules]]
name = "Block Shell Shock"
match.headers.regex = "(?i)\\(\\)\\s*\\{.*\\}"
action = "block"
log = true
# ============================================================================
# File Upload Exploits
# ============================================================================
[[rules]]
name = "Block Malicious File Extensions"
match.url.regex = "(?i)\\.(php\\d?|phtml|asp|aspx|jsp|cgi|pl|sh|bash|exe|dll|com|bat)\\.?"
action = "block"
log = true
[[rules]]
name = "Block Double Extensions"
match.url.regex = "(?i)\\.(jpg|jpeg|png|gif|pdf|doc|docx)\\.php"
action = "block"
log = true
# ============================================================================
# Information Disclosure
# ============================================================================
[[rules]]
name = "Block Git Directory"
match.url.path_prefix = "/.git"
action = "block"
log = true
[[rules]]
name = "Block SVN Directory"
match.url.path_prefix = "/.svn"
action = "block"
log = true
[[rules]]
name = "Block Environment Files"
match.url.regex = "(?i)/\\.env(\\..*)?$"
action = "block"
log = true
[[rules]]
name = "Block Backup Files"
match.url.regex = "(?i)\\.(bak|backup|old|orig|save|swp|swo|~)$"
action = "block"
log = true
[[rules]]
name = "Block Config Files"
match.url.regex = "(?i)/(config|configuration|settings)\\.(php|xml|json|yml|yaml|ini|conf)$"
action = "block"
log = true
# ============================================================================
# Protocol and Method Attacks
# ============================================================================
[[rules]]
name = "Block HTTP/0.9"
match.http_version = "0.9"
action = "block"
log = false
[[rules]]
name = "Block TRACE Method"
match.method = "TRACE"
action = "block"
log = true
[[rules]]
name = "Block TRACK Method"
match.method = "TRACK"
action = "block"
log = true
# ============================================================================
# Known Exploit Patterns
# ============================================================================
[[rules]]
name = "Block Log4Shell"
match.headers.regex = "(?i)\\$\\{jndi:(ldap|rmi|dns)://"
action = "block"
log = true
[[rules]]
name = "Block Server-Side Request Forgery (SSRF)"
match.url.regex = "(?i)(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]|169\\.254\\.|192\\.168\\.|10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.)"
action = "block"
log = true
[[rules]]
name = "Block XXE (XML External Entity)"
match.body.regex = "(?i)<!entity.*system"
action = "block"
log = true
# ============================================================================
# Rate Limiting (requires Pangolin Pro)
# ============================================================================
# Uncomment if you have Pangolin Pro with rate limiting
# [[rules]]
# name = "Rate Limit - General"
# match.all = true
# action = "rate_limit"
# rate_limit.requests = 100
# rate_limit.window = "1m"
# rate_limit.by = "ip"
# [[rules]]
# name = "Rate Limit - Login Endpoints"
# match.url.regex = "(?i)/(login|signin|session|auth)"
# action = "rate_limit"
# rate_limit.requests = 10
# rate_limit.window = "1m"
# rate_limit.by = "ip"
# ============================================================================
# Geo-Blocking (optional)
# ============================================================================
# Block traffic from specific countries if needed
# [[rules]]
# name = "Block Countries"
# match.geo.country_code = ["CN", "RU", "KP"]
# action = "block"
# log = false
# ============================================================================
# Allowlist for Legitimate Traffic
# ============================================================================
# Always allow health checks
[[rules]]
name = "Allow Health Checks"
match.url.path = "/health"
action = "allow"
priority = 1000
# Allow legitimate API endpoints
[[rules]]
name = "Allow API v1"
match.url.path_prefix = "/api/v1"
match.headers."User-Agent".regex = "towerops-agent"
action = "allow"
priority = 1000
# Allow legitimate user agents from monitoring
[[rules]]
name = "Allow Monitoring Services"
match.headers."User-Agent".regex = "(?i)(uptimerobot|pingdom|statuspage|datadog)"
action = "allow"
priority = 1000

View file

@ -50,6 +50,7 @@ message Metric {
oneof metric_type {
SensorReading sensor_reading = 1;
InterfaceStat interface_stat = 2;
NeighborDiscovery neighbor_discovery = 3;
}
}
@ -71,6 +72,20 @@ message InterfaceStat {
int64 timestamp = 8; // Unix timestamp in seconds
}
message NeighborDiscovery {
string interface_id = 1;
string protocol = 2; // "lldp" or "cdp"
string remote_chassis_id = 3;
string remote_system_name = 4;
string remote_system_description = 5;
string remote_platform = 6;
string remote_port_id = 7;
string remote_port_description = 8;
string remote_address = 9;
repeated string remote_capabilities = 10;
int64 timestamp = 11; // Unix timestamp in seconds
}
// Heartbeat metadata
message HeartbeatMetadata {
string version = 1;

View file

@ -0,0 +1,46 @@
defmodule Towerops.Repo.Migrations.CreateSnmpNeighbors do
use Ecto.Migration
def change do
create table(:snmp_neighbors) do
add :equipment_id, references(:equipment, type: :binary_id, on_delete: :delete_all),
null: false
add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all),
null: false
# Discovery protocol (lldp, cdp)
add :protocol, :string, null: false
# Remote device identification
add :remote_chassis_id, :string
add :remote_system_name, :string
add :remote_system_description, :text
add :remote_platform, :string
# Remote interface/port information
add :remote_port_id, :string
add :remote_port_description, :string
# Remote device address (IP)
add :remote_address, :string
# Capabilities as a list of strings (router, switch, bridge, etc.)
add :remote_capabilities, {:array, :string}, default: []
# Last time this neighbor was seen
add :last_discovered_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
# Indexes for performance
create index(:snmp_neighbors, [:equipment_id])
create index(:snmp_neighbors, [:interface_id])
create index(:snmp_neighbors, [:protocol])
create index(:snmp_neighbors, [:last_discovered_at])
# Unique constraint: one neighbor per interface + remote chassis + protocol
create unique_index(:snmp_neighbors, [:interface_id, :remote_chassis_id, :protocol])
end
end