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
37 changes: 27 additions & 10 deletions backend/cronjobs/cronjobs.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cronjobs

import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/robfig/cron/v3"
"github.com/seriousm4x/upsnap/iptracking"
Expand Down Expand Up @@ -56,11 +55,14 @@ func SetPingJobs(app core.App) {
for _, device := range devices {
// ping device
go func(d *core.Record) {
// only write status changes so concurrent writers to other
// fields (e.g. ip tracking) are never clobbered
d.IgnoreUnchangedFields(true)
status := d.GetString("status")
if status == "pending" {
return
}
isUp, err := networking.PingDevice(d)
isUp, err := networking.PingDevice(d, nil)
if err != nil {
logger.Error.Println(err)
}
Expand Down Expand Up @@ -119,7 +121,7 @@ func SetPingJobs(app core.App) {
}
}

func SetWakeShutdownJobs(app *pocketbase.PocketBase) {
func SetWakeShutdownJobs(app core.App) {
// remove existing jobs
for _, job := range CronWakeShutdown.Entries() {
CronWakeShutdown.Remove(job.ID)
Expand All @@ -143,10 +145,11 @@ func SetWakeShutdownJobs(app *pocketbase.PocketBase) {
logger.Error.Println(err)
return
}
d.IgnoreUnchangedFields(true)
if d.GetString("status") == "pending" {
return
}
isOnline, err := networking.PingDevice(d)
isOnline, err := networking.PingDevice(d, nil)
if err != nil {
logger.Error.Println(err)
return
Expand All @@ -159,7 +162,13 @@ func SetWakeShutdownJobs(app *pocketbase.PocketBase) {
logger.Error.Println("Failed to save record:", err)
return
}
if err := networking.WakeDevice(d); err != nil {
// refresh the save baseline so a revert to the load-time
// status below isn't dropped as unchanged
if err := d.PostScan(); err != nil {
logger.Error.Println(err)
}
iptracking.TrackDeviceAfterWake(app, d)
if err := networking.WakeDevice(d, networking.DeviceIPFunc(app, d)); err != nil {
logger.Error.Println(err)
d.Set("status", "offline")
} else {
Expand All @@ -181,24 +190,32 @@ func SetWakeShutdownJobs(app *pocketbase.PocketBase) {
logger.Error.Println(err)
return
}
d.IgnoreUnchangedFields(true)
if d.GetString("status") == "pending" {
return
}
// refresh a tracked ip first so the online check and the
// shutdown command use the device's current address
d = iptracking.TrackDevice(app, d)
d.IgnoreUnchangedFields(true)
// the scan can take a while: bail if a wake was initiated
// in the meantime
if d.GetString("status") == "pending" {
return
}
isOnline, err := networking.PingDevice(d)
isOnline, err := networking.PingDevice(d, nil)
if err != nil {
logger.Error.Println(err)
return
}
if !isOnline {
return
}
status := d.GetString("status")
if status != "online" {
return
}
d.Set("status", "pending")
if err := app.Save(d); err != nil {
logger.Error.Println("Failed to save record:", err)
} else if err := d.PostScan(); err != nil {
logger.Error.Println(err)
}
if err := networking.ShutdownDevice(d); err != nil {
logger.Error.Println(err)
Expand Down
59 changes: 59 additions & 0 deletions backend/cronjobs/cronjobs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cronjobs

import (
"testing"

"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"

// register the app migrations so test apps get the real schema
_ "github.com/seriousm4x/upsnap/migrations"
)

// A failed scheduled wake must persist the revert to the pre-wake status.
// With IgnoreUnchangedFields set, that only works because the save baseline
// is refreshed after the intermediate pending write — otherwise the revert
// equals the load-time status and is dropped from the update, leaving the
// device wedged at "pending" and skipped by every cron from then on.
func TestWakeCronPersistsRevertedStatus(t *testing.T) {
app, err := tests.NewTestApp(t.TempDir())
if err != nil {
t.Fatalf("Got unexpected error: %v", err)
}
t.Cleanup(app.Cleanup)

collection, err := app.FindCollectionByNameOrId("devices")
if err != nil {
t.Fatalf("Got unexpected error: %v", err)
}
device := core.NewRecord(collection)
device.Set("name", "wake-fails")
device.Set("ip", "127.0.0.1")
device.Set("netmask", "255.255.255.0")
device.Set("mac", "AA:BB:CC:DD:0A:01")
device.Set("status", "offline")
device.Set("wake_cron", "0 0 * * *")
device.Set("wake_cron_enabled", true)
// the wake command fails immediately, the ping command reports offline
device.Set("wake_cmd", "exit 1")
device.Set("wake_timeout", 1)
device.Set("ping_cmd", "exit 1")
if err := app.Save(device); err != nil {
t.Fatalf("Got unexpected error: %v", err)
}

SetWakeShutdownJobs(app)
entries := CronWakeShutdown.Entries()
if len(entries) != 1 {
t.Fatalf("Expected one wake cron entry, got %d", len(entries))
}
entries[0].Job.Run()

fresh, err := app.FindRecordById("devices", device.Id)
if err != nil {
t.Fatalf("Got unexpected error: %v", err)
}
if status := fresh.GetString("status"); status != "offline" {
t.Errorf("Status mismatch: expected offline, got %s", status)
}
}
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/pocketbase/pocketbase v0.39.9
github.com/prometheus-community/pro-bing v0.9.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
kernel.org/pub/linux/libs/security/libcap/cap v1.2.78
)
Expand Down Expand Up @@ -42,7 +43,6 @@ require (
golang.org/x/image v0.44.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
kernel.org/pub/linux/libs/security/libcap/psx v1.2.78 // indirect
modernc.org/libc v1.74.1 // indirect
Expand Down
67 changes: 67 additions & 0 deletions backend/iptracking/iptracking.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ package iptracking
import (
"net"
"sync/atomic"
"time"

"github.com/pocketbase/pocketbase/core"
"github.com/seriousm4x/upsnap/logger"
"github.com/seriousm4x/upsnap/networking"
"golang.org/x/sync/singleflight"
)

var sweepRunning atomic.Bool
Expand All @@ -25,6 +27,13 @@ func init() {
// nmapScan is a seam for tests to stub out the privileged nmap invocation
var nmapScan = networking.NmapScan

// scanGroup joins concurrent scans of the same subnet into one nmap run
var scanGroup singleflight.Group

// wakeScanDelay gives a woken device time to boot and renew its dhcp lease
// before its subnet is scanned; a variable so tests can shorten it
var wakeScanDelay = 15 * time.Second

// 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
Expand Down Expand Up @@ -85,6 +94,53 @@ func CatchUpSweep(app core.App) {
TrackAllSubnets(app)
}

// TrackDevice scans the device's subnet and returns the device re-read from
// the database, so an action about to use its ip address sees the address
// the scan found. Returns the given record unchanged when ip tracking is
// disabled globally or for the device, or when the scan fails.
func TrackDevice(app core.App, device *core.Record) *core.Record {
if !networking.DeviceTrackingEnabled(app, device) {
return device
}
subnet, err := networking.DeviceSubnet(device.GetString("ip"), device.GetString("netmask"))
if err != nil {
logger.Error.Println("Ip tracking for", device.GetString("name")+":", err)
return device
}
if err := TrackOneSubnet(app, subnet); err != nil {
logger.Error.Println("Ip tracking scan for", subnet.String()+":", err)
return device
}
fresh, err := app.FindRecordById("devices", device.Id)
if err != nil {
logger.Error.Println(err)
return device
}
return fresh
}

// TrackDeviceAfterWake schedules a scan of the device's subnet to pick up
// the ip address the device acquired while booting. Does nothing unless ip
// tracking is enabled globally and for the device.
func TrackDeviceAfterWake(app core.App, device *core.Record) {
if !networking.DeviceTrackingEnabled(app, device) {
return
}
subnet, err := networking.DeviceSubnet(device.GetString("ip"), device.GetString("netmask"))
if err != nil {
logger.Error.Println("Ip tracking for", device.GetString("name")+":", err)
return
}
// the timer fires regardless of how the wake attempt ends and is not
// cancelled on app shutdown: a scan is harmless when the device never
// came up, and at worst runs once against a closing app
time.AfterFunc(wakeScanDelay, func() {
if err := TrackOneSubnet(app, subnet); err != nil {
logger.Error.Println("Ip tracking scan for", subnet.String()+":", 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.
Expand All @@ -93,6 +149,17 @@ func TrackOneSubnet(app core.App, subnet *net.IPNet) error {
return err
}

// a caller whose subnet is already being scanned waits for that scan's
// result instead of spawning another nmap run. The joined scan may have
// started before the caller's trigger and miss a very recent change;
// the periodic sweep covers that gap
_, err, _ := scanGroup.Do(subnet.String(), func() (any, error) {
return nil, scanSubnet(app, subnet)
})
return err
}

func scanSubnet(app core.App, subnet *net.IPNet) error {
scan, err := nmapScan(subnet.String())
if err != nil {
return err
Expand Down
Loading