diff --git a/lib/towerops/device_profiles.ex b/lib/towerops/device_profiles.ex index 83249a2d..027450e8 100644 --- a/lib/towerops/device_profiles.ex +++ b/lib/towerops/device_profiles.ex @@ -204,7 +204,20 @@ defmodule Towerops.DeviceProfiles do end defp match_detection_rules?(rules, system_info) do - Enum.any?(rules, &match_rule?(&1, system_info)) + # Group rules by rule_group (each group represents a detection block from YAML) + # OR logic between groups, AND logic within groups + rules + |> Enum.group_by(& &1.rule_group) + |> Enum.any?(fn {_group_id, group_rules} -> + # Within a group, ALL field types must match (AND logic) + # But we need to handle multiple values for the same field type (OR within field) + group_rules + |> Enum.group_by(& &1.rule_type) + |> Enum.all?(fn {_rule_type, type_rules} -> + # Within the same field type, ANY rule can match (OR logic) + Enum.any?(type_rules, &match_rule?(&1, system_info)) + end) + end) end defp match_rule?(%DetectionRule{rule_type: "sysObjectID"} = rule, system_info) do diff --git a/lib/towerops/device_profiles/detection_rule.ex b/lib/towerops/device_profiles/detection_rule.ex index 1ae994d6..0d5c6765 100644 --- a/lib/towerops/device_profiles/detection_rule.ex +++ b/lib/towerops/device_profiles/detection_rule.ex @@ -21,6 +21,7 @@ defmodule Towerops.DeviceProfiles.DetectionRule do field :value, :string field :pattern, :string field :priority, :integer, default: 0 + field :rule_group, :integer, default: 0 belongs_to :device_profile, DeviceProfile @@ -30,7 +31,16 @@ defmodule Towerops.DeviceProfiles.DetectionRule do @doc false def changeset(rule, attrs) do rule - |> cast(attrs, [:rule_type, :oid, :operator, :value, :pattern, :priority, :device_profile_id]) + |> cast(attrs, [ + :rule_type, + :oid, + :operator, + :value, + :pattern, + :priority, + :rule_group, + :device_profile_id + ]) |> validate_required([:rule_type, :device_profile_id]) |> foreign_key_constraint(:device_profile_id) end diff --git a/lib/towerops/device_profiles/importer.ex b/lib/towerops/device_profiles/importer.ex index a07bcf82..e8b0a8df 100644 --- a/lib/towerops/device_profiles/importer.ex +++ b/lib/towerops/device_profiles/importer.ex @@ -132,17 +132,21 @@ defmodule Towerops.DeviceProfiles.Importer do end defp create_detection_rules(profile_id, rules) when is_list(rules) do - Enum.each(rules, fn rule -> - parse_detection_rule(profile_id, rule) + # Each rule in the list is a separate detection block + # Uses OR logic between blocks, AND logic within blocks + rules + |> Enum.with_index() + |> Enum.each(fn {rule, group_index} -> + parse_detection_rule(profile_id, rule, group_index) end) :ok end - defp parse_detection_rule(profile_id, rule) do + defp parse_detection_rule(profile_id, rule, group_index) do # Handle sysObjectID rules if Map.has_key?(rule, "sysObjectID") do - create_sys_object_id_rules(profile_id, rule["sysObjectID"]) + create_sys_object_id_rules(profile_id, rule["sysObjectID"], group_index) end # Handle sysDescr rules @@ -150,67 +154,42 @@ defmodule Towerops.DeviceProfiles.Importer do DeviceProfiles.create_detection_rule(%{ device_profile_id: profile_id, rule_type: "sysDescr", - value: rule["sysDescr"] + value: rule["sysDescr"], + rule_group: group_index }) end # Handle sysDescr_regex rules if Map.has_key?(rule, "sysDescr_regex") do - create_sys_descr_regex_rules(profile_id, rule["sysDescr_regex"]) + create_sys_descr_regex_rules(profile_id, rule["sysDescr_regex"], group_index) end {:ok, :processed} end - defp create_sys_object_id_rules(profile_id, oids) when is_list(oids) do + defp create_sys_object_id_rules(profile_id, oids, group_index) when is_list(oids) do Enum.each(oids, fn oid -> - # Skip overly broad patterns that would match any enterprise OID - if is_overly_broad_oid?(oid) do - Logger.warning("Skipping overly broad sysObjectID pattern: #{oid}") - else - DeviceProfiles.create_detection_rule(%{ - device_profile_id: profile_id, - rule_type: "sysObjectID", - pattern: oid - }) - end + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysObjectID", + pattern: oid, + rule_group: group_index + }) end) {:ok, :created} end - defp create_sys_object_id_rules(profile_id, oid) when is_binary(oid) do - # Skip overly broad patterns that would match any enterprise OID - if is_overly_broad_oid?(oid) do - Logger.warning("Skipping overly broad sysObjectID pattern: #{oid}") - else - DeviceProfiles.create_detection_rule(%{ - device_profile_id: profile_id, - rule_type: "sysObjectID", - pattern: oid - }) - end + defp create_sys_object_id_rules(profile_id, oid, group_index) when is_binary(oid) do + DeviceProfiles.create_detection_rule(%{ + device_profile_id: profile_id, + rule_type: "sysObjectID", + pattern: oid, + rule_group: group_index + }) end - # Check if an OID pattern is too broad and would match too many devices - # Patterns like ".1.3.6.1.4.1" would match ANY enterprise OID - defp is_overly_broad_oid?(oid) do - # Normalize to dotted format - normalized = if String.starts_with?(oid, "."), do: oid, else: "." <> oid - - # These are the only acceptable root OIDs that are specific enough - # .1.3.6.1.4.1 is the enterprise prefix - anything shorter is too broad - # Must have at least one more component after .1.3.6.1.4.1 - case normalized do - ".1.3.6.1.4.1" -> true - # Also reject other common roots that are too broad - ".1.3.6.1.4" -> true - ".1.3.6.1" -> true - _ -> false - end - end - - defp create_sys_descr_regex_rules(profile_id, patterns) when is_list(patterns) do + defp create_sys_descr_regex_rules(profile_id, patterns, group_index) when is_list(patterns) do Enum.each(patterns, fn pattern -> # Remove leading/trailing slashes from regex pattern clean_pattern = String.trim(pattern, "/") @@ -218,20 +197,22 @@ defmodule Towerops.DeviceProfiles.Importer do DeviceProfiles.create_detection_rule(%{ device_profile_id: profile_id, rule_type: "sysDescr_regex", - pattern: clean_pattern + pattern: clean_pattern, + rule_group: group_index }) end) {:ok, :created} end - defp create_sys_descr_regex_rules(profile_id, pattern) when is_binary(pattern) do + defp create_sys_descr_regex_rules(profile_id, pattern, group_index) when is_binary(pattern) do clean_pattern = String.trim(pattern, "/") DeviceProfiles.create_detection_rule(%{ device_profile_id: profile_id, rule_type: "sysDescr_regex", - pattern: clean_pattern + pattern: clean_pattern, + rule_group: group_index }) end diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index 643b4926..f59b2cd5 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -4,6 +4,8 @@ defmodule ToweropsWeb.DeviceLive.Index do alias Towerops.Devices alias Towerops.Sites + alias Towerops.Snmp + alias Towerops.Workers.DiscoveryWorker @impl true def mount(_params, _session, socket) do @@ -26,4 +28,30 @@ defmodule ToweropsWeb.DeviceLive.Index do defp apply_action(socket, :index, _params) do socket end + + @impl true + def handle_event("force_rediscover_all", _params, socket) do + devices = socket.assigns.device + snmp_devices = Enum.filter(devices, & &1.snmp_enabled) + + if Enum.empty?(snmp_devices) do + {:noreply, put_flash(socket, :error, "No SNMP-enabled devices found")} + else + Enum.each(snmp_devices, fn device -> + enqueue_discovery(device.id) + end) + + count = length(snmp_devices) + + {:noreply, put_flash(socket, :info, "Discovery started for #{count} device#{if count == 1, do: "", else: "s"}")} + end + end + + defp enqueue_discovery(device_id) do + if Application.get_env(:towerops, :env) == :test do + Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end) + else + {:ok, _job} = Exq.enqueue(Exq, "discovery", DiscoveryWorker, [device_id]) + end + end end diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index 8e0c4769..e1609aba 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -9,6 +9,15 @@ {@page_title} <:subtitle>Monitor and manage all your network devices <:actions> + <%= if @device != [] do %> + <.button + type="button" + phx-click="force_rediscover_all" + data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?" + > + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All + + <% end %> <.button :if={@has_sites} navigate={~p"/devices/new"} diff --git a/priv/repo/migrations/20260118193856_add_rule_group_to_detection_rules.exs b/priv/repo/migrations/20260118193856_add_rule_group_to_detection_rules.exs new file mode 100644 index 00000000..5a32f22a --- /dev/null +++ b/priv/repo/migrations/20260118193856_add_rule_group_to_detection_rules.exs @@ -0,0 +1,9 @@ +defmodule Towerops.Repo.Migrations.AddRuleGroupToDetectionRules do + use Ecto.Migration + + def change do + alter table(:profile_detection_rules) do + add :rule_group, :integer, null: false, default: 0 + end + end +end diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico index 7f372bfc..5c36dc20 100644 Binary files a/priv/static/favicon.ico and b/priv/static/favicon.ico differ