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_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",