mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
20c51faaea
commit
2987f66363
@@ -145,6 +145,11 @@ If you are using Docker I recommend writing the config using TOML, [here](https:
|
|||||||
|`ssdp_scanner.enabled`|`true`|Whether to run the SSDP/UPnP scanner. Defaults to enabled; set to `false` to turn it off.|
|
|`ssdp_scanner.enabled`|`true`|Whether to run the SSDP/UPnP scanner. Defaults to enabled; set to `false` to turn it off.|
|
||||||
|`ssdp_scanner.probe_timeout`|`2s`|When an SSDP/UPnP-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address|
|
|`ssdp_scanner.probe_timeout`|`2s`|When an SSDP/UPnP-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address|
|
||||||
|`dhcp_scanner.enabled`|`true`|Whether to run the DHCP scanner. Defaults to enabled; set to `false` to turn it off.|
|
|`dhcp_scanner.enabled`|`true`|Whether to run the DHCP scanner. Defaults to enabled; set to `false` to turn it off.|
|
||||||
|
|`snmp_scanner.enabled`|`true` when the section is present, otherwise off|Whether to run the SNMP scanner. The whole `[snmp_scanner]` section is optional and the scanner stays off unless you add it; within the section it defaults to enabled.|
|
||||||
|
|`snmp_scanner.target`||SNMP agent to poll, as `host:port` (e.g. your gateway: `192.168.1.1:161`). The scanner reads the agent's ARP table over SNMPv2c — no local probing.|
|
||||||
|
|`snmp_scanner.community`||SNMPv2c read-only community string. Use a read-only community and never commit a real secret.|
|
||||||
|
|`snmp_scanner.wait_between_scans`|`2m`|Time to wait between polls. Keep it well under the agent's ARP cache timeout so active devices aren't missed.|
|
||||||
|
|`snmp_scanner.timeout`|`3s`|Per-poll SNMP request timeout.|
|
||||||
|`notifications.method`|`pushover`|For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)|
|
|`notifications.method`|`pushover`|For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)|
|
||||||
|`notifications.notify_when_not_seen_for`|`1w`|Send a notification if a device comes back online after not being seen for this timeframe (you can use hours, weeks, etc.)|
|
|`notifications.notify_when_not_seen_for`|`1w`|Send a notification if a device comes back online after not being seen for this timeframe (you can use hours, weeks, etc.)|
|
||||||
|`notifications.pushover.token`||Your pushover token goes here, just copy&paste from their website after creating the app|
|
|`notifications.pushover.token`||Your pushover token goes here, just copy&paste from their website after creating the app|
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
- [x] Add linting to CLAUDE.md for both Rust and Dart
|
- [x] Add linting to CLAUDE.md for both Rust and Dart
|
||||||
- [ ] Review and document the release process
|
- [ ] Review and document the release process
|
||||||
- [ ] Add support for push notifications to the app (iOS and Android)
|
- [ ] Add support for push notifications to the app (iOS and Android)
|
||||||
|
- [ ] Review the README.md file
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
- [x] Add configuration options to enable/disable each scanner
|
- [x] Add configuration options to enable/disable each scanner
|
||||||
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
||||||
|
- [ ] Add "devices seen on last scan" to the ARP and SNMP scanners status
|
||||||
|
- [ ] Review the recommended timings for ARP and SNMP scanners (change defaults)
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- [ ] Can we add front-end tests?
|
- [ ] Can we add front-end tests?
|
||||||
|
- [ ] Break down oott_api.dart in modules
|
||||||
|
|
||||||
## Improve engine
|
## Improve engine
|
||||||
|
|
||||||
@@ -22,4 +26,5 @@ Active (you probe the network):
|
|||||||
- NDP (Neighbor Discovery Protocol) — IPv6 equivalent of ARP; important if the network uses IPv6
|
- NDP (Neighbor Discovery Protocol) — IPv6 equivalent of ARP; important if the network uses IPv6
|
||||||
|
|
||||||
Via infrastructure:
|
Via infrastructure:
|
||||||
- SNMP query to router/switch — pull the router's ARP table or switch MAC table directly; no need to scan at all
|
- [x] SNMP query to router/firewall — pull the ARP table directly via SNMPv2c (no local probing); see `[snmp_scanner]` config
|
||||||
|
- Extend the SNMP scanner: SNMPv3 support, and switch MAC/forwarding table (port/VLAN) polling
|
||||||
|
|||||||
Generated
+154
-25
@@ -114,7 +114,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -333,7 +333,7 @@ dependencies = [
|
|||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -504,6 +504,38 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csnmp"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30da8dba8a9eccc31290275c3803f1a730044029e026bfda5db3538a79d3a4e6"
|
||||||
|
dependencies = [
|
||||||
|
"derivative",
|
||||||
|
"from-to-repr",
|
||||||
|
"simple_asn1",
|
||||||
|
"tokio 1.49.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derivative"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_arbitrary"
|
name = "derive_arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -512,7 +544,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -533,7 +565,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -699,6 +731,17 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "from-to-repr"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1c07b2fb0864fc59a417a61ef5bc41d935a3045bba84c50a1a45b8fb4f6917a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fuchsia-zircon"
|
name = "fuchsia-zircon"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1294,7 +1337,7 @@ checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1533,6 +1576,31 @@ version = "0.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
|
checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1581,6 +1649,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"config",
|
"config",
|
||||||
|
"csnmp",
|
||||||
"duration-string",
|
"duration-string",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
@@ -1628,7 +1697,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1750,7 +1819,7 @@ dependencies = [
|
|||||||
"pest_meta",
|
"pest_meta",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1780,7 +1849,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1852,7 +1921,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1922,6 +1991,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1938,7 +2013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2183,7 +2258,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"rust-embed-utils",
|
"rust-embed-utils",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2369,7 +2444,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2459,6 +2534,18 @@ dependencies = [
|
|||||||
"bitflags 2.12.0",
|
"bitflags 2.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
@@ -2525,6 +2612,17 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "1.0.109"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.114"
|
version = "2.0.114"
|
||||||
@@ -2550,7 +2648,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2583,7 +2681,38 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"itoa 1.0.17",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2744,7 +2873,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3114,7 +3243,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3250,7 +3379,7 @@ dependencies = [
|
|||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3371,7 +3500,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3382,7 +3511,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3466,7 +3595,7 @@ dependencies = [
|
|||||||
"heck",
|
"heck",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
"wasm-metadata",
|
"wasm-metadata",
|
||||||
"wit-bindgen-core",
|
"wit-bindgen-core",
|
||||||
"wit-component",
|
"wit-component",
|
||||||
@@ -3482,7 +3611,7 @@ dependencies = [
|
|||||||
"prettyplease",
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
"wit-bindgen-core",
|
"wit-bindgen-core",
|
||||||
"wit-bindgen-rust",
|
"wit-bindgen-rust",
|
||||||
]
|
]
|
||||||
@@ -3570,7 +3699,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3591,7 +3720,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3611,7 +3740,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3645,7 +3774,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
|||||||
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
||||||
simple-dns = "0.11.3"
|
simple-dns = "0.11.3"
|
||||||
socket2 = "0.6.4"
|
socket2 = "0.6.4"
|
||||||
|
csnmp = "0.6.0"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
scanners::mdns::status::init();
|
scanners::mdns::status::init();
|
||||||
scanners::ssdp::status::init();
|
scanners::ssdp::status::init();
|
||||||
scanners::dhcp::status::init();
|
scanners::dhcp::status::init();
|
||||||
|
scanners::snmp::status::init();
|
||||||
|
|
||||||
// Start the device scanners, web server, and retention cleaner in parallel
|
// Start the device scanners, web server, and retention cleaner in parallel
|
||||||
tokio::join!(
|
tokio::join!(
|
||||||
@@ -68,6 +69,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
scanners::mdns::scanner::listen(),
|
scanners::mdns::scanner::listen(),
|
||||||
scanners::ssdp::scanner::listen(),
|
scanners::ssdp::scanner::listen(),
|
||||||
scanners::dhcp::scanner::listen(),
|
scanners::dhcp::scanner::listen(),
|
||||||
|
scanners::snmp::scanner::scan(),
|
||||||
web_server::serve(),
|
web_server::serve(),
|
||||||
retention::run()
|
retention::run()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub enum DeviceEventScanner {
|
|||||||
Mdns,
|
Mdns,
|
||||||
Ssdp,
|
Ssdp,
|
||||||
Dhcp,
|
Dhcp,
|
||||||
|
Snmp,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for DeviceEventScanner {
|
impl fmt::Display for DeviceEventScanner {
|
||||||
@@ -129,6 +130,7 @@ impl fmt::Display for DeviceEventScanner {
|
|||||||
Self::Mdns => write!(f, "mDNS"),
|
Self::Mdns => write!(f, "mDNS"),
|
||||||
Self::Ssdp => write!(f, "SSDP"),
|
Self::Ssdp => write!(f, "SSDP"),
|
||||||
Self::Dhcp => write!(f, "DHCP"),
|
Self::Dhcp => write!(f, "DHCP"),
|
||||||
|
Self::Snmp => write!(f, "SNMP"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,7 @@ impl FromStr for DeviceEventScanner {
|
|||||||
"mDNS" => Ok(DeviceEventScanner::Mdns),
|
"mDNS" => Ok(DeviceEventScanner::Mdns),
|
||||||
"SSDP" => Ok(DeviceEventScanner::Ssdp),
|
"SSDP" => Ok(DeviceEventScanner::Ssdp),
|
||||||
"DHCP" => Ok(DeviceEventScanner::Dhcp),
|
"DHCP" => Ok(DeviceEventScanner::Dhcp),
|
||||||
|
"SNMP" => Ok(DeviceEventScanner::Snmp),
|
||||||
_ => Err(DeviceEventScannerParseError),
|
_ => Err(DeviceEventScannerParseError),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,3 +175,36 @@ impl FromSql for DeviceEventScanner {
|
|||||||
.map_err(|e| FromSqlError::Other(Box::new(e)))
|
.map_err(|e| FromSqlError::Other(Box::new(e)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scanner_display_and_from_str_round_trip() {
|
||||||
|
for scanner in [
|
||||||
|
DeviceEventScanner::Arp,
|
||||||
|
DeviceEventScanner::Mdns,
|
||||||
|
DeviceEventScanner::Ssdp,
|
||||||
|
DeviceEventScanner::Dhcp,
|
||||||
|
DeviceEventScanner::Snmp,
|
||||||
|
] {
|
||||||
|
let text = scanner.to_string();
|
||||||
|
assert_eq!(text.parse::<DeviceEventScanner>().unwrap(), scanner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snmp_scanner_displays_as_snmp() {
|
||||||
|
assert_eq!(DeviceEventScanner::Snmp.to_string(), "SNMP");
|
||||||
|
assert_eq!(
|
||||||
|
"SNMP".parse::<DeviceEventScanner>().unwrap(),
|
||||||
|
DeviceEventScanner::Snmp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_scanner_fails_to_parse() {
|
||||||
|
assert!("BOGUS".parse::<DeviceEventScanner>().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ pub mod arp;
|
|||||||
pub mod dhcp;
|
pub mod dhcp;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod mdns;
|
pub mod mdns;
|
||||||
|
pub mod snmp;
|
||||||
pub mod ssdp;
|
pub mod ssdp;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod finder;
|
||||||
|
pub mod scanner;
|
||||||
|
pub mod status;
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
use csnmp::{ObjectIdentifier, ObjectValue, Snmp2cClient};
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::data::mac_vendor_finder;
|
||||||
|
use crate::data::vendor_device_type_finder;
|
||||||
|
use crate::model::devices::Device;
|
||||||
|
use crate::settings::SnmpScanner;
|
||||||
|
|
||||||
|
// ipNetToMediaPhysAddress (RFC 1213 MIB-II): maps interface + IPv4 address to a MAC. Walking
|
||||||
|
// this column yields one row per neighbour in the agent's ARP cache. Rows are indexed by
|
||||||
|
// `<ifIndex>.<a>.<b>.<c>.<d>`, so the last four sub-identifiers of each returned OID are the
|
||||||
|
// IPv4 address and the value is the 6-byte hardware address.
|
||||||
|
const IP_NET_TO_MEDIA_PHYS_ADDRESS: [u32; 10] = [1, 3, 6, 1, 2, 1, 4, 22, 1, 2];
|
||||||
|
|
||||||
|
// How many rows to request per GETBULK round-trip while walking the table.
|
||||||
|
const MAX_REPETITIONS: u32 = 20;
|
||||||
|
|
||||||
|
/// Poll the configured SNMP agent's ARP cache and return the discovered devices, enriched with
|
||||||
|
/// vendor and device-type information. Errors (unreachable agent, timeout, bad community) are
|
||||||
|
/// propagated so the caller can log and retry on the next cycle.
|
||||||
|
pub async fn find(config: &SnmpScanner) -> Result<Vec<Device>, Box<dyn std::error::Error>> {
|
||||||
|
let target: SocketAddr = config.target.parse()?;
|
||||||
|
let timeout = Some(config.timeout.into());
|
||||||
|
|
||||||
|
debug!("Opening SNMP session to {target}");
|
||||||
|
let client = Snmp2cClient::new(
|
||||||
|
target,
|
||||||
|
config.community.clone().into_bytes(),
|
||||||
|
None, // bind address: let the OS choose
|
||||||
|
timeout,
|
||||||
|
0, // retries
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let base = ObjectIdentifier::try_from(&IP_NET_TO_MEDIA_PHYS_ADDRESS[..])?;
|
||||||
|
let rows = client.walk_bulk(base, MAX_REPETITIONS).await?;
|
||||||
|
info!("SNMP ARP table walk returned {} rows", rows.len());
|
||||||
|
|
||||||
|
let pairs = parse_arp_table(rows.iter(), &base);
|
||||||
|
|
||||||
|
let devices = pairs
|
||||||
|
.into_iter()
|
||||||
|
.map(|(mac, ip)| {
|
||||||
|
let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
|
||||||
|
let mut device = Device::new(mac, ip.to_string(), vendor, Local::now().to_utc());
|
||||||
|
device.device_type = vendor_device_type_finder::find(&device.vendor);
|
||||||
|
device
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the rows of an `ipNetToMediaPhysAddress` walk into `(mac, ipv4)` pairs.
|
||||||
|
///
|
||||||
|
/// Kept separate from the network I/O so it can be unit-tested with synthetic rows. Each row's
|
||||||
|
/// OID is `base.<ifIndex>.<a>.<b>.<c>.<d>`; the last four sub-identifiers form the IPv4 address
|
||||||
|
/// and the value is the 6-byte MAC. Rows that don't match this shape (wrong suffix length,
|
||||||
|
/// out-of-range octets, non-string or wrong-length value) are skipped.
|
||||||
|
pub fn parse_arp_table<'a, I>(rows: I, base: &ObjectIdentifier) -> Vec<(String, Ipv4Addr)>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (&'a ObjectIdentifier, &'a ObjectValue)>,
|
||||||
|
{
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
|
||||||
|
for (oid, value) in rows {
|
||||||
|
let suffix = match oid.relative_to(base) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => continue, // not under the table base
|
||||||
|
};
|
||||||
|
let parts = suffix.as_slice();
|
||||||
|
if parts.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IPv4 address is the last four sub-identifiers of the index.
|
||||||
|
let octets = &parts[parts.len() - 4..];
|
||||||
|
if octets.iter().any(|o| *o > 255) {
|
||||||
|
warn!("Ignoring SNMP ARP row with out-of-range IP octet: {octets:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ip = Ipv4Addr::new(
|
||||||
|
octets[0] as u8,
|
||||||
|
octets[1] as u8,
|
||||||
|
octets[2] as u8,
|
||||||
|
octets[3] as u8,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mac = match value {
|
||||||
|
ObjectValue::String(bytes) if bytes.len() == 6 => format_mac(bytes),
|
||||||
|
_ => {
|
||||||
|
debug!("Ignoring SNMP ARP row for {ip} with non-MAC value");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pairs.push((mac, ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format 6 raw MAC bytes as a lowercase colon-separated string.
|
||||||
|
fn format_mac(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn base() -> ObjectIdentifier {
|
||||||
|
ObjectIdentifier::try_from(&IP_NET_TO_MEDIA_PHYS_ADDRESS[..]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a full row OID: base + ifIndex + four IP octets.
|
||||||
|
fn row_oid(if_index: u32, ip: [u32; 4]) -> ObjectIdentifier {
|
||||||
|
let mut parts = IP_NET_TO_MEDIA_PHYS_ADDRESS.to_vec();
|
||||||
|
parts.push(if_index);
|
||||||
|
parts.extend_from_slice(&ip);
|
||||||
|
ObjectIdentifier::try_from(&parts[..]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_value(bytes: [u8; 6]) -> ObjectValue {
|
||||||
|
ObjectValue::String(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_valid_arp_row() {
|
||||||
|
let oid = row_oid(2, [192, 168, 1, 50]);
|
||||||
|
let value = mac_value([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
|
||||||
|
let rows = vec![(&oid, &value)];
|
||||||
|
|
||||||
|
let pairs = parse_arp_table(rows, &base());
|
||||||
|
assert_eq!(
|
||||||
|
pairs,
|
||||||
|
vec![("aa:bb:cc:dd:ee:ff".to_string(), Ipv4Addr::new(192, 168, 1, 50))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_rows_with_wrong_mac_length() {
|
||||||
|
let oid = row_oid(1, [10, 0, 0, 1]);
|
||||||
|
let value = ObjectValue::String(vec![0x01, 0x02, 0x03]); // too short
|
||||||
|
let rows = vec![(&oid, &value)];
|
||||||
|
assert!(parse_arp_table(rows, &base()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_non_string_values() {
|
||||||
|
let oid = row_oid(1, [10, 0, 0, 1]);
|
||||||
|
let value = ObjectValue::Integer(42);
|
||||||
|
let rows = vec![(&oid, &value)];
|
||||||
|
assert!(parse_arp_table(rows, &base()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_rows_outside_the_table_base() {
|
||||||
|
// An OID that is not under the table base must be ignored.
|
||||||
|
let unrelated = ObjectIdentifier::try_from(&[1u32, 3, 6, 1, 2, 1, 1, 1, 0][..]).unwrap();
|
||||||
|
let value = mac_value([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
|
||||||
|
let rows = vec![(&unrelated, &value)];
|
||||||
|
assert!(parse_arp_table(rows, &base()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_multiple_rows() {
|
||||||
|
let oid_a = row_oid(1, [192, 168, 1, 2]);
|
||||||
|
let val_a = mac_value([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||||
|
let oid_b = row_oid(1, [192, 168, 1, 3]);
|
||||||
|
let val_b = mac_value([0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb]);
|
||||||
|
let rows = vec![(&oid_a, &val_a), (&oid_b, &val_b)];
|
||||||
|
|
||||||
|
let pairs = parse_arp_table(rows, &base());
|
||||||
|
assert_eq!(pairs.len(), 2);
|
||||||
|
assert!(pairs.contains(&("00:11:22:33:44:55".to_string(), Ipv4Addr::new(192, 168, 1, 2))));
|
||||||
|
assert!(pairs.contains(&("66:77:88:99:aa:bb".to_string(), Ipv4Addr::new(192, 168, 1, 3))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_mac_pads_and_lowercases() {
|
||||||
|
assert_eq!(format_mac(&[0x0a, 0x00, 0xff, 0x10, 0x20, 0x30]), "0a:00:ff:10:20:30");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use super::finder;
|
||||||
|
use super::status;
|
||||||
|
use crate::db;
|
||||||
|
use crate::events;
|
||||||
|
use crate::model::device_events::DeviceEventScanner;
|
||||||
|
use crate::settings::get_settings;
|
||||||
|
use chrono::Utc;
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
|
/// Periodically poll an SNMP agent (typically the gateway) for its ARP/neighbour cache and feed
|
||||||
|
/// the discovered devices into the same pipeline used by the other scanners (devices table +
|
||||||
|
/// events + notifications). Unlike the ARP scanner this generates no traffic on the local
|
||||||
|
/// segment and can surface devices on subnets the host cannot reach directly.
|
||||||
|
pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config = &get_settings().snmp_scanner;
|
||||||
|
if !config.enabled {
|
||||||
|
info!("SNMP scanner disabled in configuration; not starting");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if config.target.is_empty() {
|
||||||
|
info!("SNMP scanner has no target configured; not starting");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("SNMP scanner polling agent at {}", config.target);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
status::set_running();
|
||||||
|
|
||||||
|
// A failed poll (unreachable agent, timeout, bad community) must not stop the loop;
|
||||||
|
// log it and try again next cycle.
|
||||||
|
match finder::find(config).await {
|
||||||
|
Ok(devices) => {
|
||||||
|
info!("SNMP poll found {} devices in the ARP cache", devices.len());
|
||||||
|
for device in devices.iter() {
|
||||||
|
process_device(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => error!("SNMP poll of {} failed: {err}", config.target),
|
||||||
|
}
|
||||||
|
|
||||||
|
let wait = Duration::from(config.wait_between_scans);
|
||||||
|
let next_scan_at = Utc::now() + chrono::Duration::from_std(wait).unwrap();
|
||||||
|
info!(
|
||||||
|
"SNMP scan finished. Sleeping for {}",
|
||||||
|
config.wait_between_scans
|
||||||
|
);
|
||||||
|
status::set_waiting(next_scan_at);
|
||||||
|
sleep(wait).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_device(device: &crate::model::devices::Device) {
|
||||||
|
match db::devices::read(device.mac_address.clone()) {
|
||||||
|
Some(recorded_device) => {
|
||||||
|
debug!("SNMP sighting of known device {}; updating", device.mac_address);
|
||||||
|
if let Err(err) = db::devices::seen(
|
||||||
|
device.mac_address.clone(),
|
||||||
|
device.ipv4_address.clone(),
|
||||||
|
device.vendor.clone(),
|
||||||
|
device.device_type.clone(),
|
||||||
|
device.name.clone(),
|
||||||
|
) {
|
||||||
|
error!("Failed to update SNMP device {}: {err}", device.mac_address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ignoring errors: do not stop the loop if notification delivery fails.
|
||||||
|
events::trigger_existing_device(recorded_device, device.clone(), DeviceEventScanner::Snmp)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
debug!(
|
||||||
|
"New device {} discovered via SNMP; inserting",
|
||||||
|
device.mac_address
|
||||||
|
);
|
||||||
|
if let Err(err) = db::devices::insert(device.clone()) {
|
||||||
|
error!("Failed to insert SNMP device {}: {err}", device.mac_address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events::trigger_new_device(device.clone(), DeviceEventScanner::Snmp).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct SnmpScannerStatus {
|
||||||
|
pub is_running: bool,
|
||||||
|
pub scan_started_at: Option<DateTime<Utc>>,
|
||||||
|
pub next_scan_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SnmpScannerStatusSnapshot {
|
||||||
|
pub is_running: bool,
|
||||||
|
pub scan_started_at: Option<DateTime<Utc>>,
|
||||||
|
pub next_scan_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static STATUS: OnceCell<Mutex<SnmpScannerStatus>> = OnceCell::new();
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
STATUS
|
||||||
|
.set(Mutex::new(SnmpScannerStatus {
|
||||||
|
is_running: false,
|
||||||
|
scan_started_at: None,
|
||||||
|
next_scan_at: None,
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_running() {
|
||||||
|
if let Some(m) = STATUS.get() {
|
||||||
|
let mut s = m.lock().unwrap();
|
||||||
|
s.is_running = true;
|
||||||
|
s.scan_started_at = Some(Utc::now());
|
||||||
|
s.next_scan_at = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
|
||||||
|
if let Some(m) = STATUS.get() {
|
||||||
|
let mut s = m.lock().unwrap();
|
||||||
|
s.is_running = false;
|
||||||
|
s.scan_started_at = None;
|
||||||
|
s.next_scan_at = Some(next_scan_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get() -> Option<SnmpScannerStatusSnapshot> {
|
||||||
|
STATUS.get().map(|m| {
|
||||||
|
let s = m.lock().unwrap();
|
||||||
|
SnmpScannerStatusSnapshot {
|
||||||
|
is_running: s.is_running,
|
||||||
|
scan_started_at: s.scan_started_at,
|
||||||
|
next_scan_at: s.next_scan_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn reset_for_test() {
|
||||||
|
if let Some(m) = STATUS.get() {
|
||||||
|
let mut s = m.lock().unwrap();
|
||||||
|
s.is_running = false;
|
||||||
|
s.scan_started_at = None;
|
||||||
|
s.next_scan_at = None;
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_running() {
|
||||||
|
reset_for_test();
|
||||||
|
set_running();
|
||||||
|
let snapshot = get().unwrap();
|
||||||
|
assert!(snapshot.is_running);
|
||||||
|
assert!(snapshot.scan_started_at.is_some());
|
||||||
|
assert!(snapshot.next_scan_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_waiting() {
|
||||||
|
reset_for_test();
|
||||||
|
let next = Utc::now() + chrono::Duration::seconds(60);
|
||||||
|
set_waiting(next);
|
||||||
|
let snapshot = get().unwrap();
|
||||||
|
assert!(!snapshot.is_running);
|
||||||
|
assert!(snapshot.scan_started_at.is_none());
|
||||||
|
assert_eq!(snapshot.next_scan_at.unwrap(), next);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_state() {
|
||||||
|
reset_for_test();
|
||||||
|
let snapshot = get().unwrap();
|
||||||
|
assert!(!snapshot.is_running);
|
||||||
|
assert!(snapshot.scan_started_at.is_none());
|
||||||
|
assert!(snapshot.next_scan_at.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Pushover {
|
pub struct Pushover {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
@@ -127,6 +153,8 @@ pub struct Settings {
|
|||||||
pub ssdp_scanner: SsdpScanner,
|
pub ssdp_scanner: SsdpScanner,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dhcp_scanner: DhcpScanner,
|
pub dhcp_scanner: DhcpScanner,
|
||||||
|
#[serde(default)]
|
||||||
|
pub snmp_scanner: SnmpScanner,
|
||||||
}
|
}
|
||||||
// End configuration structure
|
// End configuration structure
|
||||||
// -----------------------------------------------------------
|
// -----------------------------------------------------------
|
||||||
@@ -208,6 +236,26 @@ mod tests {
|
|||||||
assert!(settings.mdns_scanner.enabled);
|
assert!(settings.mdns_scanner.enabled);
|
||||||
assert!(settings.ssdp_scanner.enabled);
|
assert!(settings.ssdp_scanner.enabled);
|
||||||
assert!(settings.dhcp_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]
|
#[test]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::web_server::arp_scanner::ArpScannerStatusResponse;
|
|||||||
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
||||||
use crate::web_server::dhcp_scanner::DhcpScannerStatusResponse;
|
use crate::web_server::dhcp_scanner::DhcpScannerStatusResponse;
|
||||||
use crate::web_server::mdns_scanner::MdnsScannerStatusResponse;
|
use crate::web_server::mdns_scanner::MdnsScannerStatusResponse;
|
||||||
|
use crate::web_server::snmp_scanner::SnmpScannerStatusResponse;
|
||||||
use crate::web_server::ssdp_scanner::SsdpScannerStatusResponse;
|
use crate::web_server::ssdp_scanner::SsdpScannerStatusResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
@@ -32,6 +33,7 @@ pub mod devices;
|
|||||||
pub mod dhcp_scanner;
|
pub mod dhcp_scanner;
|
||||||
pub mod mdns_scanner;
|
pub mod mdns_scanner;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod snmp_scanner;
|
||||||
pub mod ssdp_scanner;
|
pub mod ssdp_scanner;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ pub mod utils;
|
|||||||
mdns_scanner::status,
|
mdns_scanner::status,
|
||||||
ssdp_scanner::status,
|
ssdp_scanner::status,
|
||||||
dhcp_scanner::status,
|
dhcp_scanner::status,
|
||||||
|
snmp_scanner::status,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
Device,
|
Device,
|
||||||
@@ -75,6 +78,7 @@ pub mod utils;
|
|||||||
MdnsScannerStatusResponse,
|
MdnsScannerStatusResponse,
|
||||||
SsdpScannerStatusResponse,
|
SsdpScannerStatusResponse,
|
||||||
DhcpScannerStatusResponse,
|
DhcpScannerStatusResponse,
|
||||||
|
SnmpScannerStatusResponse,
|
||||||
)),
|
)),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
tags(
|
tags(
|
||||||
@@ -85,6 +89,7 @@ pub mod utils;
|
|||||||
(name = "mdns_scanner", description = "mDNS/Bonjour scanner process status"),
|
(name = "mdns_scanner", description = "mDNS/Bonjour scanner process status"),
|
||||||
(name = "ssdp_scanner", description = "SSDP/UPnP scanner process status"),
|
(name = "ssdp_scanner", description = "SSDP/UPnP scanner process status"),
|
||||||
(name = "dhcp_scanner", description = "DHCP scanner process status"),
|
(name = "dhcp_scanner", description = "DHCP scanner process status"),
|
||||||
|
(name = "snmp_scanner", description = "SNMP scanner process status"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
@@ -147,6 +152,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
|||||||
.route("/api/mdns_scanner/status", get(mdns_scanner::status))
|
.route("/api/mdns_scanner/status", get(mdns_scanner::status))
|
||||||
.route("/api/ssdp_scanner/status", get(ssdp_scanner::status))
|
.route("/api/ssdp_scanner/status", get(ssdp_scanner::status))
|
||||||
.route("/api/dhcp_scanner/status", get(dhcp_scanner::status))
|
.route("/api/dhcp_scanner/status", get(dhcp_scanner::status))
|
||||||
|
.route("/api/snmp_scanner/status", get(snmp_scanner::status))
|
||||||
.route("/api/notifications", get(notifications::list))
|
.route("/api/notifications", get(notifications::list))
|
||||||
.route(
|
.route(
|
||||||
"/api/notifications/mark_all_as_old",
|
"/api/notifications/mark_all_as_old",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use axum::{Json, http::StatusCode};
|
||||||
|
use chrono::Utc;
|
||||||
|
use log::error;
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SnmpScannerStatusResponse {
|
||||||
|
pub is_running: bool,
|
||||||
|
/// Seconds the current poll has been running (only set when is_running is true)
|
||||||
|
pub running_for_seconds: Option<f64>,
|
||||||
|
/// Seconds until the next poll starts (only set when is_running is false; clamped to 0)
|
||||||
|
pub next_run_in_seconds: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/snmp_scanner/status",
|
||||||
|
tag = "snmp_scanner",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "SNMP scanner status", body = SnmpScannerStatusResponse),
|
||||||
|
(status = 500, description = "Internal server error"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn status() -> Result<Json<SnmpScannerStatusResponse>, StatusCode> {
|
||||||
|
let snapshot = match crate::scanners::snmp::status::get() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
error!("SNMP scanner status not initialized");
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let (running_for_seconds, next_run_in_seconds) = if snapshot.is_running {
|
||||||
|
let running_for = snapshot
|
||||||
|
.scan_started_at
|
||||||
|
.map(|t| (now - t).num_milliseconds() as f64 / 1000.0);
|
||||||
|
(running_for, None)
|
||||||
|
} else {
|
||||||
|
let next_run_in = snapshot.next_scan_at.map(|t| {
|
||||||
|
let secs = (t - now).num_milliseconds() as f64 / 1000.0;
|
||||||
|
secs.max(0.0)
|
||||||
|
});
|
||||||
|
(None, next_run_in)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(SnmpScannerStatusResponse {
|
||||||
|
is_running: snapshot.is_running,
|
||||||
|
running_for_seconds,
|
||||||
|
next_run_in_seconds,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -24,6 +24,16 @@ probe_timeout="2s" # When an SSDP/UPnP-discovered IP is not in the OS ARP cache,
|
|||||||
[dhcp_scanner]
|
[dhcp_scanner]
|
||||||
enabled=true # Set to false to disable the DHCP scanner
|
enabled=true # Set to false to disable the DHCP scanner
|
||||||
|
|
||||||
|
# [snmp_scanner] # Optional: poll a router/firewall's ARP table over SNMP. Disabled unless this section is present.
|
||||||
|
# enabled=true # Set to false to disable the SNMP scanner without removing this section
|
||||||
|
# target="192.168.1.1:161" # SNMP agent to poll, as host:port. Point this at your gateway (router/firewall).
|
||||||
|
# community="public" # SNMPv2c read-only community string. Use a read-only community; never commit a real secret here.
|
||||||
|
# wait_between_scans="2m" # Wait time between polls. Keep this well under the agent's ARP cache timeout so active devices aren't missed.
|
||||||
|
# timeout="3s" # Per-poll SNMP request timeout.
|
||||||
|
# Note: SNMP must be enabled on the target. On pfSense: Services > SNMP (v2c; the mibII module exposes the ARP table).
|
||||||
|
# On OPNsense: install the os-net-snmp plugin, then Services > Net-SNMP. In both cases add a firewall rule allowing
|
||||||
|
# UDP/161 from the host running OOTT.
|
||||||
|
|
||||||
[notifications]
|
[notifications]
|
||||||
method="pushover" # For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)
|
method="pushover" # For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)
|
||||||
notify_when_not_seen_for="1w" # Send a notification if a device comes back online after not being seen for this timeframe
|
notify_when_not_seen_for="1w" # Send a notification if a device comes back online after not being seen for this timeframe
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class DeviceEvent {
|
|||||||
'Mdns' => 'mDNS',
|
'Mdns' => 'mDNS',
|
||||||
'Ssdp' => 'SSDP/UPnP',
|
'Ssdp' => 'SSDP/UPnP',
|
||||||
'Dhcp' => 'DHCP',
|
'Dhcp' => 'DHCP',
|
||||||
|
'Snmp' => 'SNMP',
|
||||||
_ => scanner,
|
_ => scanner,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class SnmpScannerStatus {
|
||||||
|
final bool isRunning;
|
||||||
|
final double? runningForSeconds;
|
||||||
|
final double? nextRunInSeconds;
|
||||||
|
|
||||||
|
const SnmpScannerStatus({
|
||||||
|
required this.isRunning,
|
||||||
|
this.runningForSeconds,
|
||||||
|
this.nextRunInSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SnmpScannerStatus.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SnmpScannerStatus(
|
||||||
|
isRunning: json['is_running'] as bool,
|
||||||
|
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
|
||||||
|
nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../widgets/arp_scanner_card.dart';
|
import '../widgets/arp_scanner_card.dart';
|
||||||
import '../widgets/dhcp_scanner_card.dart';
|
import '../widgets/dhcp_scanner_card.dart';
|
||||||
import '../widgets/mdns_scanner_card.dart';
|
import '../widgets/mdns_scanner_card.dart';
|
||||||
|
import '../widgets/snmp_scanner_card.dart';
|
||||||
import '../widgets/ssdp_scanner_card.dart';
|
import '../widgets/ssdp_scanner_card.dart';
|
||||||
|
|
||||||
class StatusScreen extends StatelessWidget {
|
class StatusScreen extends StatelessWidget {
|
||||||
@@ -22,6 +23,8 @@ class StatusScreen extends StatelessWidget {
|
|||||||
const SsdpScannerCard(),
|
const SsdpScannerCard(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const DhcpScannerCard(),
|
const DhcpScannerCard(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const SnmpScannerCard(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../model/arp_scanner_status.dart';
|
|||||||
import '../model/dhcp_scanner_status.dart';
|
import '../model/dhcp_scanner_status.dart';
|
||||||
import '../model/mdns_scanner_status.dart';
|
import '../model/mdns_scanner_status.dart';
|
||||||
import '../model/ssdp_scanner_status.dart';
|
import '../model/ssdp_scanner_status.dart';
|
||||||
|
import '../model/snmp_scanner_status.dart';
|
||||||
import '../model/device.dart';
|
import '../model/device.dart';
|
||||||
import '../model/device_event.dart';
|
import '../model/device_event.dart';
|
||||||
import '../model/device_summary.dart';
|
import '../model/device_summary.dart';
|
||||||
@@ -333,6 +334,16 @@ class BackendAPI {
|
|||||||
return DhcpScannerStatus.fromJson(response.data as Map<String, dynamic>);
|
return DhcpScannerStatus.fromJson(response.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<SnmpScannerStatus> getSnmpScannerStatus({
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/snmp_scanner/status',
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
return SnmpScannerStatus.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
Future<({List<Notification> items, bool hasNextPage})> listNotifications(
|
Future<({List<Notification> items, bool hasNextPage})> listNotifications(
|
||||||
bool? isNew, {
|
bool? isNew, {
|
||||||
int page = 0,
|
int page = 0,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../model/arp_scanner_status.dart';
|
import '../model/arp_scanner_status.dart';
|
||||||
import '../model/dhcp_scanner_status.dart';
|
import '../model/dhcp_scanner_status.dart';
|
||||||
import '../model/mdns_scanner_status.dart';
|
import '../model/mdns_scanner_status.dart';
|
||||||
|
import '../model/snmp_scanner_status.dart';
|
||||||
import '../model/ssdp_scanner_status.dart';
|
import '../model/ssdp_scanner_status.dart';
|
||||||
import '../navigation.dart';
|
import '../navigation.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
@@ -28,6 +29,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
late final PolledValue<MdnsScannerStatus> _mdns;
|
late final PolledValue<MdnsScannerStatus> _mdns;
|
||||||
late final PolledValue<SsdpScannerStatus> _ssdp;
|
late final PolledValue<SsdpScannerStatus> _ssdp;
|
||||||
late final PolledValue<DhcpScannerStatus> _dhcp;
|
late final PolledValue<DhcpScannerStatus> _dhcp;
|
||||||
|
late final PolledValue<SnmpScannerStatus> _snmp;
|
||||||
Timer? _tickTimer;
|
Timer? _tickTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -58,6 +60,12 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
pollInterval: const Duration(seconds: 5),
|
pollInterval: const Duration(seconds: 5),
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
staleErrorAfter: const Duration(seconds: 30),
|
||||||
);
|
);
|
||||||
|
_snmp = PolledValue<SnmpScannerStatus>(
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
|
||||||
|
pollInterval: const Duration(seconds: 5),
|
||||||
|
staleErrorAfter: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
@@ -96,6 +104,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
_mdns.pause();
|
_mdns.pause();
|
||||||
_ssdp.pause();
|
_ssdp.pause();
|
||||||
_dhcp.pause();
|
_dhcp.pause();
|
||||||
|
_snmp.pause();
|
||||||
_tickTimer?.cancel();
|
_tickTimer?.cancel();
|
||||||
_tickTimer = null;
|
_tickTimer = null;
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
_mdns.resume();
|
_mdns.resume();
|
||||||
_ssdp.resume();
|
_ssdp.resume();
|
||||||
_dhcp.resume();
|
_dhcp.resume();
|
||||||
|
_snmp.resume();
|
||||||
_tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
_tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
@@ -119,6 +129,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
_mdns.dispose();
|
_mdns.dispose();
|
||||||
_ssdp.dispose();
|
_ssdp.dispose();
|
||||||
_dhcp.dispose();
|
_dhcp.dispose();
|
||||||
|
_snmp.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +141,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
_mdns,
|
_mdns,
|
||||||
_ssdp,
|
_ssdp,
|
||||||
_dhcp,
|
_dhcp,
|
||||||
|
_snmp,
|
||||||
BackendReachability.instance,
|
BackendReachability.instance,
|
||||||
]),
|
]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -137,10 +149,12 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
final mdnsFreshness = effectiveFreshness(_mdns);
|
final mdnsFreshness = effectiveFreshness(_mdns);
|
||||||
final ssdpFreshness = effectiveFreshness(_ssdp);
|
final ssdpFreshness = effectiveFreshness(_ssdp);
|
||||||
final dhcpFreshness = effectiveFreshness(_dhcp);
|
final dhcpFreshness = effectiveFreshness(_dhcp);
|
||||||
|
final snmpFreshness = effectiveFreshness(_snmp);
|
||||||
if (arpFreshness == PolledFreshness.initialLoading ||
|
if (arpFreshness == PolledFreshness.initialLoading ||
|
||||||
mdnsFreshness == PolledFreshness.initialLoading ||
|
mdnsFreshness == PolledFreshness.initialLoading ||
|
||||||
ssdpFreshness == PolledFreshness.initialLoading ||
|
ssdpFreshness == PolledFreshness.initialLoading ||
|
||||||
dhcpFreshness == PolledFreshness.initialLoading) {
|
dhcpFreshness == PolledFreshness.initialLoading ||
|
||||||
|
snmpFreshness == PolledFreshness.initialLoading) {
|
||||||
return const Card(
|
return const Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -153,6 +167,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness);
|
final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness);
|
||||||
final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness);
|
final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness);
|
||||||
final (dhcpColor, dhcpText) = _resolveDhcp(context, dhcpFreshness);
|
final (dhcpColor, dhcpText) = _resolveDhcp(context, dhcpFreshness);
|
||||||
|
final (snmpColor, snmpText) = _resolveSnmp(context, snmpFreshness);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -203,6 +218,15 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
_dhcp,
|
_dhcp,
|
||||||
dhcpFreshness,
|
dhcpFreshness,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_scannerRow(
|
||||||
|
context,
|
||||||
|
snmpColor,
|
||||||
|
'SNMP',
|
||||||
|
snmpText,
|
||||||
|
_snmp,
|
||||||
|
snmpFreshness,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -350,4 +374,33 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
}
|
}
|
||||||
return (neutralColor, 'Not started');
|
return (neutralColor, 'Not started');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(Color, String) _resolveSnmp(
|
||||||
|
BuildContext context,
|
||||||
|
PolledFreshness freshness,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final successColor = theme.extension<AppColorExtension>()!.success;
|
||||||
|
final neutralColor = theme.colorScheme.outline;
|
||||||
|
if (freshness == PolledFreshness.error) {
|
||||||
|
return (theme.colorScheme.error, 'Error');
|
||||||
|
}
|
||||||
|
final status = _snmp.value!;
|
||||||
|
final elapsed = DateTime.now()
|
||||||
|
.difference(_snmp.lastSuccessAt!)
|
||||||
|
.inSeconds
|
||||||
|
.toDouble();
|
||||||
|
if (status.isRunning) {
|
||||||
|
final secs = (status.runningForSeconds ?? 0) + elapsed;
|
||||||
|
return (successColor, 'Running for ${formatSeconds(secs)}');
|
||||||
|
}
|
||||||
|
if (status.nextRunInSeconds != null) {
|
||||||
|
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
|
||||||
|
0.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
return (neutralColor, 'Next run in ${formatSeconds(remaining)}');
|
||||||
|
}
|
||||||
|
return (neutralColor, 'Not started');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../model/snmp_scanner_status.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/duration_formatter.dart';
|
||||||
|
import '../utils/oott_api.dart';
|
||||||
|
import 'scanner_status_card.dart';
|
||||||
|
|
||||||
|
class SnmpScannerCard extends StatelessWidget {
|
||||||
|
const SnmpScannerCard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ScannerStatusCard<SnmpScannerStatus>(
|
||||||
|
title: 'SNMP Scanner',
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
|
||||||
|
resolver: _resolve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScannerStatus _resolve(
|
||||||
|
BuildContext context,
|
||||||
|
SnmpScannerStatus status,
|
||||||
|
double elapsed,
|
||||||
|
) {
|
||||||
|
final successColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).extension<AppColorExtension>()!.success;
|
||||||
|
final neutralColor = Theme.of(context).colorScheme.outline;
|
||||||
|
if (status.isRunning) {
|
||||||
|
final sub = status.runningForSeconds != null
|
||||||
|
? ['Running for ${formatSeconds(status.runningForSeconds! + elapsed)}']
|
||||||
|
: <String>[];
|
||||||
|
return (color: successColor, label: 'Running', sublabels: sub);
|
||||||
|
}
|
||||||
|
if (status.nextRunInSeconds != null) {
|
||||||
|
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
|
||||||
|
0.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
color: neutralColor,
|
||||||
|
label: 'Waiting for next run',
|
||||||
|
sublabels: ['Next run in ${formatSeconds(remaining)}'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (color: neutralColor, label: 'Not started', sublabels: []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,31 @@ in {
|
|||||||
description = "Whether to run the DHCP scanner.";
|
description = "Whether to run the DHCP scanner.";
|
||||||
default = true;
|
default = true;
|
||||||
};
|
};
|
||||||
|
snmp_scanner.enabled = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
description = "Whether to run the SNMP scanner. Off by default; requires a target and community to be set.";
|
||||||
|
default = false;
|
||||||
|
};
|
||||||
|
snmp_scanner.target = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "SNMP agent to poll, as host:port (e.g. the gateway: 192.168.1.1:161). The scanner reads its ARP table over SNMPv2c.";
|
||||||
|
default = "";
|
||||||
|
};
|
||||||
|
snmp_scanner.community = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "SNMPv2c read-only community string. Use a read-only community and avoid committing real secrets.";
|
||||||
|
default = "";
|
||||||
|
};
|
||||||
|
snmp_scanner.wait_between_scans = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Wait time between SNMP polls. Keep it well under the agent's ARP cache timeout so active devices aren't missed.";
|
||||||
|
default = "2m";
|
||||||
|
};
|
||||||
|
snmp_scanner.timeout = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Per-poll SNMP request timeout.";
|
||||||
|
default = "3s";
|
||||||
|
};
|
||||||
notifications.method = mkOption {
|
notifications.method = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
description = "For now just pushover, you can set this to none to avoid sending notifications (it will just log).";
|
description = "For now just pushover, you can set this to none to avoid sending notifications (it will just log).";
|
||||||
|
|||||||
Reference in New Issue
Block a user