From c828f3a1a4e4be9278ea69fa2dc97a05d0094bf8 Mon Sep 17 00:00:00 2001 From: Mike Pastore Date: Fri, 7 Aug 2026 17:27:45 -0500 Subject: [PATCH 1/5] feat: optional per-device ip address tracking via periodic arp scan Adds a per-device "Track IP address" toggle (disabled by default) and a global cron interval setting. When enabled, upsnap periodically arp-scans the local subnets of opted-in devices with nmap and updates a device's ip address whenever its mac address is found at a different one. Subnets are skipped unless directly attached to the host, so routed devices are never scanned. Uses the same nmap + CAP_NET_RAW requirements as the existing network scan feature; no new privileges needed. Co-Authored-By: Claude Fable 5 --- README.md | 1 + backend/cronjobs/cronjobs.go | 92 +++++++++++++++++++ .../migrations/1786060800_updated_devices.go | 40 ++++++++ .../1786060801_updated_settings_private.go | 45 +++++++++ backend/networking/scan.go | 43 +++++++++ backend/networking/scan_linux.go | 41 +++++++++ backend/networking/scan_other.go | 7 ++ backend/networking/trackip.go | 42 +++++++++ docker-compose.yml | 2 +- frontend/src/lib/components/DeviceForm.svelte | 8 ++ frontend/src/lib/types/device.ts | 1 + frontend/src/lib/types/settings.ts | 1 + frontend/src/routes/settings/+page.svelte | 31 +++++++ frontend/translations/en-US.json | 4 + 14 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/1786060800_updated_devices.go create mode 100644 backend/migrations/1786060801_updated_settings_private.go create mode 100644 backend/networking/scan.go create mode 100644 backend/networking/scan_linux.go create mode 100644 backend/networking/scan_other.go create mode 100644 backend/networking/trackip.go diff --git a/README.md b/README.md index c1c1a101c..0abf5b85c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - ⏰ Timed Events via Cron for Automation - 🔌 Ping Any Port You Choose - 🔍 Discover Devices with Network Scanning (nmap required) +- 📌 Optional IP Address Tracking per Device via periodic ARP scan (nmap required, no extra privileges beyond network scanning) - ❎️ Shutdown Devices with a Custom Command - 👤 Secured User Management - 🌐 i18n support for [these](/frontend/translations) languages diff --git a/backend/cronjobs/cronjobs.go b/backend/cronjobs/cronjobs.go index aa875aa2e..4c4524402 100644 --- a/backend/cronjobs/cronjobs.go +++ b/backend/cronjobs/cronjobs.go @@ -1,6 +1,9 @@ package cronjobs import ( + "net" + "sync/atomic" + "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" "github.com/robfig/cron/v3" @@ -103,6 +106,95 @@ func SetPingJobs(app *pocketbase.PocketBase) { }(device) } }) + + // update ip addresses of opted-in devices from a periodic arp scan + trackIpInterval := settingsPrivateRecords[0].GetString("track_ip_interval") + if trackIpInterval != "" { + if _, err := CronPing.AddFunc(trackIpInterval, func() { + trackDeviceIPs(app) + }); err != nil { + logger.Error.Println("Failed to add ip tracking cronjob:", err) + } + } +} + +var trackIpRunning atomic.Bool + +// trackDeviceIPs scans the local subnets of devices with ip tracking enabled +// and updates their ip address if their mac address is found at a different one. +func trackDeviceIPs(app *pocketbase.PocketBase) { + // skip if the previous scan is still running + if !trackIpRunning.CompareAndSwap(false, true) { + return + } + defer trackIpRunning.Store(false) + + devices, err := app.FindRecordsByFilter("devices", "track_ip = true", "", 0, 0) + if err != nil { + logger.Error.Println(err) + return + } + + // group devices by subnet, skipping subnets not directly attached to the host + subnets := make(map[string][]*core.Record) + for _, device := range devices { + subnet, err := networking.DeviceSubnet(device.GetString("ip"), device.GetString("netmask")) + if err != nil { + logger.Error.Println("Ip tracking for", device.GetString("name")+":", err) + continue + } + if ones, bits := subnet.Mask.Size(); ones == bits || ones < 16 { + // a /32 has nothing to scan, anything larger than a /16 takes too long + continue + } + if !networking.IsLocalSubnet(subnet) { + continue + } + subnets[subnet.String()] = append(subnets[subnet.String()], device) + } + + for cidr, subnetDevices := range subnets { + scan, err := networking.NmapScan(cidr) + if err != nil { + logger.Error.Println("Ip tracking scan for", cidr+":", err) + continue + } + + // map mac addresses to ips + macToIp := make(map[string]string) + for _, host := range scan.Host { + var hostIp, hostMac string + for _, addr := range host.Address { + if addr.Addrtype == "ipv4" { + hostIp = addr.Addr + } else if addr.Addrtype == "mac" { + hostMac = addr.Addr + } + } + if parsedMac, err := net.ParseMAC(hostMac); hostIp != "" && err == nil { + macToIp[parsedMac.String()] = hostIp + } + } + + for _, device := range subnetDevices { + parsedMac, err := net.ParseMAC(device.GetString("mac")) + if err != nil { + continue + } + newIp, ok := macToIp[parsedMac.String()] + if !ok || newIp == device.GetString("ip") { + continue + } + logger.Info.Println("Ip tracking: updating", device.GetString("name"), "from", device.GetString("ip"), "to", newIp) + device.Set("ip", newIp) + // only write the changed ip field to avoid clobbering concurrent + // status updates from the ping and wake/shutdown cronjobs + device.IgnoreUnchangedFields(true) + if err := app.Save(device); err != nil { + logger.Error.Println("Failed to save record:", err) + } + } + } } func SetWakeShutdownJobs(app *pocketbase.PocketBase) { diff --git a/backend/migrations/1786060800_updated_devices.go b/backend/migrations/1786060800_updated_devices.go new file mode 100644 index 000000000..6ff9a8ee8 --- /dev/null +++ b/backend/migrations/1786060800_updated_devices.go @@ -0,0 +1,40 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") + if err != nil { + return err + } + + // add field + if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ + "hidden": false, + "id": "bool2618032555", + "name": "track_ip", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") + if err != nil { + return err + } + + // remove field + collection.Fields.RemoveById("bool2618032555") + + return app.Save(collection) + }) +} diff --git a/backend/migrations/1786060801_updated_settings_private.go b/backend/migrations/1786060801_updated_settings_private.go new file mode 100644 index 000000000..0c193b70f --- /dev/null +++ b/backend/migrations/1786060801_updated_settings_private.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("nmj3ko20gzkg8n3") + if err != nil { + return err + } + + // add field + if err := collection.Fields.AddMarshaledJSONAt(4, []byte(`{ + "autogeneratePattern": "", + "hidden": false, + "id": "text3846109882", + "max": 0, + "min": 0, + "name": "track_ip_interval", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("nmj3ko20gzkg8n3") + if err != nil { + return err + } + + // remove field + collection.Fields.RemoveById("text3846109882") + + return app.Save(collection) + }) +} diff --git a/backend/networking/scan.go b/backend/networking/scan.go new file mode 100644 index 000000000..2b928f708 --- /dev/null +++ b/backend/networking/scan.go @@ -0,0 +1,43 @@ +package networking + +import ( + "encoding/xml" + "os" + "os/exec" +) + +type Nmaprun struct { + Host []struct { + Address []struct { + Addr string `xml:"addr,attr" binding:"required"` + Addrtype string `xml:"addrtype,attr" binding:"required"` + Vendor string `xml:"vendor,attr"` + } `xml:"address"` + } `xml:"host"` +} + +func runNmap(scanRange string) (Nmaprun, error) { + nmapOutput := Nmaprun{} + + nmap, err := exec.LookPath("nmap") + if err != nil { + return nmapOutput, err + } + + timeout := os.Getenv("UPSNAP_SCAN_TIMEOUT") + if timeout == "" { + timeout = "500ms" + } + + cmd := exec.Command(nmap, "-sn", "-oX", "-", scanRange, "--host-timeout", timeout, "--privileged") + cmdOutput, err := cmd.Output() + if err != nil { + return nmapOutput, err + } + + if err := xml.Unmarshal(cmdOutput, &nmapOutput); err != nil { + return nmapOutput, err + } + + return nmapOutput, nil +} diff --git a/backend/networking/scan_linux.go b/backend/networking/scan_linux.go new file mode 100644 index 000000000..685f7ed29 --- /dev/null +++ b/backend/networking/scan_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package networking + +import ( + "fmt" + + "kernel.org/pub/linux/libs/security/libcap/cap" +) + +func NmapScan(scanRange string) (Nmaprun, error) { + orig := cap.GetProc() + defer orig.SetProc() // restore original caps on exit. + + c, err := orig.Dup() + if err != nil { + return Nmaprun{}, fmt.Errorf("Failed to dup existing capabilities: %v", err) + } + + if on, _ := c.GetFlag(cap.Permitted, cap.NET_RAW); !on { + return Nmaprun{}, fmt.Errorf("unable to get NET_RAW permissions") + } + + if err := c.SetFlag(cap.Effective, true, cap.NET_RAW); err != nil { + return Nmaprun{}, fmt.Errorf("unable to set NET_RAW capability effective") + } + + if err := c.SetFlag(cap.Inheritable, true, cap.NET_RAW); err != nil { + return Nmaprun{}, fmt.Errorf("unable to set NET_RAW capability inheritable") + } + + if err := c.SetProc(); err != nil { + return Nmaprun{}, fmt.Errorf("unable to raise NET_RAW capability") + } + + if err := cap.SetAmbient(true, cap.NET_RAW); err != nil { + return Nmaprun{}, fmt.Errorf("unable to set NET_RAW capability ambient") + } + + return runNmap(scanRange) +} diff --git a/backend/networking/scan_other.go b/backend/networking/scan_other.go new file mode 100644 index 000000000..d6f4e56d9 --- /dev/null +++ b/backend/networking/scan_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package networking + +func NmapScan(scanRange string) (Nmaprun, error) { + return runNmap(scanRange) +} diff --git a/backend/networking/trackip.go b/backend/networking/trackip.go new file mode 100644 index 000000000..9123c4291 --- /dev/null +++ b/backend/networking/trackip.go @@ -0,0 +1,42 @@ +package networking + +import ( + "errors" + "net" +) + +// DeviceSubnet returns the device's IPv4 subnet computed from its ip and netmask. +func DeviceSubnet(ipStr, maskStr string) (*net.IPNet, error) { + ip := net.ParseIP(ipStr) + if ip == nil || ip.To4() == nil { + return nil, errors.New("ip not a valid ipv4 address") + } + ip = ip.To4() + + mask := net.ParseIP(maskStr) + if mask == nil || mask.To4() == nil { + return nil, errors.New("subnet mask not a valid ipv4 address") + } + ipMask := net.IPMask(mask.To4()) + + return &net.IPNet{IP: ip.Mask(ipMask), Mask: ipMask}, nil +} + +// IsLocalSubnet reports whether one of the host's interface addresses is +// inside the given subnet, i.e. the subnet is directly attached and not routed. +func IsLocalSubnet(subnet *net.IPNet) bool { + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + if ipNet.IP.To4() != nil && subnet.Contains(ipNet.IP) { + return true + } + } + return false +} diff --git a/docker-compose.yml b/docker-compose.yml index 58bb10b70..6dfce6972 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: upsnap: cap_add: - - NET_RAW # NET_RAW is required for privileged ping and network device scan (nmap) + - NET_RAW # NET_RAW is required for privileged ping, network device scan and ip address tracking (nmap) cap_drop: - ALL container_name: upsnap diff --git a/frontend/src/lib/components/DeviceForm.svelte b/frontend/src/lib/components/DeviceForm.svelte index 59b811edb..6fad81399 100644 --- a/frontend/src/lib/components/DeviceForm.svelte +++ b/frontend/src/lib/components/DeviceForm.svelte @@ -274,6 +274,14 @@ * {m.device_general_required_field()} +
+ {m.device_general_track_ip()} + +

{m.device_general_track_ip_desc()}

+
diff --git a/frontend/src/lib/types/device.ts b/frontend/src/lib/types/device.ts index 68fd5cdd4..01948beff 100644 --- a/frontend/src/lib/types/device.ts +++ b/frontend/src/lib/types/device.ts @@ -5,6 +5,7 @@ export type Device = RecordModel & { ip: string; mac: string; netmask: string; + track_ip: boolean; description: string; status: 'pending' | 'online' | 'offline' | ''; ports: string[]; diff --git a/frontend/src/lib/types/settings.ts b/frontend/src/lib/types/settings.ts index 49c6129d5..8c956a434 100644 --- a/frontend/src/lib/types/settings.ts +++ b/frontend/src/lib/types/settings.ts @@ -11,4 +11,5 @@ export type SettingsPrivate = RecordModel & { interval: string; lazy_ping: boolean; scan_range: string; + track_ip_interval: string; }; diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 65a4684e6..19452a51d 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -72,6 +72,14 @@ throw new Error('ping_interval not valid'); } + if ( + settingsPrivClone.track_ip_interval !== '' && + !(await parseCron(settingsPrivClone.track_ip_interval)) + ) { + toast.error(m.settings_invalid_cron()); + throw new Error('track_ip_interval not valid'); + } + await $pocketbase .collection('settings_public') .update(settingsPubClone.id, settingsPubClone) @@ -163,6 +171,29 @@ second (0–59, optional) {m.settings_lazy_ping_enable()}
+

{m.settings_track_ip_title()}

+

+ {m.settings_track_ip_desc()} +

+
+ +
+ {#if settingsPrivClone.track_ip_interval} +

+ {#await parseCron(settingsPrivClone.track_ip_interval)} + + {:then valid} + {valid + ? '✅ ' + nextCronDate(settingsPrivClone.track_ip_interval) + : m.settings_invalid_cron()} + {/await} +

+ {/if}
diff --git a/frontend/translations/en-US.json b/frontend/translations/en-US.json index bb3171ce6..f0ddb1548 100644 --- a/frontend/translations/en-US.json +++ b/frontend/translations/en-US.json @@ -40,6 +40,8 @@ "device_general_name": "Name", "device_general_netmask": "Netmask", "device_general_required_field": "required field", + "device_general_track_ip": "Track IP address", + "device_general_track_ip_desc": "Updates this device's IP address automatically whenever the periodic network scan finds its MAC address at a different IP. Requires the 'IP address tracking' interval to be set in the settings and only works on subnets UpSnap is directly attached to.", "device_groups": "Groups", "device_groups_desc": "You can add devices to a group to have them sorted by group on the dashboard.", "device_groups_placeholder": "e.g. 'Basement' or 'Office'", @@ -140,6 +142,8 @@ "settings_ping_interval_desc1": "Sets the interval in which the devices are pinged. Leave blank to use default value of */3 * * * * *.", "settings_ping_interval_desc2": "Learn more about the correct syntax for cron on Wikipedia or refer to the package documentation.", "settings_ping_interval_title": "Ping interval", + "settings_track_ip_desc": "Cron interval at which UpSnap scans the local subnets of devices with 'Track IP address' enabled and updates their IP address if their MAC address is found at a different one. Every 15-60 minutes is plenty; short intervals broadcast-scan your network aggressively, which can be especially bad on wider subnets. Leave empty to disable. Requires nmap, same as the network scan.", + "settings_track_ip_title": "IP address tracking", "settings_upsnap_version": "UpSnap version", "settings_website_title_desc": "Sets the title of the website and in the browser tab.", "settings_website_title_title": "Website title", From 76316fd561e30cf68b1186329e106f8f83a3676f Mon Sep 17 00:00:00 2001 From: Mike Pastore Date: Fri, 7 Aug 2026 18:29:03 -0500 Subject: [PATCH 2/5] test: cover ip tracking subnet helpers and nmap xml parsing Co-Authored-By: Claude Fable 5 --- backend/networking/scan_test.go | 49 ++++++++++++ backend/networking/trackip_test.go | 117 +++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 backend/networking/scan_test.go create mode 100644 backend/networking/trackip_test.go diff --git a/backend/networking/scan_test.go b/backend/networking/scan_test.go new file mode 100644 index 000000000..01a5860b2 --- /dev/null +++ b/backend/networking/scan_test.go @@ -0,0 +1,49 @@ +package networking + +import ( + "encoding/xml" + "testing" +) + +func TestNmaprunUnmarshal(t *testing.T) { + // Trimmed from real `nmap -sn -oX -` output: one host with a mac (scanned + // with raw socket privileges), one without (the scanning host itself). + sample := ` + + +
+
+ + + +
+ + + +` + + nmapOutput := Nmaprun{} + if err := xml.Unmarshal([]byte(sample), &nmapOutput); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + + if len(nmapOutput.Host) != 2 { + t.Fatalf("Host count mismatch: expected 2, got %d", len(nmapOutput.Host)) + } + + first := nmapOutput.Host[0].Address + if len(first) != 2 { + t.Fatalf("Address count mismatch: expected 2, got %d", len(first)) + } + if first[0].Addrtype != "ipv4" || first[0].Addr != "10.1.10.1" { + t.Errorf("Unexpected ipv4 address: %+v", first[0]) + } + if first[1].Addrtype != "mac" || first[1].Addr != "AA:BB:CC:DD:EE:FF" || first[1].Vendor != "Ubiquiti Networks" { + t.Errorf("Unexpected mac address: %+v", first[1]) + } + + second := nmapOutput.Host[1].Address + if len(second) != 1 || second[0].Addrtype != "ipv4" { + t.Errorf("Unexpected addresses for mac-less host: %+v", second) + } +} diff --git a/backend/networking/trackip_test.go b/backend/networking/trackip_test.go new file mode 100644 index 000000000..07d542279 --- /dev/null +++ b/backend/networking/trackip_test.go @@ -0,0 +1,117 @@ +package networking + +import ( + "testing" +) + +func TestDeviceSubnet(t *testing.T) { + testCases := []struct { + name string + ip string + netmask string + wantCidr string + wantError bool + }{ + // Valid case: address is masked down to the network + { + name: "Valid Case", + ip: "192.168.1.100", + netmask: "255.255.255.0", + wantCidr: "192.168.1.0/24", + wantError: false, + }, + // Valid case: wider mask + { + name: "Valid /16", + ip: "10.1.10.142", + netmask: "255.255.0.0", + wantCidr: "10.1.0.0/16", + wantError: false, + }, + // Valid case: host mask + { + name: "Valid /32", + ip: "192.168.1.5", + netmask: "255.255.255.255", + wantCidr: "192.168.1.5/32", + wantError: false, + }, + // Invalid IP address + { + name: "Invalid IP", + ip: "256.1.1.1", // invalid + netmask: "255.255.255.0", + wantCidr: "", + wantError: true, + }, + // Invalid netmask + { + name: "Invalid Netmask", + ip: "192.168.1.100", + netmask: "300.255.255.0", // invalid + wantCidr: "", + wantError: true, + }, + // IPv6 address is not a valid device ip + { + name: "IPv6 IP", + ip: "fe80::1", + netmask: "255.255.255.0", + wantCidr: "", + wantError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + subnet, err := DeviceSubnet(tc.ip, tc.netmask) + if tc.wantError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Got unexpected error: %v", err) + } else if subnet.String() != tc.wantCidr { + t.Errorf("Subnet mismatch: expected %s, got %s", tc.wantCidr, subnet.String()) + } + } + }) + } +} + +func TestIsLocalSubnet(t *testing.T) { + testCases := []struct { + name string + ip string + netmask string + want bool + }{ + // The loopback address is configured on every host + { + name: "Loopback", + ip: "127.0.0.1", + netmask: "255.0.0.0", + want: true, + }, + // TEST-NET-1 (RFC 5737) is reserved for documentation and never assigned + { + name: "Reserved Test Net", + ip: "192.0.2.1", + netmask: "255.255.255.0", + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + subnet, err := DeviceSubnet(tc.ip, tc.netmask) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + if got := IsLocalSubnet(subnet); got != tc.want { + t.Errorf("IsLocalSubnet(%s): expected %v, got %v", subnet.String(), tc.want, got) + } + }) + } +} From f9945b7e72d4dcd0472c4876de56a894d6d833e3 Mon Sep 17 00:00:00 2001 From: Mike Pastore Date: Sun, 9 Aug 2026 23:23:40 -0500 Subject: [PATCH 3/5] refactor: extract ip tracking into its own package and expand test coverage Move the arp scan sweep out of cronjobs into a new iptracking package with two entry points: TrackAllSubnets (the cron sweep) and TrackOneSubnet (a single validated scan). Device lookup is now by mac address across all tracked devices, with updates constrained to the device's own subnet. The scannability check becomes networking.ValidateScannableSubnet, which reports why a subnet can't be scanned and accepts any subnet overlapping a directly attached network, so sub-blocks of an attached prefix are now scannable. The mac-to-ip mapping moves to networking as Nmaprun.MacToIP. Cover the subnet guard, mac normalization, skip paths, and sweep orchestration with tests against a stubbed scanner and a real migrated schema. Co-Authored-By: Claude Fable 5 --- backend/cronjobs/cronjobs.go | 85 +------ backend/iptracking/iptracking.go | 102 +++++++++ backend/iptracking/iptracking_test.go | 314 ++++++++++++++++++++++++++ backend/networking/scan.go | 21 ++ backend/networking/scan_test.go | 26 ++- backend/networking/trackip.go | 35 ++- backend/networking/trackip_test.go | 63 ++++-- 7 files changed, 532 insertions(+), 114 deletions(-) create mode 100644 backend/iptracking/iptracking.go create mode 100644 backend/iptracking/iptracking_test.go diff --git a/backend/cronjobs/cronjobs.go b/backend/cronjobs/cronjobs.go index 4c4524402..32c47d352 100644 --- a/backend/cronjobs/cronjobs.go +++ b/backend/cronjobs/cronjobs.go @@ -1,12 +1,10 @@ package cronjobs import ( - "net" - "sync/atomic" - "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" "github.com/robfig/cron/v3" + "github.com/seriousm4x/upsnap/iptracking" "github.com/seriousm4x/upsnap/logger" "github.com/seriousm4x/upsnap/networking" ) @@ -111,92 +109,13 @@ func SetPingJobs(app *pocketbase.PocketBase) { trackIpInterval := settingsPrivateRecords[0].GetString("track_ip_interval") if trackIpInterval != "" { if _, err := CronPing.AddFunc(trackIpInterval, func() { - trackDeviceIPs(app) + iptracking.TrackAllSubnets(app) }); err != nil { logger.Error.Println("Failed to add ip tracking cronjob:", err) } } } -var trackIpRunning atomic.Bool - -// trackDeviceIPs scans the local subnets of devices with ip tracking enabled -// and updates their ip address if their mac address is found at a different one. -func trackDeviceIPs(app *pocketbase.PocketBase) { - // skip if the previous scan is still running - if !trackIpRunning.CompareAndSwap(false, true) { - return - } - defer trackIpRunning.Store(false) - - devices, err := app.FindRecordsByFilter("devices", "track_ip = true", "", 0, 0) - if err != nil { - logger.Error.Println(err) - return - } - - // group devices by subnet, skipping subnets not directly attached to the host - subnets := make(map[string][]*core.Record) - for _, device := range devices { - subnet, err := networking.DeviceSubnet(device.GetString("ip"), device.GetString("netmask")) - if err != nil { - logger.Error.Println("Ip tracking for", device.GetString("name")+":", err) - continue - } - if ones, bits := subnet.Mask.Size(); ones == bits || ones < 16 { - // a /32 has nothing to scan, anything larger than a /16 takes too long - continue - } - if !networking.IsLocalSubnet(subnet) { - continue - } - subnets[subnet.String()] = append(subnets[subnet.String()], device) - } - - for cidr, subnetDevices := range subnets { - scan, err := networking.NmapScan(cidr) - if err != nil { - logger.Error.Println("Ip tracking scan for", cidr+":", err) - continue - } - - // map mac addresses to ips - macToIp := make(map[string]string) - for _, host := range scan.Host { - var hostIp, hostMac string - for _, addr := range host.Address { - if addr.Addrtype == "ipv4" { - hostIp = addr.Addr - } else if addr.Addrtype == "mac" { - hostMac = addr.Addr - } - } - if parsedMac, err := net.ParseMAC(hostMac); hostIp != "" && err == nil { - macToIp[parsedMac.String()] = hostIp - } - } - - for _, device := range subnetDevices { - parsedMac, err := net.ParseMAC(device.GetString("mac")) - if err != nil { - continue - } - newIp, ok := macToIp[parsedMac.String()] - if !ok || newIp == device.GetString("ip") { - continue - } - logger.Info.Println("Ip tracking: updating", device.GetString("name"), "from", device.GetString("ip"), "to", newIp) - device.Set("ip", newIp) - // only write the changed ip field to avoid clobbering concurrent - // status updates from the ping and wake/shutdown cronjobs - device.IgnoreUnchangedFields(true) - if err := app.Save(device); err != nil { - logger.Error.Println("Failed to save record:", err) - } - } - } -} - func SetWakeShutdownJobs(app *pocketbase.PocketBase) { // remove existing jobs for _, job := range CronWakeShutdown.Entries() { diff --git a/backend/iptracking/iptracking.go b/backend/iptracking/iptracking.go new file mode 100644 index 000000000..dd6ed12b6 --- /dev/null +++ b/backend/iptracking/iptracking.go @@ -0,0 +1,102 @@ +// Package iptracking updates the stored ip addresses of opted-in devices by +// scanning their local subnets for their mac addresses. +package iptracking + +import ( + "net" + "sync/atomic" + + "github.com/pocketbase/pocketbase/core" + "github.com/seriousm4x/upsnap/logger" + "github.com/seriousm4x/upsnap/networking" +) + +var sweepRunning atomic.Bool + +// nmapScan is a seam for tests to stub out the privileged nmap invocation +var nmapScan = networking.NmapScan + +// TrackAllSubnets scans the local subnets of devices with ip tracking enabled +// and updates their ip address if their mac address is found at a different +// one. Non-scannable subnets are silently skipped, but their devices can +// still be updated by another subnet's scan that finds their mac address, +// as long as the new ip stays within the device's own subnet. +func TrackAllSubnets(app core.App) { + // skip if the previous sweep is still running + if !sweepRunning.CompareAndSwap(false, true) { + return + } + defer sweepRunning.Store(false) + + devices, err := app.FindRecordsByFilter("devices", "track_ip = true", "", 0, 0) + if err != nil { + logger.Error.Println(err) + return + } + + // collect the unique scannable subnets of the tracked devices + subnets := make(map[string]*net.IPNet) + for _, device := range devices { + subnet, err := networking.DeviceSubnet(device.GetString("ip"), device.GetString("netmask")) + if err != nil { + logger.Error.Println("Ip tracking for", device.GetString("name")+":", err) + continue + } + if networking.ValidateScannableSubnet(subnet) != nil { + continue + } + subnets[subnet.String()] = subnet + } + + for cidr, subnet := range subnets { + if err := TrackOneSubnet(app, subnet); err != nil { + logger.Error.Println("Ip tracking scan for", cidr+":", err) + } + } +} + +// TrackOneSubnet runs an nmap scan of the given subnet and updates the ip +// address of any ip-tracked device whose mac address is found at a new +// address within its own subnet. Returns an error for a non-scannable subnet. +func TrackOneSubnet(app core.App, subnet *net.IPNet) error { + if err := networking.ValidateScannableSubnet(subnet); err != nil { + return err + } + + scan, err := nmapScan(subnet.String()) + if err != nil { + return err + } + macToIp := scan.MacToIP() + + devices, err := app.FindRecordsByFilter("devices", "track_ip = true", "", 0, 0) + if err != nil { + return err + } + + for _, device := range devices { + parsedMac, err := net.ParseMAC(device.GetString("mac")) + if err != nil { + continue + } + newIp, ok := macToIp[parsedMac.String()] + if !ok || newIp == device.GetString("ip") { + continue + } + // only move a device within its own subnet, so that a scan can't + // relocate devices tracked on other subnets that see the same mac + deviceSubnet, err := networking.DeviceSubnet(device.GetString("ip"), device.GetString("netmask")) + if err != nil || !deviceSubnet.Contains(net.ParseIP(newIp)) { + continue + } + logger.Info.Println("Ip tracking: updating", device.GetString("name"), "from", device.GetString("ip"), "to", newIp) + device.Set("ip", newIp) + // only write the changed ip field to avoid clobbering concurrent + // status updates from the ping and wake/shutdown cronjobs + device.IgnoreUnchangedFields(true) + if err := app.Save(device); err != nil { + logger.Error.Println("Failed to save record:", err) + } + } + return nil +} diff --git a/backend/iptracking/iptracking_test.go b/backend/iptracking/iptracking_test.go new file mode 100644 index 000000000..5c7536d16 --- /dev/null +++ b/backend/iptracking/iptracking_test.go @@ -0,0 +1,314 @@ +package iptracking + +import ( + "encoding/xml" + "fmt" + "net" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" + "github.com/seriousm4x/upsnap/networking" + + // register the app migrations so test apps get the real schema + _ "github.com/seriousm4x/upsnap/migrations" +) + +// newTestApp returns a throwaway app with all migrations applied. +func newTestApp(t *testing.T) *tests.TestApp { + t.Helper() + app, err := tests.NewTestApp(t.TempDir()) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + t.Cleanup(app.Cleanup) + return app +} + +func buildDevice(t *testing.T, app core.App, name, ip, netmask, mac string, trackIp bool) *core.Record { + t.Helper() + collection, err := app.FindCollectionByNameOrId("devices") + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + device := core.NewRecord(collection) + device.Set("name", name) + device.Set("ip", ip) + device.Set("netmask", netmask) + device.Set("mac", mac) + device.Set("track_ip", trackIp) + return device +} + +func newDevice(t *testing.T, app core.App, name, ip, netmask, mac string, trackIp bool) *core.Record { + t.Helper() + device := buildDevice(t, app, name, ip, netmask, mac, trackIp) + if err := app.Save(device); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + return device +} + +// newLegacyDevice saves a device bypassing field validation, like rows +// created before the ip and mac format rules existed. +func newLegacyDevice(t *testing.T, app core.App, name, ip, netmask, mac string, trackIp bool) *core.Record { + t.Helper() + device := buildDevice(t, app, name, ip, netmask, mac, trackIp) + if err := app.SaveNoValidate(device); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + return device +} + +// stubScan replaces the nmap seam with a canned scan reporting the given +// mac addresses at the given ips, and returns the list of scanned ranges. +func stubScan(t *testing.T, macToHostIp map[string]string) *[]string { + t.Helper() + var sample strings.Builder + sample.WriteString("") + for mac, ip := range macToHostIp { + fmt.Fprintf(&sample, `
`, ip, mac) + } + sample.WriteString("") + scan := networking.Nmaprun{} + if err := xml.Unmarshal([]byte(sample.String()), &scan); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + + var scanned []string + orig := nmapScan + nmapScan = func(scanRange string) (networking.Nmaprun, error) { + scanned = append(scanned, scanRange) + return scan, nil + } + t.Cleanup(func() { nmapScan = orig }) + return &scanned +} + +func mustSubnet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + _, subnet, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + return subnet +} + +// The loopback /16 passes the scannable checks on any host, making it the +// only subnet a test can scan without depending on the environment. +const ( + testSubnet = "127.0.0.0/16" + testNetmask = "255.255.0.0" +) + +func TestTrackOneSubnetSkipsUntrackedDevices(t *testing.T) { + app := newTestApp(t) + device := newDevice(t, app, "untracked", "127.0.0.50", testNetmask, "AA:BB:CC:DD:EE:01", false) + stubScan(t, map[string]string{"AA:BB:CC:DD:EE:01": "127.0.0.99"}) + + if err := TrackOneSubnet(app, mustSubnet(t, testSubnet)); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + + fresh, err := app.FindRecordById("devices", device.Id) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + if ip := fresh.GetString("ip"); ip != "127.0.0.50" { + t.Errorf("Ip of device without track_ip changed to %s", ip) + } +} + +func TestTrackOneSubnetSkipPaths(t *testing.T) { + app := newTestApp(t) + saves := 0 + app.OnRecordUpdate("devices").BindFunc(func(e *core.RecordEvent) error { + saves++ + return e.Next() + }) + + testCases := []struct { + name string + ip string + mac string + legacy bool + }{ + // A device mac that can't be parsed never matches + { + name: "Invalid Mac", + ip: "127.0.0.50", + mac: "not-a-mac", + legacy: true, + }, + // A device mac missing from the scan results is left alone + { + name: "Mac Not In Scan", + ip: "127.0.0.50", + mac: "AA:BB:CC:DD:EE:02", + }, + // A device ip without a computable subnet is never moved + { + name: "Invalid Device Ip", + ip: "999.999.999.999", + mac: "AA:BB:CC:DD:EE:01", + legacy: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + create := newDevice + if tc.legacy { + create = newLegacyDevice + } + device := create(t, app, tc.name, tc.ip, testNetmask, tc.mac, true) + stubScan(t, map[string]string{"AA:BB:CC:DD:EE:01": "127.0.0.99"}) + + if err := TrackOneSubnet(app, mustSubnet(t, testSubnet)); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + + fresh, err := app.FindRecordById("devices", device.Id) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + if ip := fresh.GetString("ip"); ip != tc.ip { + t.Errorf("Ip mismatch: expected %s, got %s", tc.ip, ip) + } + if saves != 0 { + t.Errorf("Expected no record updates, got %d", saves) + } + }) + } +} + +func TestTrackOneSubnetUnscannable(t *testing.T) { + app := newTestApp(t) + // TEST-NET-1 (RFC 5737) is never assigned to an interface + scanned := stubScan(t, nil) + + if err := TrackOneSubnet(app, mustSubnet(t, "192.0.2.0/24")); err == nil { + t.Error("Expected error but got none") + } + if len(*scanned) != 0 { + t.Errorf("Expected no scans of an unscannable subnet, got %v", *scanned) + } +} + +func TestTrackOneSubnetSameSubnetGuard(t *testing.T) { + app := newTestApp(t) + + testCases := []struct { + name string + ip string + netmask string + mac string + scanMac string + foundIp string + wantIp string + }{ + // A move within the device's own subnet is applied, with the + // stored dashed lower case mac matching the scanned + // colon-separated upper case one + { + name: "Within Own Subnet", + ip: "127.0.0.50", + netmask: testNetmask, + mac: "aa-bb-cc-dd-01-01", + scanMac: "AA:BB:CC:DD:01:01", + foundIp: "127.0.0.99", + wantIp: "127.0.0.99", + }, + // The device's own subnet bounds the move, not the scanned one + { + name: "Narrower Subnet Within Scan", + ip: "127.0.5.5", + netmask: "255.255.255.0", + mac: "AA:BB:CC:DD:01:02", + foundIp: "127.0.5.9", + wantIp: "127.0.5.9", + }, + // A found ip outside the device's narrower subnet is not applied, + // even though it is inside the scanned subnet + { + name: "Outside Own Subnet", + ip: "127.0.5.5", + netmask: "255.255.255.0", + mac: "AA:BB:CC:DD:01:03", + foundIp: "127.0.6.9", + wantIp: "127.0.5.5", + }, + // A device from a disjoint subnet is never moved into the scanned one + { + name: "Disjoint Subnet", + ip: "10.9.9.9", + netmask: "255.255.255.0", + mac: "AA:BB:CC:DD:01:04", + foundIp: "127.0.0.99", + wantIp: "10.9.9.9", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + device := newDevice(t, app, tc.name, tc.ip, tc.netmask, tc.mac, true) + scanMac := tc.scanMac + if scanMac == "" { + scanMac = tc.mac + } + stubScan(t, map[string]string{scanMac: tc.foundIp}) + + if err := TrackOneSubnet(app, mustSubnet(t, testSubnet)); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + + fresh, err := app.FindRecordById("devices", device.Id) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + if ip := fresh.GetString("ip"); ip != tc.wantIp { + t.Errorf("Ip mismatch: expected %s, got %s", tc.wantIp, ip) + } + }) + } +} + +// TrackAllSubnets scans each unique scannable subnet once, silently skips the +// rest, and updates any tracked device found at a new ip within its own +// subnet — even one whose own subnet couldn't be scanned. +func TestTrackAllSubnets(t *testing.T) { + app := newTestApp(t) + first := newDevice(t, app, "first", "127.0.0.10", testNetmask, "AA:BB:CC:DD:02:01", true) + second := newDevice(t, app, "second", "127.0.0.20", testNetmask, "AA:BB:CC:DD:02:02", true) + // TEST-NET-1 (RFC 5737) is never assigned to an interface + unscannable := newDevice(t, app, "unscannable", "192.0.2.5", "255.255.255.0", "AA:BB:CC:DD:02:03", true) + // a /8 subnet is too large to scan but contains the found ip + wide := newDevice(t, app, "wide", "127.0.0.60", "255.0.0.0", "AA:BB:CC:DD:02:04", true) + scanned := stubScan(t, map[string]string{ + "AA:BB:CC:DD:02:01": "127.0.0.11", + "AA:BB:CC:DD:02:02": "127.0.0.21", + "AA:BB:CC:DD:02:04": "127.0.0.61", + }) + + TrackAllSubnets(app) + + if len(*scanned) != 1 || (*scanned)[0] != testSubnet { + t.Errorf("Expected a single scan of %s, got %v", testSubnet, *scanned) + } + wantIps := map[string]string{ + first.Id: "127.0.0.11", + second.Id: "127.0.0.21", + unscannable.Id: "192.0.2.5", + wide.Id: "127.0.0.61", + } + for id, wantIp := range wantIps { + fresh, err := app.FindRecordById("devices", id) + if err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + if ip := fresh.GetString("ip"); ip != wantIp { + t.Errorf("Ip mismatch for %s: expected %s, got %s", fresh.GetString("name"), wantIp, ip) + } + } +} diff --git a/backend/networking/scan.go b/backend/networking/scan.go index 2b928f708..08511517c 100644 --- a/backend/networking/scan.go +++ b/backend/networking/scan.go @@ -2,6 +2,7 @@ package networking import ( "encoding/xml" + "net" "os" "os/exec" ) @@ -16,6 +17,26 @@ type Nmaprun struct { } `xml:"host"` } +// MacToIP maps each scanned host's normalized mac address to its ipv4 +// address, skipping hosts that lack either. +func (n Nmaprun) MacToIP() map[string]string { + macToIp := make(map[string]string) + for _, host := range n.Host { + var hostIp, hostMac string + for _, addr := range host.Address { + if addr.Addrtype == "ipv4" { + hostIp = addr.Addr + } else if addr.Addrtype == "mac" { + hostMac = addr.Addr + } + } + if parsedMac, err := net.ParseMAC(hostMac); hostIp != "" && err == nil { + macToIp[parsedMac.String()] = hostIp + } + } + return macToIp +} + func runNmap(scanRange string) (Nmaprun, error) { nmapOutput := Nmaprun{} diff --git a/backend/networking/scan_test.go b/backend/networking/scan_test.go index 01a5860b2..b48721f1b 100644 --- a/backend/networking/scan_test.go +++ b/backend/networking/scan_test.go @@ -5,10 +5,9 @@ import ( "testing" ) -func TestNmaprunUnmarshal(t *testing.T) { - // Trimmed from real `nmap -sn -oX -` output: one host with a mac (scanned - // with raw socket privileges), one without (the scanning host itself). - sample := ` +// Trimmed from real `nmap -sn -oX -` output: one host with a mac (scanned +// with raw socket privileges), one without (the scanning host itself). +const nmapSample = `
@@ -22,8 +21,9 @@ func TestNmaprunUnmarshal(t *testing.T) { ` +func TestNmaprunUnmarshal(t *testing.T) { nmapOutput := Nmaprun{} - if err := xml.Unmarshal([]byte(sample), &nmapOutput); err != nil { + if err := xml.Unmarshal([]byte(nmapSample), &nmapOutput); err != nil { t.Fatalf("Got unexpected error: %v", err) } @@ -47,3 +47,19 @@ func TestNmaprunUnmarshal(t *testing.T) { t.Errorf("Unexpected addresses for mac-less host: %+v", second) } } + +func TestNmaprunMacToIP(t *testing.T) { + nmapOutput := Nmaprun{} + if err := xml.Unmarshal([]byte(nmapSample), &nmapOutput); err != nil { + t.Fatalf("Got unexpected error: %v", err) + } + + macToIp := nmapOutput.MacToIP() + if len(macToIp) != 1 { + t.Fatalf("Entry count mismatch: expected 1, got %d", len(macToIp)) + } + // the mac address is normalized to lower case, the mac-less host is skipped + if ip := macToIp["aa:bb:cc:dd:ee:ff"]; ip != "10.1.10.1" { + t.Errorf("Unexpected ip for aa:bb:cc:dd:ee:ff: %q", ip) + } +} diff --git a/backend/networking/trackip.go b/backend/networking/trackip.go index 9123c4291..7837f57ea 100644 --- a/backend/networking/trackip.go +++ b/backend/networking/trackip.go @@ -22,21 +22,38 @@ func DeviceSubnet(ipStr, maskStr string) (*net.IPNet, error) { return &net.IPNet{IP: ip.Mask(ipMask), Mask: ipMask}, nil } -// IsLocalSubnet reports whether one of the host's interface addresses is -// inside the given subnet, i.e. the subnet is directly attached and not routed. -func IsLocalSubnet(subnet *net.IPNet) bool { +// ValidateScannableSubnet returns nil if the subnet is suitable for an nmap +// scan, or an error explaining why it isn't. +func ValidateScannableSubnet(subnet *net.IPNet) error { + ones, bits := subnet.Mask.Size() + if ones == 0 && bits == 0 { + // Size() returns 0,0 for a non-contiguous mask, which has no cidr + // notation to hand to nmap + return errors.New("netmask is not contiguous") + } + if ones == bits { + return errors.New("a single address has nothing to scan") + } + if ones < 16 { + return errors.New("subnets larger than a /16 take too long to scan") + } + + // arp only answers on-link, so the subnet must overlap one of the + // host's attached ipv4 networks for a scan to find mac addresses addrs, err := net.InterfaceAddrs() if err != nil { - return false + return err } for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { + attached, ok := addr.(*net.IPNet) + if !ok || attached.IP.To4() == nil { continue } - if ipNet.IP.To4() != nil && subnet.Contains(ipNet.IP) { - return true + // subnets are either disjoint or nested, so checking containment + // in both directions covers overlap + if attached.Contains(subnet.IP) || subnet.Contains(attached.IP) { + return nil } } - return false + return errors.New("subnet does not overlap a directly attached network") } diff --git a/backend/networking/trackip_test.go b/backend/networking/trackip_test.go index 07d542279..3c9ce9256 100644 --- a/backend/networking/trackip_test.go +++ b/backend/networking/trackip_test.go @@ -80,26 +80,55 @@ func TestDeviceSubnet(t *testing.T) { } } -func TestIsLocalSubnet(t *testing.T) { +func TestValidateScannableSubnet(t *testing.T) { testCases := []struct { - name string - ip string - netmask string - want bool + name string + ip string + netmask string + wantError bool }{ - // The loopback address is configured on every host + // A /32 has nothing to scan + { + name: "Host Mask", + ip: "192.168.1.5", + netmask: "255.255.255.255", + wantError: true, + }, + // A non-contiguous mask has no cidr notation to scan { - name: "Loopback", - ip: "127.0.0.1", - netmask: "255.0.0.0", - want: true, + name: "Non-contiguous Mask", + ip: "10.0.1.5", + netmask: "255.0.255.0", + wantError: true, }, - // TEST-NET-1 (RFC 5737) is reserved for documentation and never assigned + // Anything larger than a /16 takes too long to scan { - name: "Reserved Test Net", - ip: "192.0.2.1", - netmask: "255.255.255.0", - want: false, + name: "Too Large", + ip: "10.0.0.1", + netmask: "255.0.0.0", + wantError: true, + }, + // The loopback address as a /16 is within limits and always attached + { + name: "Local /16", + ip: "127.0.0.1", + netmask: "255.255.0.0", + wantError: false, + }, + // A sub-block of the attached loopback /8 is on-link even though + // it doesn't contain the host's own 127.0.0.1 + { + name: "Nested In Attached", + ip: "127.0.1.5", + netmask: "255.255.255.0", + wantError: false, + }, + // TEST-NET-1 (RFC 5737) is never assigned to an interface + { + name: "No Overlap", + ip: "192.0.2.1", + netmask: "255.255.255.0", + wantError: true, }, } @@ -109,8 +138,8 @@ func TestIsLocalSubnet(t *testing.T) { if err != nil { t.Fatalf("Got unexpected error: %v", err) } - if got := IsLocalSubnet(subnet); got != tc.want { - t.Errorf("IsLocalSubnet(%s): expected %v, got %v", subnet.String(), tc.want, got) + if err := ValidateScannableSubnet(subnet); (err != nil) != tc.wantError { + t.Errorf("ValidateScannableSubnet(%s): expected error=%v, got %v", subnet.String(), tc.wantError, err) } }) } From 89a929cf51eae72a095fef8896b4e87100636051 Mon Sep 17 00:00:00 2001 From: Mike Pastore Date: Mon, 10 Aug 2026 11:22:11 -0500 Subject: [PATCH 4/5] feat: pause tracking sweeps while nobody is connected With lazy_ping turned on, the periodic tracking sweep now skips its tick while no realtime clients are connected, just like the ping cron, and owes a catch-up sweep to the next client that connects: the realtime connect hook runs one sweep only when a tick was actually skipped (or none has run since boot), so page reloads during active use trigger nothing. This extends lazy_ping's meaning from "pause pings when idle" to "pause periodic network activity when idle"; the setting's description is updated accordingly. Co-Authored-By: Claude Fable 5 --- backend/cronjobs/cronjobs.go | 7 ++++-- backend/iptracking/iptracking.go | 30 ++++++++++++++++++++++++ backend/iptracking/iptracking_test.go | 33 +++++++++++++++++++++++++++ backend/pb/pb.go | 13 +++++++++++ frontend/translations/en-US.json | 2 +- 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/backend/cronjobs/cronjobs.go b/backend/cronjobs/cronjobs.go index 32c47d352..60e27b189 100644 --- a/backend/cronjobs/cronjobs.go +++ b/backend/cronjobs/cronjobs.go @@ -20,7 +20,7 @@ var ( ))) ) -func SetPingJobs(app *pocketbase.PocketBase) { +func SetPingJobs(app core.App) { // remove existing jobs for _, job := range CronPing.Entries() { CronPing.Remove(job.ID) @@ -109,7 +109,10 @@ func SetPingJobs(app *pocketbase.PocketBase) { trackIpInterval := settingsPrivateRecords[0].GetString("track_ip_interval") if trackIpInterval != "" { if _, err := CronPing.AddFunc(trackIpInterval, func() { - iptracking.TrackAllSubnets(app) + // pause scans if no realtime clients connected and lazy_ping is + // turned on; a catch-up sweep runs when the next client connects + realtimeClients := len(app.SubscriptionsBroker().Clients()) + iptracking.PeriodicSweep(app, realtimeClients == 0 && settingsPrivateRecords[0].GetBool("lazy_ping")) }); err != nil { logger.Error.Println("Failed to add ip tracking cronjob:", err) } diff --git a/backend/iptracking/iptracking.go b/backend/iptracking/iptracking.go index dd6ed12b6..ff5e9f3bf 100644 --- a/backend/iptracking/iptracking.go +++ b/backend/iptracking/iptracking.go @@ -13,6 +13,15 @@ import ( var sweepRunning atomic.Bool +// sweepOwed marks that a periodic sweep was skipped while no realtime +// clients were connected, so the next client to connect gets a catch-up +// sweep. Starts true so the first client after boot gets fresh ips. +var sweepOwed atomic.Bool + +func init() { + sweepOwed.Store(true) +} + // nmapScan is a seam for tests to stub out the privileged nmap invocation var nmapScan = networking.NmapScan @@ -55,6 +64,27 @@ func TrackAllSubnets(app core.App) { } } +// PeriodicSweep runs the scheduled sweep, or skips it when paused — no +// realtime clients connected while lazy_ping is on — owing a catch-up +// sweep to the next client that connects. +func PeriodicSweep(app core.App, pause bool) { + if pause { + sweepOwed.Store(true) + return + } + TrackAllSubnets(app) +} + +// CatchUpSweep runs a sweep if one is owed, i.e. the periodic sweep was +// skipped while nobody was connected or hasn't run since boot. Clients +// connecting while sweeps run on schedule trigger nothing. +func CatchUpSweep(app core.App) { + if !sweepOwed.CompareAndSwap(true, false) { + return + } + TrackAllSubnets(app) +} + // TrackOneSubnet runs an nmap scan of the given subnet and updates the ip // address of any ip-tracked device whose mac address is found at a new // address within its own subnet. Returns an error for a non-scannable subnet. diff --git a/backend/iptracking/iptracking_test.go b/backend/iptracking/iptracking_test.go index 5c7536d16..f3221b89f 100644 --- a/backend/iptracking/iptracking_test.go +++ b/backend/iptracking/iptracking_test.go @@ -274,6 +274,39 @@ func TestTrackOneSubnetSameSubnetGuard(t *testing.T) { } } +// A paused periodic sweep scans nothing and owes a catch-up sweep, which +// runs at most once until the next pause; an unpaused sweep scans directly. +func TestPeriodicSweepAndCatchUp(t *testing.T) { + app := newTestApp(t) + newDevice(t, app, "tracked", "127.0.0.50", testNetmask, "AA:BB:CC:DD:09:01", true) + scanned := stubScan(t, map[string]string{"AA:BB:CC:DD:09:01": "127.0.0.99"}) + + PeriodicSweep(app, true) + if len(*scanned) != 0 { + t.Fatalf("Expected no scans while paused, got %v", *scanned) + } + + CatchUpSweep(app) + if len(*scanned) != 1 { + t.Fatalf("Expected one scan after a paused sweep, got %v", *scanned) + } + + CatchUpSweep(app) + if len(*scanned) != 1 { + t.Errorf("Expected no scan when none is owed, got %v", *scanned) + } + + PeriodicSweep(app, false) + if len(*scanned) != 2 { + t.Errorf("Expected an unpaused sweep to scan, got %v", *scanned) + } + + CatchUpSweep(app) + if len(*scanned) != 2 { + t.Errorf("Expected an unpaused sweep to owe nothing, got %v", *scanned) + } +} + // TrackAllSubnets scans each unique scannable subnet once, silently skips the // rest, and updates any tracked device found at a new ip within its own // subnet — even one whose own subnet couldn't be scanned. diff --git a/backend/pb/pb.go b/backend/pb/pb.go index 810d3302b..2834adb11 100644 --- a/backend/pb/pb.go +++ b/backend/pb/pb.go @@ -14,6 +14,7 @@ import ( "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/robfig/cron/v3" "github.com/seriousm4x/upsnap/cronjobs" + "github.com/seriousm4x/upsnap/iptracking" "github.com/seriousm4x/upsnap/logger" _ "github.com/seriousm4x/upsnap/migrations" ) @@ -198,6 +199,18 @@ func StartPocketBase(distDirFS fs.FS) { return e.Next() }) + app.OnRealtimeConnectRequest().BindFunc(func(e *core.RealtimeConnectRequestEvent) error { + // a client just became active: catch up on any tracking sweep that + // was skipped while nobody was connected + settings, err := e.App.FindFirstRecordByFilter("settings_private", "") + if err != nil { + logger.Error.Println(err) + } else if settings.GetBool("lazy_ping") && settings.GetString("track_ip_interval") != "" { + go iptracking.CatchUpSweep(e.App) + } + return e.Next() + }) + app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { cronjobs.StopAll() return e.Next() diff --git a/frontend/translations/en-US.json b/frontend/translations/en-US.json index f0ddb1548..811da44e0 100644 --- a/frontend/translations/en-US.json +++ b/frontend/translations/en-US.json @@ -135,7 +135,7 @@ "settings_icon_desc": "Set a custom favicon. Supported file types are:", "settings_icon_title": "Icon", "settings_invalid_cron": "❌ Invalid cron syntax", - "settings_lazy_ping_desc": "When lazy ping is turned on, UpSnap will only ping devices if there is an active user visiting the website. If it's turned off, UpSnap will always ping devices.", + "settings_lazy_ping_desc": "When lazy ping is turned on, UpSnap will only ping devices and run IP tracking scans while a user has the website open. If it's turned off, UpSnap always pings and scans.", "settings_lazy_ping_enable": "Enable", "settings_lazy_ping_title": "Lazy ping", "settings_page_title": "Settings", From 2420d265714ff34fa71f3c4dc82d9ffec8fcbbf5 Mon Sep 17 00:00:00 2001 From: Mike Pastore Date: Mon, 10 Aug 2026 12:16:13 -0500 Subject: [PATCH 5/5] ui: move lazy ping section below ip tracking in settings Co-Authored-By: Claude Fable 5 --- frontend/src/routes/settings/+page.svelte | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 19452a51d..9aa7a206f 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -161,16 +161,6 @@ | minute (0–59) second (0–59, optional) -

{m.settings_lazy_ping_title()}

-

- {m.settings_lazy_ping_desc()} -

-
- -

{m.settings_track_ip_title()}

{m.settings_track_ip_desc()} @@ -194,6 +184,16 @@ second (0–59, optional) {/await}

{/if} +

{m.settings_lazy_ping_title()}

+

+ {m.settings_lazy_ping_desc()} +

+
+ +