Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion backend/cronjobs/cronjobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
132 changes: 132 additions & 0 deletions backend/iptracking/iptracking.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading