# Towerops Device Detection Algorithm **Analysis Date**: 2026-02-11 **Towerops Version**: Current main branch **Key Files**: `lib/towerops/profiles/yaml_profiles.ex`, `priv/profiles/os_detection/*.yaml` ## Overview Towerops uses a YAML-based detection system adapted from LibreNMS but enhanced with: - **4-phase matching** (unconditional/conditional × non-generic/generic) - **ETS caching** with read concurrency optimization - **Rust NIF MIB resolution** for fast, reliable OID lookups - **Index templates** to prevent sensor deduplication issues ## Detection Flow The detection process happens during SNMP discovery: ``` Device SNMP Query ↓ discover_system_info() - Collects sysObjectID, sysDescr, sysName ↓ select_profile() - Matches against YAML profiles ↓ YamlProfiles.match_profile() - 4-Pass LibreNMS-style matching ↓ Profile Selected → build_device_info() - Extracts manufacturer, model, firmware ↓ Dynamic.identify_device() - Uses profile to extract device metadata ↓ discover_sensors() - Loads sensors from profile ``` **Entry Point**: `Towerops.Snmp.Discovery.discover_device/1` ## YAML Profile Structure Each profile file represents one device OS/type. Located: `priv/profiles/os_detection/*.yaml` ### Example Structure ```yaml os: routeros # Profile identifier (maps to filename) text: 'MikroTik RouterOS' # Human-readable vendor name type: network # Device type category icon: mikrotik # UI icon reference mib_dir: mikrotik # MIB directory name discovery: - sysObjectID: # Primary detection: OID matching - .1.3.6.1.4.1.14988.1 sysDescr: # Optional: compound matching (OID + string) - "RouterOS" sysDescr_regex: # Optional: regex patterns - '/RouterOS/i' # Case-insensitive snmpget: # Optional: conditional SNMP query oid: 'MIKROTIK-MIB::mtxrLicVersion' op: '!=' # Operators: '=', '!=', 'starts', 'contains', 'regex', '>=' value: '' snmpwalk: # Optional: walk condition oid: 'MIKROTIK-MIB::mtxrInterfaceStatsName' op: 'contains' value: 'ether' ``` ### Profile Composition - **Multiple detection blocks** per profile (OR logic) - **Multiple conditions** per block (AND logic) - **First matching profile** wins - **Best match selection** by longest OID when multiple blocks match ## 4-Phase Matching Algorithm `YamlProfiles.match_profile()` implements priority-based matching (lines 653-673): ### Phase 1: Non-Generic Profiles (Unconditional Blocks) **Goal**: Fast string-based matching for specific vendors **Checks**: - All profiles **except** generic fallbacks (`airos`, `freebsd`, `linux`) - Only detection blocks **without** `snmpget`/`snmpwalk` conditions - Pure string matching on sysObjectID and sysDescr - Very fast: <1ms per device **Example**: Cisco router with known OID prefix **Code**: ```elixir # Filter profiles non_generic = Enum.filter(profiles, &(&1.name not in @generic_os)) # Filter unconditional blocks Enum.filter(profile.detection_blocks, &(!&1.has_condition)) # Match blocks Enum.filter(blocks, &block_matches?(&1, sys_object_id, sys_descr, opts, false)) ``` ### Phase 2: Non-Generic Profiles (Conditional Blocks) **Goal**: Accurate matching for ambiguous devices requiring SNMP queries **Checks**: - Same profiles as Phase 1 - Only blocks **with** `snmpget`/`snmpwalk` conditions - MIB names pre-resolved at startup (cached) - Uses actual SNMP queries to verify conditions **Example**: Ubiquiti AirOS AF60 variant (requires `snmpget: UI-AF60-MIB::af60Role.1 = false`) **Code**: ```elixir # Filter conditional blocks Enum.filter(profile.detection_blocks, &(&1.has_condition)) # Match with conditional evaluation Enum.filter(blocks, &block_matches?(&1, sys_object_id, sys_descr, opts, true)) ``` ### Phase 3: Generic OS Profiles (Unconditional) **Goal**: Catch generic Linux/BSD devices without conditional checks **Checks**: - Generic profiles only: `airos`, `freebsd`, `linux` - Unconditional blocks only - Matches by sysDescr string **Example**: Linux device with "Linux" in sysDescr **Code**: ```elixir generic = Enum.filter(profiles, &(&1.name in @generic_os)) Enum.filter(profile.detection_blocks, &(!&1.has_condition)) ``` ### Phase 4: Generic OS Profiles (Conditional) **Goal**: Final fallback for devices requiring conditional checks **Checks**: - Generic profiles with `snmpget`/`snmpwalk` conditions - Last resort matching **Example**: Ubiquiti device with Linux + UI-AF60-MIB check ### Best Match Selection When multiple blocks match within the same phase: ```elixir Enum.max_by(matching_blocks, &String.length(&1.oid || "")) ``` **Logic**: Longest OID wins (more specific match) **Example**: - Block A OID: `.1.3.6.1.4.1.10002` (16 chars) - Block B OID: `.1.3.6.1.4.1.10002.1.2.3` (27 chars) - **Winner**: Block B (more specific) ## Detection Block Matching Logic Each block is evaluated using `block_matches?/5` with four checks: ### 1. OID Check ```elixir oid_match = block.oid && String.contains?(sys_object_id, block.oid) ``` **Behavior**: Substring matching (not exact equality) **Rationale**: Device firmware sometimes truncates OID responses. Substring matching handles vendor inconsistencies. **Example**: - Block OID: `.1.3.6.1.4.1.10002` - Device OID: `1.3.6.1.4.1.10002.1.2` (no leading dot) - **Match**: YES (substring found) ### 2. Condition Mode Check ```elixir condition_match = block.has_condition == match_conditional ``` **Behavior**: Filters blocks based on current phase **Purpose**: Prevents `snmpget` blocks from being evaluated in Phase 1 (performance) ### 3. sysDescr Constraints ```elixir defp block_matches_descr?(block, sys_descr) do case {patterns, regexes} do {[], []} -> true # No constraints _ when is_nil(sys_descr) -> false # No sysDescr to match _ -> Enum.any?(patterns, fn p -> String.contains?(sys_descr, p) end) || Enum.any?(regexes, fn r -> Regex.match?(r, sys_descr) end) end end ``` **Logic**: - **No constraints**: Block matches if OID matches (sufficient) - **String patterns**: At least one must match (substring search, case-sensitive) - **Regex patterns**: At least one must match (case-sensitive or insensitive based on flags) - **Both types**: Treated as OR (string OR regex) ### 4. Conditional Checks (snmpget/snmpwalk) #### snmpget Evaluation ```elixir defp evaluate_snmpget_condition(%{oid: oid, op: op, value: expected}, client_opts) do numeric_oid = MibCache.lookup(oid) # Cached MIB resolution case Client.get(client_opts, numeric_oid) do {:ok, actual} -> compare_snmp_values(actual, op, expected) {:error, _} -> op == "=" && expected == false # Missing OID = false end end ``` **Behavior**: - Performs single SNMP GET query - Resolves MIB symbolic names (e.g., `UI-AF60-MIB::af60Role.1`) to numeric OIDs - Returns true if comparison succeeds - **Special case**: If OID doesn't exist, treats as `false` (useful for `= false` checks) #### snmpwalk Evaluation ```elixir defp evaluate_snmpwalk_condition(%{oid: oid, op: op, value: expected}, client_opts) do numeric_oid = MibCache.lookup(oid) case Client.walk(client_opts, numeric_oid) do {:ok, results} when is_map(results) -> values = Map.values(results) Enum.any?(values, fn value -> compare_snmp_values(value, op, expected) end) {:error, _} -> false end end ``` **Behavior**: - Walks OID subtree, returns all values found - Matches if **ANY** value satisfies condition - Useful for checking table-based conditions #### Comparison Operators ```elixir "=" → exact string match "!=" → string inequality "starts" → String.starts_with? "contains"→ String.contains? (default) "regex" → Regex.match? (compiles pattern dynamically) ">=" → numeric comparison (with fallback to string) ``` ## ETS Caching Strategy ### Profile ETS Table **Table**: `:yaml_profiles` **Initialized in**: `YamlProfiles.init/0` (GenServer) ```elixir :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) ``` **Cache keys** (composite indexing): - `{:profile, name}` → Full profile map - `{:oid, numeric_oid}` → Profile name (fast OID lookup) - `{:pattern, string}` → Profile name (sysDescr pattern lookups) **Why read_concurrency: true?** - Multiple discovery processes read profiles simultaneously - Write occurs only at startup or reload - Extremely low contention (99% reads, 1% writes) **Lookup performance**: ```elixir def get_profile(name) do case :ets.lookup(@table, {:profile, name}) do [{_, profile}] -> profile # O(1) constant time [] -> nil end end ``` ### MIB Cache ETS Table **Table**: `:mib_cache` **Stores**: MIB symbolic names → numeric OID strings - `"SNMPv2-MIB::sysDescr"` → `"1.3.6.1.2.1.1.1"` - `"UBNT-AirFIBER-MIB::fwVersion.1"` → `"1.3.6.1.4.1.10002.1.2.3.1.0"` **Pre-resolution at startup**: ```elixir # In YamlProfiles.init: mib_names = extract_mib_names_from_profiles(raw_profiles) MibCache.pre_resolve(mib_names) # In MibCache.handle_cast: Enum.reduce(mib_names, 0, fn mib_name, acc -> case resolve_oid_with_timeout(mib_name) do {:ok, oid} -> :ets.insert(@table, {mib_name, oid}); acc + 1 {:error, _} -> Logger.warning("Failed to resolve #{mib_name}"); acc end end) ``` **Timeout handling**: 5-second timeout per MIB name (prevents hanging on missing MIBs) **Runtime fallback**: ```elixir def lookup(mib_name) do case :ets.lookup(@table, mib_name) do [{^mib_name, oid}] -> oid # Cache hit [] -> resolve_and_cache(mib_name) # Cache miss - resolve at runtime end end ``` ## MIB Name Resolution via Rust NIF ### ToweropsNative Module **Location**: `lib/towerops_native.ex` (wrapper for compiled NIF) **Implementation**: C NIF that calls `libnetsnmp` - Binary: `priv/towerops_nif` (compiled at build time) - Wraps net-snmp library functions ### Resolution Process ``` MIB Name → ToweropsNative.resolve_oid/1 → snmptranslate → Numeric OID ``` **Call sequence**: ```elixir # In conditional matching (Phase 2/4) numeric_oid = MibCache.lookup("UBNT-AirFIBER-MIB::fwVersion.1") ↓ # Cache hit? Return immediately (<1µs) # Cache miss? Fall back to: ToweropsNative.resolve_oid("UBNT-AirFIBER-MIB::fwVersion.1") ↓ # C NIF calls internal net-snmp functions # Returns: "1.3.6.1.4.1.10002.1.2.3.1.0" ``` ### Performance Characteristics - **MIB Loading**: ~1-2 seconds (one-time at app startup) - **In-memory lookup**: ~1-20 microseconds (after pre-resolution) - **Throughput**: ~50,000-100,000 resolutions per second per core - **Cache hit rate**: ~95%+ for profiles (pre-resolved at startup) ### System Dependencies **Development (macOS)**: ```bash brew install net-snmp # keg-only, requires PKG_CONFIG_PATH setup ``` **Production (Docker)**: ```dockerfile apt-get install libsnmp-dev snmp-mibs-downloader ``` **Runtime requirement**: `snmptranslate` command in PATH ### Test Environment - MIB resolution **disabled** in test mode (avoids hanging) - Tests must stub `ToweropsNative` if they need MIB behavior ## YAML Profile Processing Pipeline ### Load Phase (`load_all_profiles/0`) ```elixir # 1. Find all YAML files yaml_files = Path.wildcard("priv/profiles/os_detection/*.yaml") # 2. Parse each YAML file {name, file, load_profile(profiles_path, name)} # read_yaml(file) → YamlElixir.read_from_string(content) # build_profile(name, detection, discovery) # 3. Index into ETS :ets.insert(@table, {{:profile, name}, profile}) :ets.insert(@table, {{:oid, oid}, name}) :ets.insert(@table, {{:pattern, pattern}, name}) # 4. Extract and pre-resolve MIB names mib_names = extract_mib_names_from_profiles(raw_profiles) MibCache.pre_resolve(mib_names) ``` ### Profile Struct Building (`build_profile/3`) Raw YAML is transformed into optimized detection structure: ```elixir %{ name: "airos-af", vendor: "Ubiquiti AirFiber", # Primary detection OID (first OID from first block) detection_oid: "1.3.6.1.4.1.10002.1", # Simple string patterns for fallback matching detection_patterns: ["Linux"], detection_descr_patterns: ["Linux"], detection_descr_regex: [compiled_regex], # All detection blocks with structured info detection_blocks: [ %{ oid: "1.3.6.1.4.1.10002.1", has_snmpget: true, has_snmpwalk: false, has_condition: true, descr_patterns: ["Linux"], descr_regex: [], snmpget: %{oid: "UBNT-AirFIBER-MIB::fwVersion.1", op: "!=", value: "false"}, snmpwalk: nil } ], # Device metadata extraction device_oids: %{ firmware_version: "SNMPv2-MIB::sysDescr.0", serial_number: "SOME-MIB::serialNumber.0" }, hardware_regex: compiled_regex, # Sensor definitions for SNMP polling sensor_oids: [...], table_sensor_oids: [...], processor_oids: [...], count_sensor_oids: [...], state_sensor_oids: [...] } ``` ## Index Templates (Sensor Deduplication) ### Problem MikroTik firewall sensors (total/ipv4/ipv6 connections) shared `sensor_index: "count_1"` because each walked separately with `Enum.with_index(1)`. ### Solution: {{ $index }} Placeholders For table-based sensors, YAML uses `{{ $index }}` placeholder: ```yaml modules: sensors: count: data: - oid: 'MIKROTIK-MIB::mtxrGaugeName' num_oid: '.1.3.6.1.4.1.14988.1.1.3.10.{{ $index }}' index: 'count_{{ $index }}' # custom sensor_index format ``` **Runtime substitution**: ```elixir # OID walk returns: {"1.3.6.1.4.1.14988.1.1.3.10.1" => 100, ...} # Extract index: "1" # Substitute: "count_{{ $index }}" → "count_1" ``` **Prevents**: All sensors getting same index when walking different OID branches ## Complete Detection Flow Example ### Scenario: Ubiquiti AirFiber device **Step 1: SNMP Query** ``` sysObjectID: "1.3.6.1.4.1.10002.1.2.1" sysDescr: "Linux airfiber 5.4.0 #1 armv5tejl GNU/Linux" ``` **Step 2: Phase 1 - Non-Generic Unconditional** ``` Profiles checked: [all except airos, freebsd, linux] Blocks checked: [blocks WITHOUT snmpget/snmpwalk] airos-af profile, block 1: ✓ OID check: "10002.1" in "10002.1.2.1"? YES ✓ Condition mode: no snmpget? YES (unconditional) ✓ sysDescr pattern "Linux" in sysDescr? YES → MATCH FOUND! Best match: airos-af (block 1 OID length: 10 chars) → Return profile immediately ``` **Step 3: Device Identification** ``` Dynamic.identify_device(profile, client_opts, system_info): 1. Fetch device_oids from profile - firmware_version → SNMP GET - serial_number → SNMP GET 2. Extract hardware from sysDescr_regex - Pattern: "/armv5tejl/" matches - Result: "5.4.0" 3. Return device metadata ``` **Step 4: Sensor Discovery** ``` Dynamic.discover_sensors(profile, client_opts): 1. Base sensors via ENTITY-SENSOR-MIB 2. Profile scalar sensors (direct OID queries) 3. Profile table sensors (SNMP walks with index templates) 4. Return sensor list ``` ## Comparison with LibreNMS | Feature | LibreNMS | Towerops | |---------|----------|----------| | Profile source | YAML files | YAML files | | Number of phases | 2 (fast/slow) | 4 (unconditional/conditional × non-generic/generic) | | OID matching | Prefix match (startsWith) | Substring match (contains) | | Conditional checks | snmpget in PHP | Pre-resolved MIB cache (ETS) | | Generic OS handling | Deferred to pass 2 | Separate phases 3-4 | | MIB resolution | PHP SNMP module (unreliable) | Rust NIF + libnetsnmp (fast, reliable) | | Caching | In-memory PHP arrays | ETS with read_concurrency + pre-warming | | Index deduplication | None | `{{ $index }}` template substitution | | Performance (lookups) | Depends on PHP | ~microseconds per lookup | | Configuration loading | ~1-2 seconds | ~1-2 seconds | ## Key Design Decisions ### Decision 1: 4-Phase Matching vs 2-Phase **Why?** Separates conditional checks into separate phases instead of mixing with fast checks. Clearer separation of concerns. **Benefit**: Easier to debug, more predictable performance ### Decision 2: Pre-resolved MIB Cache **Why?** Conditional snmpget checks need MIB names, but net-snmp resolution is slow. Pre-resolving at startup (1-2 seconds) enables microsecond lookups during matching. **Benefit**: Fast conditional matching without repeated MIB resolution ### Decision 3: ETS with read_concurrency **Why?** Multiple agent discovery processes query profiles simultaneously during scaling. Read-heavy workload (99% reads, 1% startup writes). **Benefit**: Concurrent reads without locking ### Decision 4: OID Substring Matching **Why?** Device firmware sometimes truncates OID responses. Substring matching is more forgiving than exact equality. **Benefit**: Handles vendor inconsistencies and leading dot variations ### Decision 5: Index Templates **Why?** Some sensors (MikroTik) walk in ways that create duplicate indices. **Benefit**: Prevents sensor deduplication errors ## Debugging Profile Matching ### Enable Logging ```elixir # In config: config :towerops, log_level: :debug # In iex: Logger.configure(level: :debug) ``` ### Common Issues **Profile not matching?** 1. Check sysObjectID in device output 2. Verify OID in YAML (leading dot optional) 3. Check sysDescr patterns/regexes 4. Test conditional snmpget manually **snmpget condition fails?** 1. Test MIB resolution: `ToweropsNative.resolve_oid("MIB::object")` 2. Test SNMP query manually: `snmpget -v2c -c public 192.168.1.1 1.3.6.1.4.1.10002...` 3. Check expected value (string comparison, not numeric) **Sensors not discovering?** 1. Verify `modules.sensors` section in discovery YAML 2. Check base_oid contains `{{ $index }}` for table sensors 3. Verify OID is walkable ## Current Status **Total Profiles**: 784 OS detection profiles in `priv/profiles/os_detection/` **Key Files**: - `lib/towerops/profiles/yaml_profiles.ex` - Profile matching logic - `lib/towerops/profiles/mib_cache.ex` - MIB resolution cache - `lib/towerops/snmp/profiles/dynamic.ex` - Device identification and sensor discovery - `lib/towerops_native.ex` - Rust NIF wrapper for MIB resolution