summaryrefslogtreecommitdiff
path: root/adapters/netscan
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /adapters/netscan
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'adapters/netscan')
-rw-r--r--adapters/netscan/netscan.go74
-rw-r--r--adapters/netscan/netscan_test.go33
2 files changed, 107 insertions, 0 deletions
diff --git a/adapters/netscan/netscan.go b/adapters/netscan/netscan.go
new file mode 100644
index 0000000..5aaf415
--- /dev/null
+++ b/adapters/netscan/netscan.go
@@ -0,0 +1,74 @@
+// Package netscan nudges the kernel into ARP-resolving every host on a subnet,
+// so devices that never talk to us still show up in the neighbour table. It does
+// this by attempting a TCP connection to each host: the result is irrelevant,
+// the ARP exchange the kernel performs to send the SYN is the point.
+package netscan
+
+import (
+ "fmt"
+ "net"
+ "sync"
+ "time"
+)
+
+const (
+ probePort = "9" // discard; closed almost everywhere, so a fast RST
+ maxHostBits = 12 // refuse to sweep anything larger than a /20 (~4094 hosts)
+ sweepWorkers = 32
+)
+
+func Sweep(cidr string, timeout time.Duration) error {
+ hosts, err := hostIPs(cidr)
+ if err != nil {
+ return err
+ }
+
+ sem := make(chan struct{}, sweepWorkers)
+ var wg sync.WaitGroup
+ for _, ip := range hosts {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(ip string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ if conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, probePort), timeout); err == nil {
+ conn.Close()
+ }
+ }(ip)
+ }
+ wg.Wait()
+ return nil
+}
+
+func hostIPs(cidr string) ([]string, error) {
+ _, ipnet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ return nil, err
+ }
+ if ones, bits := ipnet.Mask.Size(); bits-ones > maxHostBits {
+ return nil, fmt.Errorf("netscan: subnet /%d too large to sweep", ones)
+ }
+
+ ip := make(net.IP, len(ipnet.IP))
+ copy(ip, ipnet.IP)
+
+ ips := []string{}
+ for ; ipnet.Contains(ip); inc(ip) {
+ ips = append(ips, ip.String())
+ }
+
+ // drop the network and broadcast addresses
+ if len(ips) > 2 {
+ ips = ips[1 : len(ips)-1]
+ }
+ return ips, nil
+}
+
+func inc(ip net.IP) {
+ for i := len(ip) - 1; i >= 0; i-- {
+ ip[i]++
+ if ip[i] != 0 {
+ break
+ }
+ }
+}
diff --git a/adapters/netscan/netscan_test.go b/adapters/netscan/netscan_test.go
new file mode 100644
index 0000000..b50ef0c
--- /dev/null
+++ b/adapters/netscan/netscan_test.go
@@ -0,0 +1,33 @@
+package netscan
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestHostIPs(t *testing.T) {
+ ips, err := hostIPs("192.168.1.0/30")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"192.168.1.1", "192.168.1.2"}; !reflect.DeepEqual(ips, want) {
+ t.Errorf("/30 = %v, want %v", ips, want)
+ }
+
+ ips, err = hostIPs("10.0.0.0/24")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ips) != 254 || ips[0] != "10.0.0.1" || ips[253] != "10.0.0.254" {
+ t.Errorf("/24 = %d hosts (%q..%q), want 254 (.1...254)", len(ips), ips[0], ips[len(ips)-1])
+ }
+}
+
+func TestHostIPsRejects(t *testing.T) {
+ if _, err := hostIPs("10.0.0.0/8"); err == nil {
+ t.Error("oversized subnet should error")
+ }
+ if _, err := hostIPs("not-a-cidr"); err == nil {
+ t.Error("bad cidr should error")
+ }
+}