diff options
Diffstat (limited to 'adapters')
| -rw-r--r-- | adapters/arp/arp.go | 63 | ||||
| -rw-r--r-- | adapters/arp/arp_test.go | 51 | ||||
| -rw-r--r-- | adapters/netscan/netscan.go | 74 | ||||
| -rw-r--r-- | adapters/netscan/netscan_test.go | 33 | ||||
| -rw-r--r-- | adapters/rcon/rcon.go | 130 | ||||
| -rw-r--r-- | adapters/rcon/rcon_test.go | 91 |
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) + } +} |
