Make optional config fields fall back to defaults

Several settings were required at parse time even though they had a
sensible default, causing the backend to panic on startup when omitted:

- notifications.notify_when_not_seen_for now defaults to "1w"
- the whole [networking] section is now optional (no mandatory fields)
- scanner duration/timeout fields (ARP, mDNS, SSDP, SNMP) now fall back
  to their defaults when the section is present but the field is omitted.
  serde only applies a field default when marked #[serde(default)], so
  the per-field attributes were added and the Default impls now share the
  same default functions as the single source of truth.

Updates the sample TOML and README accordingly and adds tests covering
each defaulting case.
This commit is contained in:
rzuasti
2026-06-04 09:02:36 -04:00
parent 9367c359d7
commit 38946d3abe
3 changed files with 143 additions and 10 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ Options marked **Required** have no built-in default and must be set in your con
|`snmp_scanner.wait_between_scans`|`10m`|Time to wait between polls. Keep it well under the agent's ARP cache timeout so active devices aren't missed.|
|`snmp_scanner.timeout`|`5s`|Per-poll SNMP request timeout.|
|`notifications.method`|**Required**|For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)|
|`notifications.notify_when_not_seen_for`|**Required**|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`|**Required**|Your pushover token goes here, just copy&paste from their website after creating the app (may be left empty when `method` is `none`)|
|`notifications.pushover.user_key`|**Required**|User key goes here, this is the account wide code for pushover (may be left empty when `method` is `none`)|
|`retention.window`|`365d`|How long to retain device events and notifications. Records older than this are purged daily. Accepts duration strings (e.g. `90d`, `1y`, `6m`).|
+140 -8
View File
@@ -22,12 +22,40 @@ fn default_port() -> u16 {
3000
}
fn default_notify_when_not_seen_for() -> DurationString {
DurationString::try_from("1w".to_string()).unwrap()
}
fn default_arp_wait_between_scans() -> DurationString {
DurationString::try_from("30m".to_string()).unwrap()
}
fn default_arp_sender_timeout() -> DurationString {
DurationString::try_from("1m".to_string()).unwrap()
}
fn default_arp_scan_duration() -> DurationString {
DurationString::try_from("10m".to_string()).unwrap()
}
fn default_probe_timeout() -> DurationString {
DurationString::try_from("2s".to_string()).unwrap()
}
fn default_snmp_wait_between_scans() -> DurationString {
DurationString::try_from("10m".to_string()).unwrap()
}
fn default_snmp_timeout() -> DurationString {
DurationString::try_from("5s".to_string()).unwrap()
}
#[derive(Debug, Deserialize, Clone)]
pub struct Database {
pub path: String,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Clone, Default)]
pub struct Networking {
pub interface: Option<String>,
}
@@ -50,8 +78,11 @@ impl Default for Log {
pub struct ArpScanner {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_arp_wait_between_scans")]
pub wait_between_scans: DurationString,
#[serde(default = "default_arp_sender_timeout")]
pub sender_timeout: DurationString,
#[serde(default = "default_arp_scan_duration")]
pub scan_duration: DurationString,
}
@@ -59,9 +90,9 @@ impl Default for ArpScanner {
fn default() -> Self {
ArpScanner {
enabled: true,
wait_between_scans: DurationString::try_from("30m".to_string()).unwrap(),
sender_timeout: DurationString::try_from("1m".to_string()).unwrap(),
scan_duration: DurationString::try_from("10m".to_string()).unwrap(),
wait_between_scans: default_arp_wait_between_scans(),
sender_timeout: default_arp_sender_timeout(),
scan_duration: default_arp_scan_duration(),
}
}
}
@@ -70,6 +101,7 @@ impl Default for ArpScanner {
pub struct MdnsScanner {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_probe_timeout")]
pub probe_timeout: DurationString,
}
@@ -77,7 +109,7 @@ impl Default for MdnsScanner {
fn default() -> Self {
MdnsScanner {
enabled: true,
probe_timeout: DurationString::try_from("2s".to_string()).unwrap(),
probe_timeout: default_probe_timeout(),
}
}
}
@@ -86,6 +118,7 @@ impl Default for MdnsScanner {
pub struct SsdpScanner {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_probe_timeout")]
pub probe_timeout: DurationString,
}
@@ -93,7 +126,7 @@ impl Default for SsdpScanner {
fn default() -> Self {
SsdpScanner {
enabled: true,
probe_timeout: DurationString::try_from("2s".to_string()).unwrap(),
probe_timeout: default_probe_timeout(),
}
}
}
@@ -118,7 +151,9 @@ pub struct SnmpScanner {
pub target: String,
/// SNMPv2c read-only community string.
pub community: String,
#[serde(default = "default_snmp_wait_between_scans")]
pub wait_between_scans: DurationString,
#[serde(default = "default_snmp_timeout")]
pub timeout: DurationString,
}
@@ -130,8 +165,8 @@ impl Default for SnmpScanner {
enabled: false,
target: String::new(),
community: String::new(),
wait_between_scans: DurationString::try_from("10m".to_string()).unwrap(),
timeout: DurationString::try_from("5s".to_string()).unwrap(),
wait_between_scans: default_snmp_wait_between_scans(),
timeout: default_snmp_timeout(),
}
}
}
@@ -146,6 +181,7 @@ pub struct Pushover {
pub struct Notifications {
pub method: String,
pub pushover: Pushover,
#[serde(default = "default_notify_when_not_seen_for")]
pub notify_when_not_seen_for: DurationString,
}
@@ -187,6 +223,7 @@ impl Default for DeviceEvents {
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub database: Database,
#[serde(default)]
pub networking: Networking,
#[serde(default)]
pub log: Log,
@@ -309,6 +346,69 @@ mod tests {
assert_eq!(settings.snmp_scanner.community, "public");
}
#[test]
fn snmp_scanner_durations_default_when_omitted() {
// The section is present (with only the required target/community), so the
// duration fields must fall back to their defaults rather than failing.
let toml = format!(
"{BASE_CONFIG}
[snmp_scanner]
target = \"192.168.1.1:161\"
community = \"public\"
"
);
let settings = parse(&toml);
assert!(settings.snmp_scanner.enabled);
assert_eq!(
std::time::Duration::from(settings.snmp_scanner.wait_between_scans),
std::time::Duration::from_secs(10 * 60)
);
assert_eq!(
std::time::Duration::from(settings.snmp_scanner.timeout),
std::time::Duration::from_secs(5)
);
}
#[test]
fn scanner_durations_default_when_section_present_but_fields_omitted() {
// Each scanner section is present (e.g. to toggle `enabled`) but the duration
// fields are omitted; they must fall back to their defaults, not fail parsing.
let toml = format!(
"{BASE_CONFIG}
[mdns_scanner]
enabled = true
[ssdp_scanner]
enabled = true
"
);
// Keep the `[arp_scanner]` section but drop its duration fields.
let toml = toml.replace(
"[arp_scanner]\n wait_between_scans = \"1m\"\n sender_timeout = \"1m\"\n scan_duration = \"2m\"",
"[arp_scanner]",
);
let settings = parse(&toml);
assert_eq!(
std::time::Duration::from(settings.arp_scanner.wait_between_scans),
std::time::Duration::from_secs(30 * 60)
);
assert_eq!(
std::time::Duration::from(settings.arp_scanner.sender_timeout),
std::time::Duration::from_secs(60)
);
assert_eq!(
std::time::Duration::from(settings.arp_scanner.scan_duration),
std::time::Duration::from_secs(10 * 60)
);
assert_eq!(
std::time::Duration::from(settings.mdns_scanner.probe_timeout),
std::time::Duration::from_secs(2)
);
assert_eq!(
std::time::Duration::from(settings.ssdp_scanner.probe_timeout),
std::time::Duration::from_secs(2)
);
}
#[test]
fn arp_scanner_uses_code_defaults_when_section_omitted() {
// A config without an `[arp_scanner]` section falls back to the code defaults.
@@ -422,6 +522,38 @@ mod tests {
);
}
#[test]
fn notify_when_not_seen_for_defaults_when_field_omitted() {
// Drop the `notify_when_not_seen_for` field; it should fall back to the "1w" default.
let toml = BASE_CONFIG.replace("\n notify_when_not_seen_for = \"1w\"", "");
let settings = parse(&toml);
assert_eq!(
std::time::Duration::from(settings.notifications.notify_when_not_seen_for),
std::time::Duration::from_secs(7 * 24 * 60 * 60)
);
}
#[test]
fn networking_section_is_optional() {
// The `[networking]` section has no mandatory fields, so omitting it entirely
// should fall back to the code default (interface auto-detected at runtime).
const NO_NETWORKING_CONFIG: &str = r#"
[database]
path = "./oott.db"
[log]
level = "info"
[notifications]
method = "none"
[notifications.pushover]
token = ""
user_key = ""
[web_server]
api_key = "test"
"#;
let settings = parse(NO_NETWORKING_CONFIG);
assert!(settings.networking.interface.is_none());
}
#[test]
fn scanners_can_be_disabled() {
let toml = format!(
+2 -1
View File
@@ -1,6 +1,7 @@
[database]
path = "/db/oott.db" # Database path. For the Docker image this must be "/db/oott.db" (the mounted volume); make sure it's writeable for the user running this process
# The whole [networking] section is optional; omit it to auto-detect the interface.
[networking]
# interface = "eno1" # Optional: network interface to use for scans. If not set, the first non-loopback connected interface is used.
@@ -36,7 +37,7 @@ enabled=true # Set to false to disable the DHCP scanner
[notifications]
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. Defaults to "1w" if omitted
[notifications.pushover]
token="" # Your pushover token goes here, just copy&paste from their website after creating the app