Add SNMP scanner that polls a gateway's ARP table

Introduce a sixth scanner that periodically queries an SNMP agent
(typically the router/firewall) for its ARP/neighbour cache via
SNMPv2c and feeds discovered devices into the shared devices, events
and notifications pipeline. Unlike the ARP scanner it generates no
traffic on the local segment and can surface devices across all
subnets the agent routes.

Scope is intentionally minimal: SNMPv2c only, a single target, and the
ipNetToMediaTable (ARP) only. SNMPv3 and switch MAC/forwarding-table
polling are left as follow-ups in TODO.md.

- backend: csnmp dependency; SnmpScanner config (opt-in, off unless a
  [snmp_scanner] section is present); DeviceEventScanner::Snmp;
  scanners/snmp/{finder,scanner,status}; main.rs wiring; status API
  endpoint wired into OpenAPI
- frontend: SNMP scanner status model, card, API method, status screen
  and summary card rows, and device-event label
- docs/config: sample TOML, README options, NixOS module option, and
  setup notes for enabling SNMP on pfSense/OPNsense

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-02 08:03:20 -04:00
co-authored by Claude Opus 4.8
parent 20c51faaea
commit 2987f66363
22 changed files with 868 additions and 27 deletions
+48
View File
@@ -78,6 +78,32 @@ impl Default for DhcpScanner {
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct SnmpScanner {
#[serde(default = "default_true")]
pub enabled: bool,
/// SNMP agent to poll, as `host:port` (e.g. the gateway: `192.168.1.1:161`).
pub target: String,
/// SNMPv2c read-only community string.
pub community: String,
pub wait_between_scans: DurationString,
pub timeout: DurationString,
}
impl Default for SnmpScanner {
fn default() -> Self {
// No universal target exists, so the SNMP scanner stays off until the user adds a
// `[snmp_scanner]` section pointing at their gateway.
SnmpScanner {
enabled: false,
target: String::new(),
community: String::new(),
wait_between_scans: DurationString::try_from("2m".to_string()).unwrap(),
timeout: DurationString::try_from("3s".to_string()).unwrap(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Pushover {
pub token: String,
@@ -127,6 +153,8 @@ pub struct Settings {
pub ssdp_scanner: SsdpScanner,
#[serde(default)]
pub dhcp_scanner: DhcpScanner,
#[serde(default)]
pub snmp_scanner: SnmpScanner,
}
// End configuration structure
// -----------------------------------------------------------
@@ -208,6 +236,26 @@ mod tests {
assert!(settings.mdns_scanner.enabled);
assert!(settings.ssdp_scanner.enabled);
assert!(settings.dhcp_scanner.enabled);
// The SNMP scanner is opt-in: with no `[snmp_scanner]` section it stays disabled.
assert!(!settings.snmp_scanner.enabled);
}
#[test]
fn snmp_scanner_section_is_parsed() {
let toml = format!(
"{BASE_CONFIG}
[snmp_scanner]
target = \"192.168.1.1:161\"
community = \"public\"
wait_between_scans = \"5m\"
timeout = \"3s\"
"
);
let settings = parse(&toml);
// `enabled` defaults to true once the section is present.
assert!(settings.snmp_scanner.enabled);
assert_eq!(settings.snmp_scanner.target, "192.168.1.1:161");
assert_eq!(settings.snmp_scanner.community, "public");
}
#[test]