From 263fca5c1022184d44c07bddbe1c238527186238 Mon Sep 17 00:00:00 2001 From: minie4 Date: Fri, 3 May 2024 16:44:08 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initial=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 4 ++ .gitignore | 17 +++++ PrometheusCollector/Collector.go | 79 +++++++++++++++++++++ TPLinkClient/Client.go | 101 +++++++++++++++++++++++++++ TPLinkClient/Definitions.go | 105 ++++++++++++++++++++++++++++ TPLinkClient/Network.go | 45 ++++++++++++ TPLinkClient/Protocol.go | 113 +++++++++++++++++++++++++++++++ TPLinkClient/Utils.go | 25 +++++++ go.mod | 18 +++++ go.sum | 22 ++++++ main.go | 42 ++++++++++++ 11 files changed, 571 insertions(+) create mode 100644 .env.sample create mode 100644 .gitignore create mode 100644 PrometheusCollector/Collector.go create mode 100644 TPLinkClient/Client.go create mode 100644 TPLinkClient/Definitions.go create mode 100644 TPLinkClient/Network.go create mode 100644 TPLinkClient/Protocol.go create mode 100644 TPLinkClient/Utils.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..db10692 --- /dev/null +++ b/.env.sample @@ -0,0 +1,4 @@ +# Enter the MAC Address of the TP-Link switch +# you want to monitor here + +TPLINK_MAC=ff:ff:ff:ff:ff:ff \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a36b7f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +.env \ No newline at end of file diff --git a/PrometheusCollector/Collector.go b/PrometheusCollector/Collector.go new file mode 100644 index 0000000..ee614e7 --- /dev/null +++ b/PrometheusCollector/Collector.go @@ -0,0 +1,79 @@ +package PrometheusCollector + +import ( + "fmt" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "minie4.de/tplinkswitchexporter/TPLinkClient" +) + +type TPLinkCollector struct { + client *TPLinkClient.TPLinkClient + portMetrics map[string](*prometheus.GaugeVec) +} + +var portFields = []string{ + "Enabled", + "LinkStatus", + "TxGoodPkt", + "TxBadPkt", + "RxGoodPkt", + "RxBadPkt", +} + +func NewPrometheusCollector(client *TPLinkClient.TPLinkClient) *TPLinkCollector { + portMetrics := make(map[string](*prometheus.GaugeVec)) + + for _, field := range portFields { + portMetrics[field] = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: strings.ToLower(field), + Namespace: "tplinkexporter", + Subsystem: "portstats", + Help: fmt.Sprintf("Value of the '%s' metric from the TP-Link switch", field), + }, + []string{"portnum", "host", "mac", "name", "type"}, + ) + } + + return &TPLinkCollector{ + client: client, + portMetrics: portMetrics, + } +} + +func (collector *TPLinkCollector) Collect(ch chan<- prometheus.Metric) { + info := collector.client.QueryInfo() + stats := collector.client.QueryStats() + + addLabels := func(g *prometheus.GaugeVec, port TPLinkClient.StatsData) prometheus.Gauge { + return g.With(prometheus.Labels{ + "portnum": strconv.Itoa(int(port.Port)), + "host": info.IPAddr, + "mac": info.Mac, + "name": info.Hostname, + "type": info.Type, + }) + } + + for _, port := range stats { + addLabels(collector.portMetrics["Enabled"], port).Set(map[bool]float64{true: 1, false: 0}[port.Enabled]) + addLabels(collector.portMetrics["LinkStatus"], port).Set(float64(port.LinkStatus)) + addLabels(collector.portMetrics["TxGoodPkt"], port).Set(float64(port.TxGoodPkt)) + addLabels(collector.portMetrics["TxBadPkt"], port).Set(float64(port.TxBadPkt)) + addLabels(collector.portMetrics["RxGoodPkt"], port).Set(float64(port.RxGoodPkt)) + addLabels(collector.portMetrics["RxBadPkt"], port).Set(float64(port.RxBadPkt)) + } + + for _, metric := range collector.portMetrics { + metric.Collect(ch) + } +} + +func (collector *TPLinkCollector) Describe(ch chan<- *prometheus.Desc) { + for _, field := range collector.portMetrics { + field.Describe(ch) + } +} diff --git a/TPLinkClient/Client.go b/TPLinkClient/Client.go new file mode 100644 index 0000000..b8c081f --- /dev/null +++ b/TPLinkClient/Client.go @@ -0,0 +1,101 @@ +package TPLinkClient + +import ( + "bytes" + "encoding/binary" + "math/rand/v2" + "net" +) + +func (client *TPLinkClient) QueryStats() []StatsData { + // Send the request + var payload = buildPayload(16384, []byte{}) + response := doQuery(&client.header, payload) + if len(response) == 0 { + return []StatsData{} + } + + // Decode the response + responsePayload := decodePayload(response) + portStats := make([]StatsData, 0) + for _, port := range responsePayload { + if port.Type.DataType != "stat" { + continue + } + // Decode the port statistics + var portData StatsData + binary.Read(bytes.NewBuffer(port.Value), binary.BigEndian, &portData) + portStats = append(portStats, portData) + } + + return portStats +} + +func (client *TPLinkClient) QueryInfo() InfoData { + // Send the request + var payload = buildPayload(2, []byte{}) + response := doQuery(&client.header, payload) + if len(response) == 0 { + return InfoData{} + } + + // Decode the response + responsePayload := decodePayload(response) + infoData := InfoData{} + for _, line := range responsePayload { + if line.Type.Id > 14 { + continue + } + // Decode the info data + switch lineType := line.Type.Name; lineType { + case "type": + infoData.Type = decodeString(line.Value) + case "hostname": + infoData.Hostname = decodeString(line.Value) + case "mac": + infoData.Mac = net.HardwareAddr(line.Value).String() + case "ip_addr": + infoData.IPAddr = bytesToIp(line.Value) + case "ip_mask": + infoData.IPMask = bytesToIp(line.Value) + case "gateway": + infoData.Gateway = bytesToIp(line.Value) + case "firmware": + infoData.Firmware = decodeString(line.Value) + case "hardware": + infoData.Hardware = decodeString(line.Value) + case "dhcp": + infoData.DHCP = line.Value[0] > 0 + case "auto_save": + infoData.AutoSave = line.Value[0] > 0 + case "is_factory": + infoData.IsFactory = line.Value[0] > 0 + } + } + + return infoData +} + +func NewClient(switchMacAddr string) TPLinkClient { + // Create client instance with default header + client := TPLinkClient{ + switchMacAddr: macToBytes(switchMacAddr), + header: PacketHeader{ + Version: 1, + OpCode: 0, + SwitchMAC: [6]byte{}, + HostMAC: [6]byte{}, + SequenceID: int16(rand.IntN(1000)), + ErrorCode: 0, + CheckLength: 0, + FragmentOffset: 0, + Flag: 0, + TokenID: 0, + Checksum: 0, + }, + } + // Copy the Switch MAC address into the 6 byte array + copy(client.header.SwitchMAC[:], client.switchMacAddr) + + return client +} diff --git a/TPLinkClient/Definitions.go b/TPLinkClient/Definitions.go new file mode 100644 index 0000000..7585569 --- /dev/null +++ b/TPLinkClient/Definitions.go @@ -0,0 +1,105 @@ +package TPLinkClient + +// Most of the protocol implementation is from: +// https://github.com/philippechataignon/smrt/blob/master/protocol.py + +var KEY = []byte{191, 155, 227, 202, 99, 162, 79, 104, 49, 18, 190, 164, 30, + 76, 189, 131, 23, 52, 86, 106, 207, 125, 126, 169, 196, 28, 172, 58, + 188, 132, 160, 3, 36, 120, 144, 168, 12, 231, 116, 44, 41, 97, 108, + 213, 42, 198, 32, 148, 218, 107, 247, 112, 204, 14, 66, 68, 91, 224, + 206, 235, 33, 130, 203, 178, 1, 134, 199, 78, 249, 123, 7, 145, 73, + 208, 209, 100, 74, 115, 72, 118, 8, 22, 243, 147, 64, 96, 5, 87, 60, + 113, 233, 152, 31, 219, 143, 174, 232, 153, 245, 158, 254, 70, 170, + 75, 77, 215, 211, 59, 71, 133, 214, 157, 151, 6, 46, 81, 94, 136, + 166, 210, 4, 43, 241, 29, 223, 176, 67, 63, 186, 137, 129, 40, 248, + 255, 55, 15, 62, 183, 222, 105, 236, 197, 127, 54, 179, 194, 229, + 185, 37, 90, 237, 184, 25, 156, 173, 26, 187, 220, 2, 225, 0, 240, + 50, 251, 212, 253, 167, 17, 193, 205, 177, 21, 181, 246, 82, 226, + 38, 101, 163, 182, 242, 92, 20, 11, 95, 13, 230, 16, 121, 124, 109, + 195, 117, 39, 98, 239, 84, 56, 139, 161, 47, 201, 51, 135, 250, 10, + 19, 150, 45, 111, 27, 24, 142, 80, 85, 83, 234, 138, 216, 57, 93, + 65, 154, 141, 122, 34, 140, 128, 238, 88, 89, 9, 146, 171, 149, 53, + 102, 61, 114, 69, 217, 175, 103, 228, 35, 180, 252, 200, 192, 165, + 159, 221, 244, 110, 119, 48} + +var PACKET_END = []byte{0xff, 0xff, 0x00, 0x00} + +type TPLinkClient struct { + switchMacAddr []byte + header PacketHeader +} + +type PacketHeader struct { + Version uint8 + OpCode uint8 + SwitchMAC [6]byte + HostMAC [6]byte + SequenceID int16 + ErrorCode int32 + CheckLength int16 + FragmentOffset int16 + Flag int16 + TokenID int16 + Checksum int32 +} + +// OP-Codes +const ( + Discovery uint8 = iota + Get + Set + Login + Return + Read5 +) + +type PayloadType struct { + Id uint16 + Name string + DataType string +} + +type PayloadItem struct { + Type PayloadType + Value []byte +} + +type StatsData struct { + Port uint8 + Enabled bool + LinkStatus uint8 + TxGoodPkt uint32 + TxBadPkt uint32 + RxGoodPkt uint32 + RxBadPkt uint32 +} + +type InfoData struct { + Type string + Hostname string + Mac string + Firmware string + Hardware string + DHCP bool + IPAddr string + IPMask string + Gateway string + AutoSave bool + IsFactory bool +} + +var payloadTypes = []PayloadType{ + {1, "type", "str"}, + {2, "hostname", "str"}, + {3, "mac", "hex"}, + {4, "ip_addr", "ip"}, + {5, "ip_mask", "ip"}, + {6, "gateway", "ip"}, + {7, "firmware", "str"}, + {8, "hardware", "str"}, + {9, "dhcp", "bool"}, + {13, "auto_save", "bool"}, + {14, "is_factory", "bool"}, + + {16384, "stats", "stat"}, +} diff --git a/TPLinkClient/Network.go b/TPLinkClient/Network.go new file mode 100644 index 0000000..204202d --- /dev/null +++ b/TPLinkClient/Network.go @@ -0,0 +1,45 @@ +package TPLinkClient + +import ( + "fmt" + "net" + "time" +) + +func sendPacket(packet []byte) { + addr, err := net.ResolveUDPAddr("udp", "255.255.255.255:29808") + if err != nil { + fmt.Println("Could not resolve UDP addr") + return + } + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + fmt.Println("Failed to dial UDP") + return + } + defer conn.Close() + conn.Write(packet) +} + +func recvPacket(buf *[]byte) int { + listen_addr, err := net.ResolveUDPAddr("udp", "255.255.255.255:29809") + if err != nil { + fmt.Println("Could not resolve UDP addr") + return -1 + } + listener, err := net.ListenUDP("udp", listen_addr) + if err != nil { + fmt.Println("Failed to dial UDP") + return -1 + } + defer listener.Close() + + // Time out after three seconds + listener.SetReadDeadline(time.Now().Add(3 * time.Second)) + + len, _, err := listener.ReadFrom(*buf) + if err != nil { + return -1 + } + return len +} diff --git a/TPLinkClient/Protocol.go b/TPLinkClient/Protocol.go new file mode 100644 index 0000000..ea5ae1d --- /dev/null +++ b/TPLinkClient/Protocol.go @@ -0,0 +1,113 @@ +package TPLinkClient + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +// Most of the protocol implementation is from: +// https://github.com/philippechataignon/smrt/blob/master/protocol.py + +func doQuery(header *PacketHeader, payload []byte) []byte { + header.OpCode = Get + header.SequenceID = (header.SequenceID + 1) % 1000 + + // Send the query packet + packet := assmblePacket(*header, payload) + applyKey(&packet) + sendPacket(packet) + + // Listen for a response + buf := make([]byte, 8000) + len := recvPacket(&buf) + if len < 1 { + return []byte{} + } + data := make([]byte, len) + copy(data, buf) + applyKey(&data) + + return data +} + +func assmblePacket(header PacketHeader, payload []byte) []byte { + // Packet size is: 32 bytes header + length of the payload + length of the end bytes + header.CheckLength = int16(32 + len(payload) + len(PACKET_END)) + + // Encode the header + var headerBytes bytes.Buffer + err := binary.Write(&headerBytes, binary.BigEndian, header) + if err != nil { + fmt.Println("Failed generating header bytes") + } + + // Concatenate header, payload and end bytes + return append(headerBytes.Bytes(), append(payload, PACKET_END...)...) +} + +func buildPayload(requestType uint16, data []byte) []byte { + var payload = make([]byte, 4) + binary.BigEndian.PutUint16(payload[:2], requestType) + copy(payload[2:4], data) + return payload +} + +func decodeHeader(data []byte) PacketHeader { + var header PacketHeader + // Decode header bytes to struct + buf := bytes.NewBuffer(data) + binary.Read(buf, binary.BigEndian, &header) + return header +} + +func decodePayload(data []byte) []PayloadItem { + // Payload begins at byte 32 (after the header) + payload := make([]byte, len(data)-32) + copy(payload[:], data[32:]) + + results := make([]PayloadItem, 0) + for len(payload) > len(PACKET_END) { + // Decode TLV encoded datatype and length + var dtype uint16 + var dlen uint16 + if err := binary.Read(bytes.NewReader(payload[:2]), binary.BigEndian, &dtype); err != nil { + fmt.Println("Failed readling payload type") + continue + } + if err := binary.Read(bytes.NewReader(payload[2:4]), binary.BigEndian, &dlen); err != nil { + fmt.Println("Failed readling payload length") + continue + } + + // Get TLV data + data := payload[4 : 4+dlen] + // Try to decode the datatype + payloadType := PayloadType{Id: dtype, Name: "unknown", DataType: "raw"} + for _, e := range payloadTypes { + if e.Id == dtype { + payloadType = e + } + } + // Append to list + result := PayloadItem{ + Type: payloadType, + Value: data, + } + results = append(results, result) + payload = payload[4+dlen:] + } + return results +} + +func applyKey(data *[]byte) { + s := make([]byte, len(KEY)) + copy(s, KEY) + var j byte = 0 + for k := range *data { + i := (k + 1) & 0xFF + j = (j + s[i]) & 0xFF + s[i], s[j] = s[j], s[i] // Swap elements in slice + (*data)[k] ^= s[(s[i]+s[j])&0xFF] // XOR operation + } +} diff --git a/TPLinkClient/Utils.go b/TPLinkClient/Utils.go new file mode 100644 index 0000000..8e71af8 --- /dev/null +++ b/TPLinkClient/Utils.go @@ -0,0 +1,25 @@ +package TPLinkClient + +import ( + "bytes" + "net" +) + +func macToBytes(mac string) []byte { + hwAddr, _ := net.ParseMAC(mac) + return hwAddr +} + +func decodeString(data []byte) string { + nullIndex := bytes.IndexByte(data, 0) + if nullIndex >= 0 { + s := data[:nullIndex] + return string(s) + } else { + return "" + } +} + +func bytesToIp(data []byte) string { + return net.IP{data[0], data[1], data[2], data[3]}.String() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..70ba68f --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module minie4.de/tplinkswitchexporter + +go 1.22.2 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.19.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.16.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bcb9e9a --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/main.go b/main.go new file mode 100644 index 0000000..79fd5f9 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "log" + "net/http" + "os" + "strconv" + + "github.com/joho/godotenv" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "minie4.de/tplinkswitchexporter/PrometheusCollector" + "minie4.de/tplinkswitchexporter/TPLinkClient" +) + +func main() { + godotenv.Load() + + var ( + switchMac = os.Getenv("TPLINK_MAC") + port = 9717 + ) + if switchMac == "" { + log.Fatal("Env variable 'TPLINK_MAC' not set!") + } + + log.Println("Initializing TP-Link Exporter") + log.Printf("Switch MAC: '%s'", switchMac) + + // Create TPLink Client + client := TPLinkClient.NewClient(switchMac) + + // Create and register Prometheus Collector + collector := PrometheusCollector.NewPrometheusCollector(&client) + prometheus.MustRegister(collector) + + // Start Web-Server + http.Handle("/metrics", promhttp.Handler()) + log.Printf("Listening on http://0.0.0.0:%d", port) + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(port), nil)) + +}