summaryrefslogtreecommitdiff
path: root/adapters
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
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'adapters')
-rw-r--r--adapters/arp/arp.go63
-rw-r--r--adapters/arp/arp_test.go51
-rw-r--r--adapters/netscan/netscan.go74
-rw-r--r--adapters/netscan/netscan_test.go33
-rw-r--r--adapters/rcon/rcon.go130
-rw-r--r--adapters/rcon/rcon_test.go91
6 files changed, 442 insertions, 0 deletions
diff --git a/adapters/arp/arp.go b/adapters/arp/arp.go
new file mode 100644
index 0000000..19d7632
--- /dev/null
+++ b/adapters/arp/arp.go
@@ -0,0 +1,63 @@
+package arp
+
+import (
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "penguins.lan/utils"
+)
+
+type ArpTable struct {
+ AddrToMac map[utils.INet4]utils.Mac
+}
+
+// Lookup returns the canonical MAC for an IPv4 string, false if it isn't in the
+// table or isn't parseable IPv4 (e.g. an IPv6 peer).
+func (t *ArpTable) Lookup(ip string) (string, bool) {
+ addr, err := utils.ParseINet4(ip)
+ if err != nil {
+ return "", false
+ }
+ mac, ok := t.AddrToMac[*addr]
+ if !ok {
+ return "", false
+ }
+ return mac.Format(), true
+}
+
+type TableProvider interface {
+ Fetch() (*ArpTable, error)
+}
+
+var arpLine = regexp.MustCompile(`\((\d{1,3}(?:\.\d{1,3}){3})\) at ([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2}){5})`)
+
+type SystemProvider struct{}
+
+func (SystemProvider) Fetch() (*ArpTable, error) {
+ out, err := exec.Command("arp", "-an").Output()
+ if err != nil {
+ return nil, err
+ }
+ return parseTable(string(out)), nil
+}
+
+func parseTable(out string) *ArpTable {
+ table := &ArpTable{AddrToMac: make(map[utils.INet4]utils.Mac)}
+ for _, line := range strings.Split(out, "\n") {
+ m := arpLine.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ addr, err := utils.ParseINet4(m[1])
+ if err != nil {
+ continue
+ }
+ mac, err := utils.ParseMac(m[2])
+ if err != nil {
+ continue
+ }
+ table.AddrToMac[*addr] = *mac
+ }
+ return table
+}
diff --git a/adapters/arp/arp_test.go b/adapters/arp/arp_test.go
new file mode 100644
index 0000000..9a215e0
--- /dev/null
+++ b/adapters/arp/arp_test.go
@@ -0,0 +1,51 @@
+package arp
+
+import "testing"
+
+const sample = `Neighbor Linklayer Address
+? (192.168.1.5) at 8:0:27:ab:cd:ef on en0 ifscope [ethernet]
+? (192.168.1.9) at AA:BB:CC:11:22:33 [ether] on br-lan
+? (10.0.0.2) at de:ad:be:ef:00:01 on eth0
+? (192.168.1.250) at (incomplete) on en0 [ethernet]
+
+`
+
+func TestParseTable(t *testing.T) {
+ table := parseTable(sample)
+
+ got := make(map[string]string, len(table.AddrToMac))
+ for ip, mac := range table.AddrToMac {
+ got[ip.Format()] = mac.Format()
+ }
+
+ want := map[string]string{
+ "192.168.1.5": "08:00:27:ab:cd:ef",
+ "192.168.1.9": "aa:bb:cc:11:22:33",
+ "10.0.0.2": "de:ad:be:ef:00:01",
+ }
+
+ if len(got) != len(want) {
+ t.Fatalf("parsed %d entries %v, want %d (header/incomplete lines should be skipped)", len(got), got, len(want))
+ }
+ for ip, mac := range want {
+ if got[ip] != mac {
+ t.Errorf("entry %q = %q, want %q", ip, got[ip], mac)
+ }
+ }
+}
+
+func TestTableLookup(t *testing.T) {
+ table := parseTable(sample)
+
+ if mac, ok := table.Lookup("192.168.1.5"); !ok || mac != "08:00:27:ab:cd:ef" {
+ t.Errorf("Lookup(192.168.1.5) = %q,%v", mac, ok)
+ }
+ if _, ok := table.Lookup("1.2.3.4"); ok {
+ t.Error("Lookup of a miss should be false")
+ }
+ if _, ok := table.Lookup("::1"); ok {
+ t.Error("Lookup of non-IPv4 should be false")
+ }
+}
+
+var _ TableProvider = SystemProvider{}
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")
+ }
+}
diff --git a/adapters/rcon/rcon.go b/adapters/rcon/rcon.go
new file mode 100644
index 0000000..4b9e8b6
--- /dev/null
+++ b/adapters/rcon/rcon.go
@@ -0,0 +1,130 @@
+// Package rcon is a minimal Source RCON client (the protocol Minecraft speaks),
+// enough to authenticate and run a command like "list".
+package rcon
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "net"
+ "time"
+)
+
+const (
+ typeResponse int32 = 0
+ typeAuthResp int32 = 2
+ typeExecCommand int32 = 2
+ typeAuth int32 = 3
+
+ authRequestID int32 = 0
+ execRequestID int32 = 1
+
+ headerSize = 8 // id (int32) + type (int32)
+ paddingSize = 2 // two trailing null bytes
+ maxBodySize = 4096
+
+ DefaultTimeout = 5 * time.Second
+)
+
+var (
+ ErrAuthFailed = errors.New("rcon: authentication failed")
+ ErrResponseMismatch = errors.New("rcon: response id mismatch")
+ ErrPacketSize = errors.New("rcon: invalid packet size")
+)
+
+type Client struct {
+ conn net.Conn
+ timeout time.Duration
+}
+
+func Dial(address, password string, timeout time.Duration) (*Client, error) {
+ conn, err := net.DialTimeout("tcp", address, timeout)
+ if err != nil {
+ return nil, err
+ }
+
+ client := &Client{conn: conn, timeout: timeout}
+ if err := client.auth(password); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return client, nil
+}
+
+func (c *Client) Close() error { return c.conn.Close() }
+
+func (c *Client) auth(password string) error {
+ if err := c.write(authRequestID, typeAuth, password); err != nil {
+ return err
+ }
+
+ // Some servers send an empty SERVERDATA_RESPONSE_VALUE before the auth
+ // response; skip anything that isn't the auth response.
+ for {
+ id, typ, _, err := c.read()
+ if err != nil {
+ return err
+ }
+ if typ != typeAuthResp {
+ continue
+ }
+ if id == -1 {
+ return ErrAuthFailed
+ }
+ return nil
+ }
+}
+
+func (c *Client) Execute(command string) (string, error) {
+ if err := c.write(execRequestID, typeExecCommand, command); err != nil {
+ return "", err
+ }
+
+ id, _, body, err := c.read()
+ if err != nil {
+ return "", err
+ }
+ if id != execRequestID {
+ return "", ErrResponseMismatch
+ }
+ return body, nil
+}
+
+func (c *Client) write(id, typ int32, body string) error {
+ c.conn.SetWriteDeadline(time.Now().Add(c.timeout))
+
+ var buf bytes.Buffer
+ size := int32(headerSize + len(body) + paddingSize)
+ binary.Write(&buf, binary.LittleEndian, size)
+ binary.Write(&buf, binary.LittleEndian, id)
+ binary.Write(&buf, binary.LittleEndian, typ)
+ buf.WriteString(body)
+ buf.Write([]byte{0, 0})
+
+ _, err := c.conn.Write(buf.Bytes())
+ return err
+}
+
+func (c *Client) read() (id, typ int32, body string, err error) {
+ c.conn.SetReadDeadline(time.Now().Add(c.timeout))
+
+ var size int32
+ if err = binary.Read(c.conn, binary.LittleEndian, &size); err != nil {
+ return
+ }
+ if size < headerSize+paddingSize || size > headerSize+maxBodySize+paddingSize {
+ err = ErrPacketSize
+ return
+ }
+
+ payload := make([]byte, size)
+ if _, err = io.ReadFull(c.conn, payload); err != nil {
+ return
+ }
+
+ id = int32(binary.LittleEndian.Uint32(payload[0:4]))
+ typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
+ body = string(payload[headerSize : size-paddingSize])
+ return
+}
diff --git a/adapters/rcon/rcon_test.go b/adapters/rcon/rcon_test.go
new file mode 100644
index 0000000..f712fa5
--- /dev/null
+++ b/adapters/rcon/rcon_test.go
@@ -0,0 +1,91 @@
+package rcon
+
+import (
+ "encoding/binary"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+)
+
+func writeTestPacket(w io.Writer, id, typ int32, body string) {
+ binary.Write(w, binary.LittleEndian, int32(headerSize+len(body)+paddingSize))
+ binary.Write(w, binary.LittleEndian, id)
+ binary.Write(w, binary.LittleEndian, typ)
+ io.WriteString(w, body)
+ w.Write([]byte{0, 0})
+}
+
+func readTestPacket(r io.Reader) (id, typ int32, body string) {
+ var size int32
+ binary.Read(r, binary.LittleEndian, &size)
+ payload := make([]byte, size)
+ io.ReadFull(r, payload)
+ id = int32(binary.LittleEndian.Uint32(payload[0:4]))
+ typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
+ return id, typ, string(payload[headerSize : size-paddingSize])
+}
+
+// fakeServer speaks just enough Source RCON to exercise the client.
+func fakeServer(t *testing.T, password string) string {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ id, typ, body := readTestPacket(conn)
+ if typ != typeAuth {
+ return
+ }
+ respID := id
+ if body != password {
+ respID = -1
+ }
+ writeTestPacket(conn, id, typeResponse, "") // empty value first
+ writeTestPacket(conn, respID, typeAuthResp, "")
+ if respID == -1 {
+ return
+ }
+
+ cid, _, cbody := readTestPacket(conn)
+ out := "unknown command"
+ if cbody == "list" {
+ out = "There are 1 of a max of 20 players online: Steve"
+ }
+ writeTestPacket(conn, cid, typeResponse, out)
+ }()
+
+ return ln.Addr().String()
+}
+
+func TestClientExecute(t *testing.T) {
+ client, err := Dial(fakeServer(t, "secret"), "secret", time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+
+ out, err := client.Execute("list")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "There are 1 of a max of 20 players online: Steve"; out != want {
+ t.Errorf("Execute = %q, want %q", out, want)
+ }
+}
+
+func TestClientAuthFailure(t *testing.T) {
+ _, err := Dial(fakeServer(t, "secret"), "wrong", time.Second)
+ if !errors.Is(err, ErrAuthFailed) {
+ t.Errorf("Dial err = %v, want ErrAuthFailed", err)
+ }
+}