Skip to content

feat: opportunistic ip tracking scans on wake and shutdown - #2

Draft
mwpastore wants to merge 6 commits into
claude/upsnap-magic-packet-routing-dc13bafrom
claude/upsnap-opportunistic-ip-scans
Draft

feat: opportunistic ip tracking scans on wake and shutdown#2
mwpastore wants to merge 6 commits into
claude/upsnap-magic-packet-routing-dc13bafrom
claude/upsnap-opportunistic-ip-scans

Conversation

@mwpastore

@mwpastore mwpastore commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on the seriousm4x#1763 branch (claude/upsnap-magic-packet-routing-dc13ba is the base here, so the diff shows only the six new commits). Where the base PR makes ip tracking periodic, this makes it opportunistic — scans fire at the two moments a stale ip actually hurts:

  • Waking a device. The wake wait used to ping the recorded ip blindly for up to wake_timeout (default 120s), so a device that renewed its DHCP lease during boot woke fine physically but was reported as "not online" — and stayed misreported until the next periodic sweep. Now a tracking scan of the device's subnet fires 15s after the wake attempt starts, and the wait loop re-reads the ip from the db before every ping attempt, so a moved ip is picked up seconds after the device answers ARP and the wake succeeds within its timeout.
  • Acting on a device's ip. Scheduled and manual shutdowns, reboots, sleeps, and group shutdowns refresh the device's ip with a scan before pinging or substituting {{ DEVICE_IP }}. Scheduled actions are the primary beneficiary: they have no UI gating, and a stale ip previously made the shutdown cron's online check silently no-op. Manual actions are only offered while the device shows online, and a stale ip usually flips it offline first — the wake path above then heals it — but "online" can lie: the old ip may have been re-leased to another host that answers pings, and a custom ping_cmd runs verbatim and ignores the ip field entirely, so status accuracy implies nothing about ip accuracy. The pre-action scan re-locates the mac before the action targets an address, so shutdown_cmd and sleep can't hit the wrong host.

Opportunistic scans are gated the same way on both paths: the global track_ip_interval must be set and the device's track_ip toggle on.

Commits

  1. fix: only write status changes when saving device status — every device-record flow that only mutates status (ping/wake/shutdown crons, all six handlers, boot-time reset) now saves with IgnoreUnchangedFields, so their full-record saves can never clobber an ip written concurrently by a tracking scan. (The ports status save is deliberately untouched.) Save baselines are refreshed (PostScan) after each intermediate pending write, so a later revert to the load-time status is never dropped as an unchanged field — pinned by a cronjobs regression test.
  2. feat: follow tracked ip changes during wake and ping waitsWakeDevice/PingDevice take a getIp func() string (nil = the record's ip); networking.DeviceIPFunc(app, device) composes the db-reading getter, and the gate is exported as networking.DeviceTrackingEnabled.
  3. feat: scan a woken device's subnet shortly after waking itiptracking.TrackDeviceAfterWake schedules the +15s scan at all four wake initiation points. TrackOneSubnet now wraps the nmap run in a singleflight group keyed on the subnet, so a group wake coalesces into one scan and opportunistic scans join an in-flight sweep instead of doubling it (golang.org/x/sync promoted to a direct dependency). Nmap invocations are additionally serialized process-wide, since raising and restoring CAP_NET_RAW mutates process state.
  4. feat: refresh tracked ips before shutdown actionsiptracking.TrackDevice (synchronous scan + re-read, returns the given record on gate-off or failure) at the top of the shutdown/reboot/group-shutdown flows and in the shutdown cron before its online check.
  5. feat: run shutdown, reboot, and sleep actions asynchronously — the frontend passes async=true for those three actions (a capability the API already had), so requests return as soon as the action starts instead of blocking for the scan plus the action — which could outlive reverse-proxy timeouts even before this PR. The card's pending countdown plus realtime updates deliver the outcome, and the countdown re-syncs the record once if it expires while still pending. The action handlers also no-op with the current record when the device is already pending, so a double click can't start a second action. Wake stays synchronous because its response drives link_open.
  6. feat: refresh the tracked ip before sleeping a device — sleep sends an http request to the device's ip, so it gets the same pre-action scan as the shutdown flows.

Behavior changes to note

  • The shutdown cron no longer bails when status != "online". By that point it has just successfully pinged the device via its configured check (default ICMP or ping_cmd); a live ping supersedes a status column that is stale in exactly the scenario this PR targets (ip moved → ping cron marked the device offline). pending is still respected: checked on entry and re-checked after the potentially slow pre-action scan, so a wake initiated mid-scan is never raced by the shutdown.
  • Scans coalesce. Concurrent scans of the same subnet share one nmap run and its result.
  • The scanner remains an ip-only writer. Considered and rejected marking scanned devices online: ARP presence doesn't satisfy the per-device status contract (ping or custom ping_cmd), and it would flap for ICMP-blocking devices between each scan and the next ping tick.

Testing

  • go build for linux, windows, and darwin; go vet; gofmt — clean.
  • The backend suite passes under -race: the iptracking tests (gating on both toggles, the delayed post-wake scan, singleflight join proving one nmap run for two concurrent callers, TrackDevice's refresh/gate-off/unscannable paths, guard/skip/orchestration coverage), new networking tests for DeviceTrackingEnabled, DeviceIPFunc, and WakeDevice's getIp consultation, and a cronjobs regression test pinning that a failed scheduled wake persists its status revert. All hermetic — stubbed scanner, loopback subnets, real migrated schema via tests.NewTestApp.
  • The pre-existing TestCheckPort/Invalid_IP failure is environment-dependent and unrelated (fails on the base branch too).

Not included

🤖 Generated with Claude Code

@mwpastore
mwpastore force-pushed the claude/upsnap-opportunistic-ip-scans branch 2 times, most recently from a599a77 to 4dd91ff Compare August 10, 2026 16:43
mwpastore and others added 6 commits August 10, 2026 12:17
Set IgnoreUnchangedFields on every device record whose flow only mutates
status: the ping, wake, and shutdown crons, the wake/sleep/reboot/shutdown
handlers and their group variants, and the boot-time state reset. Their
full-record saves could clobber fields written concurrently by other
flows, such as ip addresses updated by the tracking scans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WakeDevice and PingDevice now take a getIp function that supplies the
address to ping on each attempt; nil means the record's ip. DeviceIPFunc
composes one from app and device: when ip tracking is enabled globally and
for the device, each call re-reads the record from the database, so a wait
loop picks up address changes written by concurrent tracking scans instead
of pinging a stale ip until timeout. Wake call sites pass the tracking
getter; the crons and shutdown waits pass nil, preserving their behavior.
The gating check is exported as DeviceTrackingEnabled for use by other
tracking call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TrackDeviceAfterWake schedules a tracking scan of the device's subnet 15
seconds after a wake attempt starts, giving the device time to boot and
renew its dhcp lease. Combined with the getIp refresh during the wake
wait, a wake now recovers a moved ip within seconds instead of pinging the
stale address until timeout. Applies to the wake handler, group wake,
reboot, and the wake cron, and only when ip tracking is enabled globally
and for the device.

Concurrent scans of the same subnet now coalesce: TrackOneSubnet wraps the
nmap run in a singleflight group keyed on the subnet, so a group wake or a
scan racing the periodic sweep joins the in-flight run and shares its
result instead of spawning another scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shutdown handler, group shutdown, reboot, and the shutdown cron now
call TrackDevice first: a synchronous scan of the device's subnet (joining
any in-flight scan) followed by a re-read of the record, so the online
check and the shutdown command use the device's current address instead of
a stale one.

The shutdown cron also no longer bails when the status column says
offline: by that point it has just pinged the device successfully via its
configured check, which supersedes bookkeeping that may be stale — exactly
the state a device is left in after an unnoticed ip change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frontend now passes async=true for shutdown, reboot, and sleep, so
those requests return as soon as the action starts instead of blocking
for the pre-action scan plus the action itself — which could outlive
reverse proxy timeouts even before ip tracking existed. The device card
already shows a pending countdown, and the outcome arrives as a realtime
status update. If the countdown expires while the device still shows
pending, the card re-syncs the record once, covering scan-extended
actions and missed realtime events. Wake stays synchronous because its
response drives link_open.

The four action handlers now return the current record without acting
when the device is already pending, so a double click (or a request
racing a cron) can't start a second action on the same device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sleep sends an http request to the device's ip, so it needs the same
pre-action scan as the shutdown flows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mwpastore
mwpastore force-pushed the claude/upsnap-opportunistic-ip-scans branch from 4dd91ff to 12773fd Compare August 10, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant