Skip to content

feat: optional per-device IP address tracking via periodic ARP scan - #1763

Open
mwpastore wants to merge 5 commits into
seriousm4x:masterfrom
mwpastore:claude/upsnap-magic-packet-routing-dc13ba
Open

feat: optional per-device IP address tracking via periodic ARP scan#1763
mwpastore wants to merge 5 commits into
seriousm4x:masterfrom
mwpastore:claude/upsnap-magic-packet-routing-dc13ba

Conversation

@mwpastore

@mwpastore mwpastore commented Aug 7, 2026

Copy link
Copy Markdown

Summary

Adds an opt-in, per-device IP address tracking feature: when enabled, UpSnap periodically ARP-scans the local subnets of opted-in devices and updates a device's IP address whenever its MAC address shows up at a different IP. This keeps WOL and ping working for DHCP devices whose leases change.

  • Per-device toggle (disabled by default): "Track IP address" in Device settings → General (track_ip bool on the devices collection).
  • Global interval in Settings under Ping interval (track_ip_interval on settings_private, cron syntax, same validation UX as the ping interval). Empty = feature off.
  • The sweep (registered in SetPingJobs, so it re-arms on settings changes; implemented in the backend/iptracking package): fetches devices with track_ip = true, computes each device's subnet from ip+netmask, keeps only subnets that overlap a directly attached network (routed devices are never scanned), skips /32s, non-contiguous netmasks, and anything broader than /16, dedupes, runs one nmap -sn per subnet, and updates any opted-in device whose MAC now sits at a different IP — with the new IP constrained to the device's own subnet, so a scan can never relocate a device across subnets. Saves use IgnoreUnchangedFields(true) so concurrent status writes from the ping/wake crons are never clobbered. No devices opted in or no local subnets → the tick is a no-op. With lazy_ping turned on, sweeps also pause while no realtime clients are connected; a catch-up sweep runs when the next client connects.

Related work

This PR is intended as complementary to #1760, not competing: name resolution covers devices that have a resolvable name (AD, avahi, dynamic DNS) and works across routed subnets, while ARP tracking covers devices with no name at all (headless/IoT boxes, plain DHCP networks without dynamic DNS) but only on directly attached subnets. A device answers to a MAC even when nothing answers for its name. The two features touch adjacent parts of the device form, so whichever lands second will need a small rebase — happy to be the one to rebase over #1760 if it merges first.

Design notes

  • ARP scan, not broadcast ping: modern OSes ship with ICMP-echo-to-broadcast disabled, and passive ARP-table reads miss hosts the daemon hasn't talked to (arp_accept=0 ignores gratuitous ARP). A privileged nmap -sn on the local segment does the ARP probing directly and reports MACs in its XML output — the same mechanism the existing network scan feature relies on.
  • No new privileges or dependencies: requires nmap + CAP_NET_RAW, exactly like the existing network scan. Docker images already ship both; host network mode is already required for WOL. The docker-compose capability comment and README are updated accordingly.
  • Wake path untouched: tracking is periodic-only; WakeDevice/SendMagicPacket are unchanged.
  • Linux-first: capability raising is build-tagged (scan_linux.go / scan_other.go), mirroring the existing ping/scan pattern.

Deliberate duplication / follow-up refactor opportunity

networking/scan.go + scan_linux.go intentionally duplicate the nmap exec + capability-raising code from pb/handlerscan_linux.go so that no existing files in backend/pb are touched by this PR. If desired, a natural follow-up (or squash into this PR) would collapse handlerscan_linux.go/handlerscan_other.go into a single cross-platform handler that calls the shared networking.NmapScan, removing ~120 lines of duplicated handler code. I have that refactor working and can push it as a separate commit/PR on request — kept it out of this one to keep the diff strictly feature-scoped.

Translations

The 4 new UI strings and the updated lazy ping description are in en-US.json only; other locales fall back to English for the new keys until translated. Happy to add translations for the other 22 locales if you'd prefer complete files.

Testing

  • go build for darwin and linux/amd64, go vet, gofmt, and all tests pass. New suites cover the subnet validation and mac→ip mapping in networking, and the tracking behavior itself in iptracking (same-subnet guard, mac format normalization, skip paths, sweep dedupe) against a stubbed scanner and a real migrated PocketBase schema.
  • Booted the compiled backend against a fresh pb_data: both migrations apply and the new track_ip / track_ip_interval columns are created with correct defaults (off / empty).
  • Frontend: pnpm build and eslint pass; svelte-check clean apart from a pre-existing error in paraglide's generated output.
  • An adversarial code review pass flagged a lost-update race between this cron's saves and the ping cron's status saves (PocketBase Save() writes all columns); fixed via IgnoreUnchangedFields(true).
  • Soak-tested in production: I deployed a beta build of this branch to my own UpSnap instance (bare-metal systemd install on a Proxmox guest). The migrations applied cleanly to the live database, and the tracker followed a real device through an actual IP round trip (.142.143.142, driven by netplan changes on the device) within one sweep interval each way. Running with --dev SQL logging confirmed the tracking save writes only the ip column while the ping cron's concurrent full-row status updates continued unharmed, and state survived service restarts.

AI disclosure

This feature was developed with Claude Code (Anthropic). The design decisions, scope choices, and review/testing direction were mine; I've reviewed the changes and stand behind them.

🤖 Generated with Claude Code

mwpastore and others added 2 commits August 7, 2026 18:00
Adds a per-device "Track IP address" toggle (disabled by default) and a
global cron interval setting. When enabled, upsnap periodically arp-scans
the local subnets of opted-in devices with nmap and updates a device's ip
address whenever its mac address is found at a different one. Subnets are
skipped unless directly attached to the host, so routed devices are never
scanned. Uses the same nmap + CAP_NET_RAW requirements as the existing
network scan feature; no new privileges needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@invario

invario commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

I haven't looked closely at the code but from the description, this is a great feature addition.

However, it's subject to the same problems that #1760 runs into with dynamically obtaining a device's IP.... (Wanting to prevent/reduce flooding the network, while still keeping the device's info relatively updated)

Your implementation, for example, arp scans every 15 minutes, which keeps network traffic to a minimum. However, if during that 15 minute window a device gets powered off and then another device gets assigned that IP address, UpSnap won't know for 15 minutes, and in an extreme/edge case, if the user has a shutdown command scheduled during that 15 minute window, it may send the command to the new (and incorrect) device using that IP address. It's unlikely but possible.

@mwpastore

mwpastore commented Aug 10, 2026

Copy link
Copy Markdown
Author

@invario Thank you for the feedback. One thing I considered was triggering the arp scan before/during/after other events. For example, after wakeUDP runs (perhaps plus a small delay). I suppose another opportunity would be right before any action that uses the IP address, e.g. a scheduled shutdown.

I'll work on this locally and can submit a follow-up PR (or add it onto this one if y'all want).

…verage

Move the arp scan sweep out of cronjobs into a new iptracking package with
two entry points: TrackAllSubnets (the cron sweep) and TrackOneSubnet (a
single validated scan). Device lookup is now by mac address across all
tracked devices, with updates constrained to the device's own subnet.

The scannability check becomes networking.ValidateScannableSubnet, which
reports why a subnet can't be scanned and accepts any subnet overlapping a
directly attached network, so sub-blocks of an attached prefix are now
scannable. The mac-to-ip mapping moves to networking as Nmaprun.MacToIP.

Cover the subnet guard, mac normalization, skip paths, and sweep
orchestration with tests against a stubbed scanner and a real migrated
schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mwpastore

Copy link
Copy Markdown
Author

The latest commit (f9945b7) restructures the backend half of this feature and adds test coverage for the tracking logic. No frontend or schema changes.

Structure. The sweep moved out of cronjobs.go into a new backend/iptracking package with two entry points: TrackAllSubnets (the cron sweep) and TrackOneSubnet (validate one subnet, scan, apply updates). cronjobs.go is back to just registering the interval. The pure pieces moved into networking: Nmaprun.MacToIP() for the mac→ip extraction, and ValidateScannableSubnet, which reports why a subnet can't be scanned.

Two behavior fixes:

  1. The attachment check required a host address inside the device's subnet, wrongly rejecting on-link sub-blocks (e.g. a /26 carved from an attached /24). It now checks that the subnet overlaps a directly attached network — what ARP can actually reach. Non-contiguous netmasks also get an accurate error instead of being misreported as a /32.
  2. Matching is by MAC across all tracked devices, but an update only applies if the new IP falls inside the device's own ip+netmask subnet. A scan can never relocate a device across subnets, while a device whose own subnet can't be scanned can still be corrected by a scan that finds it. Both documented and tested.

Tests. The entry points take core.App, so the suite runs against PocketBase's test app with this repo's real migrations, and a one-variable seam stubs the nmap invocation — no network, no CAP_NET_RAW, deterministic anywhere. Covered: every ValidateScannableSubnet rejection branch (including the sub-block regression), MAC format normalization, the same-subnet guard from both sides, the track_ip opt-out, legacy rows predating the field format validation, and sweep dedupe/skip behavior.

Net: cronjobs.go −85 lines, one named function per rule, and the invariants are tested instead of implied.

With lazy_ping turned on, the periodic tracking sweep now skips its tick
while no realtime clients are connected, just like the ping cron, and owes
a catch-up sweep to the next client that connects: the realtime connect
hook runs one sweep only when a tick was actually skipped (or none has run
since boot), so page reloads during active use trigger nothing. This
extends lazy_ping's meaning from "pause pings when idle" to "pause
periodic network activity when idle"; the setting's description is updated
accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mwpastore

mwpastore commented Aug 10, 2026

Copy link
Copy Markdown
Author

I hope I haven't moved the cheese too badly on anyone who might have started to review this, but the refactoring and lazy_ping integration seemed integral. I think I'm done now. 😅

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants