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..60e27b189 100644
--- a/backend/cronjobs/cronjobs.go
+++ b/backend/cronjobs/cronjobs.go
@@ -4,6 +4,7 @@ import (
"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"
)
@@ -19,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)
@@ -103,6 +104,19 @@ 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() {
+ // 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)
+ }
+ }
}
func SetWakeShutdownJobs(app *pocketbase.PocketBase) {
diff --git a/backend/iptracking/iptracking.go b/backend/iptracking/iptracking.go
new file mode 100644
index 000000000..ff5e9f3bf
--- /dev/null
+++ b/backend/iptracking/iptracking.go
@@ -0,0 +1,132 @@
+// 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
+
+// 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
+
+// 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)
+ }
+ }
+}
+
+// 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.
+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..f3221b89f
--- /dev/null
+++ b/backend/iptracking/iptracking_test.go
@@ -0,0 +1,347 @@
+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)
+ }
+ })
+ }
+}
+
+// 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.
+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/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..08511517c
--- /dev/null
+++ b/backend/networking/scan.go
@@ -0,0 +1,64 @@
+package networking
+
+import (
+ "encoding/xml"
+ "net"
+ "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"`
+}
+
+// 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{}
+
+ 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/scan_test.go b/backend/networking/scan_test.go
new file mode 100644
index 000000000..b48721f1b
--- /dev/null
+++ b/backend/networking/scan_test.go
@@ -0,0 +1,65 @@
+package networking
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+// 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 = `
+
+
+
+
+
+
+
+
+
+
+
+ `
+
+func TestNmaprunUnmarshal(t *testing.T) {
+ nmapOutput := Nmaprun{}
+ if err := xml.Unmarshal([]byte(nmapSample), &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)
+ }
+}
+
+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
new file mode 100644
index 000000000..7837f57ea
--- /dev/null
+++ b/backend/networking/trackip.go
@@ -0,0 +1,59 @@
+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
+}
+
+// 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 err
+ }
+ for _, addr := range addrs {
+ attached, ok := addr.(*net.IPNet)
+ if !ok || attached.IP.To4() == nil {
+ continue
+ }
+ // 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 errors.New("subnet does not overlap a directly attached network")
+}
diff --git a/backend/networking/trackip_test.go b/backend/networking/trackip_test.go
new file mode 100644
index 000000000..3c9ce9256
--- /dev/null
+++ b/backend/networking/trackip_test.go
@@ -0,0 +1,146 @@
+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 TestValidateScannableSubnet(t *testing.T) {
+ testCases := []struct {
+ name string
+ ip string
+ netmask string
+ wantError bool
+ }{
+ // 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: "Non-contiguous Mask",
+ ip: "10.0.1.5",
+ netmask: "255.0.255.0",
+ wantError: true,
+ },
+ // Anything larger than a /16 takes too long to scan
+ {
+ 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,
+ },
+ }
+
+ 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 err := ValidateScannableSubnet(subnet); (err != nil) != tc.wantError {
+ t.Errorf("ValidateScannableSubnet(%s): expected error=%v, got %v", subnet.String(), tc.wantError, err)
+ }
+ })
+ }
+}
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/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()}
+
+ {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..9aa7a206f 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)
@@ -153,6 +161,29 @@
| minute (0–59)
second (0–59, optional)
+
{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}
{m.settings_lazy_ping_title()}
{m.settings_lazy_ping_desc()}
diff --git a/frontend/translations/en-US.json b/frontend/translations/en-US.json
index bb3171ce6..811da44e0 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'",
@@ -133,13 +135,15 @@
"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",
"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",