diff options
Diffstat (limited to 'src/go/plugin/go.d/modules/ap')
l--------- | src/go/plugin/go.d/modules/ap/README.md | 1 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/ap.go | 113 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/ap_test.go | 292 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/charts.go | 147 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/collect.go | 221 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/config_schema.json | 47 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/exec.go | 56 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/init.go | 37 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/integrations/access_points.md | 202 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/metadata.yaml | 141 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/testdata/config.json | 5 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/testdata/config.yaml | 3 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/testdata/iw_dev_ap.txt | 25 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/testdata/iw_dev_managed.txt | 11 | ||||
-rw-r--r-- | src/go/plugin/go.d/modules/ap/testdata/station_dump.txt | 58 |
15 files changed, 1359 insertions, 0 deletions
diff --git a/src/go/plugin/go.d/modules/ap/README.md b/src/go/plugin/go.d/modules/ap/README.md new file mode 120000 index 00000000..5b6e7513 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/README.md @@ -0,0 +1 @@ +integrations/access_points.md
\ No newline at end of file diff --git a/src/go/plugin/go.d/modules/ap/ap.go b/src/go/plugin/go.d/modules/ap/ap.go new file mode 100644 index 00000000..93dd06d0 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/ap.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ap + +import ( + _ "embed" + "errors" + "time" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module" + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/web" +) + +//go:embed "config_schema.json" +var configSchema string + +func init() { + module.Register("ap", module.Creator{ + JobConfigSchema: configSchema, + Defaults: module.Defaults{ + UpdateEvery: 10, + }, + Create: func() module.Module { return New() }, + Config: func() any { return &Config{} }, + }) +} + +func New() *AP { + return &AP{ + Config: Config{ + BinaryPath: "/usr/sbin/iw", + Timeout: web.Duration(time.Second * 2), + }, + charts: &module.Charts{}, + seenIfaces: make(map[string]*iwInterface), + } +} + +type Config struct { + UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"` + Timeout web.Duration `yaml:"timeout,omitempty" json:"timeout"` + BinaryPath string `yaml:"binary_path,omitempty" json:"binary_path"` +} + +type ( + AP struct { + module.Base + Config `yaml:",inline" json:""` + + charts *module.Charts + + exec iwBinary + + seenIfaces map[string]*iwInterface + } + iwBinary interface { + devices() ([]byte, error) + stationStatistics(ifaceName string) ([]byte, error) + } +) + +func (a *AP) Configuration() any { + return a.Config +} + +func (a *AP) Init() error { + if err := a.validateConfig(); err != nil { + a.Errorf("config validation: %s", err) + return err + } + + iw, err := a.initIwExec() + if err != nil { + a.Errorf("iw dev exec initialization: %v", err) + return err + } + a.exec = iw + + return nil +} + +func (a *AP) Check() error { + mx, err := a.collect() + if err != nil { + a.Error(err) + return err + } + + if len(mx) == 0 { + return errors.New("no metrics collected") + } + + return nil +} + +func (a *AP) Charts() *module.Charts { + return a.charts +} + +func (a *AP) Collect() map[string]int64 { + mx, err := a.collect() + if err != nil { + a.Error(err) + } + + if len(mx) == 0 { + return nil + } + + return mx +} + +func (a *AP) Cleanup() {} diff --git a/src/go/plugin/go.d/modules/ap/ap_test.go b/src/go/plugin/go.d/modules/ap/ap_test.go new file mode 100644 index 00000000..237e00e9 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/ap_test.go @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ap + +import ( + "errors" + "os" + "testing" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + dataConfigJSON, _ = os.ReadFile("testdata/config.json") + dataConfigYAML, _ = os.ReadFile("testdata/config.yaml") + + dataIwDevManaged, _ = os.ReadFile("testdata/iw_dev_managed.txt") + + dataIwDevAP, _ = os.ReadFile("testdata/iw_dev_ap.txt") + dataIwStationDump, _ = os.ReadFile("testdata/station_dump.txt") +) + +func Test_testDataIsValid(t *testing.T) { + for name, data := range map[string][]byte{ + "dataConfigJSON": dataConfigJSON, + "dataConfigYAML": dataConfigYAML, + "dataIwDevManaged": dataIwDevManaged, + "dataIwDevAP": dataIwDevAP, + "dataIwStationDump": dataIwStationDump, + } { + require.NotNil(t, data, name) + } +} + +func TestAP_Configuration(t *testing.T) { + module.TestConfigurationSerialize(t, &AP{}, dataConfigJSON, dataConfigYAML) +} + +func TestAP_Init(t *testing.T) { + tests := map[string]struct { + config Config + wantFail bool + }{ + "fails if 'binary_path' is not set": { + wantFail: true, + config: Config{ + BinaryPath: "", + }, + }, + "fails if failed to find binary": { + wantFail: true, + config: Config{ + BinaryPath: "iw!!!", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + pf := New() + pf.Config = test.config + + if test.wantFail { + assert.Error(t, pf.Init()) + } else { + assert.NoError(t, pf.Init()) + } + }) + } +} + +func TestAP_Cleanup(t *testing.T) { + tests := map[string]struct { + prepare func() *AP + }{ + "not initialized exec": { + prepare: func() *AP { + return New() + }, + }, + "after check": { + prepare: func() *AP { + ap := New() + ap.exec = prepareMockOk() + _ = ap.Check() + return ap + }, + }, + "after collect": { + prepare: func() *AP { + ap := New() + ap.exec = prepareMockOk() + _ = ap.Collect() + return ap + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + pf := test.prepare() + + assert.NotPanics(t, pf.Cleanup) + }) + } +} + +func TestAP_Charts(t *testing.T) { + assert.NotNil(t, New().Charts()) +} + +func TestAP_Check(t *testing.T) { + tests := map[string]struct { + prepareMock func() *mockIwExec + wantFail bool + }{ + "success case": { + wantFail: false, + prepareMock: prepareMockOk, + }, + "no ap devices": { + wantFail: true, + prepareMock: prepareMockNoAPDevices, + }, + "error on devices call": { + wantFail: true, + prepareMock: prepareMockErrOnDevices, + }, + "error on station stats call": { + wantFail: true, + prepareMock: prepareMockErrOnStationStats, + }, + "unexpected response": { + wantFail: true, + prepareMock: prepareMockUnexpectedResponse, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ap := New() + ap.exec = test.prepareMock() + + if test.wantFail { + assert.Error(t, ap.Check()) + } else { + assert.NoError(t, ap.Check()) + } + }) + } +} + +func TestAP_Collect(t *testing.T) { + tests := map[string]struct { + prepareMock func() *mockIwExec + wantMetrics map[string]int64 + wantCharts int + }{ + "success case": { + prepareMock: prepareMockOk, + wantCharts: len(apChartsTmpl) * 2, + wantMetrics: map[string]int64{ + "ap_wlp1s0_testing_average_signal": -34000, + "ap_wlp1s0_testing_bitrate_receive": 65500, + "ap_wlp1s0_testing_bitrate_transmit": 65000, + "ap_wlp1s0_testing_bw_received": 95117, + "ap_wlp1s0_testing_bw_sent": 8270, + "ap_wlp1s0_testing_clients": 2, + "ap_wlp1s0_testing_issues_failures": 1, + "ap_wlp1s0_testing_issues_retries": 1, + "ap_wlp1s0_testing_packets_received": 2531, + "ap_wlp1s0_testing_packets_sent": 38, + "ap_wlp1s1_testing_average_signal": -34000, + "ap_wlp1s1_testing_bitrate_receive": 65500, + "ap_wlp1s1_testing_bitrate_transmit": 65000, + "ap_wlp1s1_testing_bw_received": 95117, + "ap_wlp1s1_testing_bw_sent": 8270, + "ap_wlp1s1_testing_clients": 2, + "ap_wlp1s1_testing_issues_failures": 1, + "ap_wlp1s1_testing_issues_retries": 1, + "ap_wlp1s1_testing_packets_received": 2531, + "ap_wlp1s1_testing_packets_sent": 38, + }, + }, + "no ap devices": { + prepareMock: prepareMockNoAPDevices, + wantMetrics: nil, + }, + "error on devices call": { + prepareMock: prepareMockErrOnDevices, + wantMetrics: nil, + }, + "error on statis stats call": { + prepareMock: prepareMockErrOnStationStats, + wantMetrics: nil, + }, + "unexpected response": { + prepareMock: prepareMockUnexpectedResponse, + wantMetrics: nil, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ap := New() + ap.exec = test.prepareMock() + + mx := ap.Collect() + + assert.Equal(t, test.wantMetrics, mx) + assert.Equal(t, test.wantCharts, len(*ap.Charts()), "Charts") + testMetricsHasAllChartsDims(t, ap, mx) + }) + } +} + +func testMetricsHasAllChartsDims(t *testing.T, ap *AP, mx map[string]int64) { + for _, chart := range *ap.Charts() { + if chart.Obsolete { + continue + } + for _, dim := range chart.Dims { + _, ok := mx[dim.ID] + assert.Truef(t, ok, "collected metrics has no data for dim '%s' chart '%s'", dim.ID, chart.ID) + } + for _, v := range chart.Vars { + _, ok := mx[v.ID] + assert.Truef(t, ok, "collected metrics has no data for var '%s' chart '%s'", v.ID, chart.ID) + } + } +} + +func prepareMockOk() *mockIwExec { + return &mockIwExec{ + devicesData: dataIwDevAP, + stationStatsData: dataIwStationDump, + } +} + +func prepareMockNoAPDevices() *mockIwExec { + return &mockIwExec{ + devicesData: dataIwDevManaged, + } +} + +func prepareMockErrOnDevices() *mockIwExec { + return &mockIwExec{ + errOnDevices: true, + } +} + +func prepareMockErrOnStationStats() *mockIwExec { + return &mockIwExec{ + devicesData: dataIwDevAP, + errOnStationStats: true, + } +} + +func prepareMockUnexpectedResponse() *mockIwExec { + return &mockIwExec{ + devicesData: []byte(` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Nulla malesuada erat id magna mattis, eu viverra tellus rhoncus. +Fusce et felis pulvinar, posuere sem non, porttitor eros. +`), + } +} + +type mockIwExec struct { + errOnDevices bool + errOnStationStats bool + devicesData []byte + stationStatsData []byte +} + +func (m *mockIwExec) devices() ([]byte, error) { + if m.errOnDevices { + return nil, errors.New("mock.devices() error") + } + + return m.devicesData, nil +} + +func (m *mockIwExec) stationStatistics(_ string) ([]byte, error) { + if m.errOnStationStats { + return nil, errors.New("mock.stationStatistics() error") + } + return m.stationStatsData, nil +} diff --git a/src/go/plugin/go.d/modules/ap/charts.go b/src/go/plugin/go.d/modules/ap/charts.go new file mode 100644 index 00000000..b8c51c43 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/charts.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ap + +import ( + "fmt" + "strings" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module" +) + +const ( + prioClients = module.Priority + iota + prioBandwidth + prioPackets + prioIssues + prioSignal + prioBitrate +) + +var apChartsTmpl = module.Charts{ + apClientsChartTmpl.Copy(), + apBandwidthChartTmpl.Copy(), + apPacketsChartTmpl.Copy(), + apIssuesChartTmpl.Copy(), + apSignalChartTmpl.Copy(), + apBitrateChartTmpl.Copy(), +} + +var ( + apClientsChartTmpl = module.Chart{ + ID: "ap_%s_%s_clients", + Title: "Connected clients", + Fam: "clients", + Units: "clients", + Ctx: "ap.clients", + Type: module.Line, + Priority: prioClients, + Dims: module.Dims{ + {ID: "ap_%s_%s_clients", Name: "clients"}, + }, + } + + apBandwidthChartTmpl = module.Chart{ + ID: "ap_%s_%s_bandwidth", + Title: "Bandwidth", + Units: "kilobits/s", + Fam: "traffic", + Ctx: "ap.net", + Type: module.Area, + Priority: prioBandwidth, + Dims: module.Dims{ + {ID: "ap_%s_%s_bw_received", Name: "received", Algo: module.Incremental, Mul: 8, Div: 1000}, + {ID: "ap_%s_%s_bw_sent", Name: "sent", Algo: module.Incremental, Mul: -8, Div: 1000}, + }, + } + + apPacketsChartTmpl = module.Chart{ + ID: "ap_%s_%s_packets", + Title: "Packets", + Fam: "packets", + Units: "packets/s", + Ctx: "ap.packets", + Type: module.Line, + Priority: prioPackets, + Dims: module.Dims{ + {ID: "ap_%s_%s_packets_received", Name: "received", Algo: module.Incremental}, + {ID: "ap_%s_%s_packets_sent", Name: "sent", Algo: module.Incremental, Mul: -1}, + }, + } + + apIssuesChartTmpl = module.Chart{ + ID: "ap_%s_%s_issues", + Title: "Transmit issues", + Fam: "issues", + Units: "issues/s", + Ctx: "ap.issues", + Type: module.Line, + Priority: prioIssues, + Dims: module.Dims{ + {ID: "ap_%s_%s_issues_retries", Name: "tx retries", Algo: module.Incremental}, + {ID: "ap_%s_%s_issues_failures", Name: "tx failures", Algo: module.Incremental, Mul: -1}, + }, + } + + apSignalChartTmpl = module.Chart{ + ID: "ap_%s_%s_signal", + Title: "Average Signal", + Units: "dBm", + Fam: "signal", + Ctx: "ap.signal", + Type: module.Line, + Priority: prioSignal, + Dims: module.Dims{ + {ID: "ap_%s_%s_average_signal", Name: "average signal", Div: precision}, + }, + } + + apBitrateChartTmpl = module.Chart{ + ID: "ap_%s_%s_bitrate", + Title: "Bitrate", + Units: "Mbps", + Fam: "bitrate", + Ctx: "ap.bitrate", + Type: module.Line, + Priority: prioBitrate, + Dims: module.Dims{ + {ID: "ap_%s_%s_bitrate_receive", Name: "receive", Div: precision}, + {ID: "ap_%s_%s_bitrate_transmit", Name: "transmit", Mul: -1, Div: precision}, + }, + } +) + +func (a *AP) addInterfaceCharts(dev *iwInterface) { + charts := apChartsTmpl.Copy() + + for _, chart := range *charts { + chart.ID = fmt.Sprintf(chart.ID, dev.name, cleanSSID(dev.ssid)) + chart.Labels = []module.Label{ + {Key: "device", Value: dev.name}, + {Key: "ssid", Value: dev.ssid}, + } + for _, dim := range chart.Dims { + dim.ID = fmt.Sprintf(dim.ID, dev.name, dev.ssid) + } + } + + if err := a.Charts().Add(*charts...); err != nil { + a.Warning(err) + } + +} + +func (a *AP) removeInterfaceCharts(dev *iwInterface) { + px := fmt.Sprintf("ap_%s_%s_", dev.name, cleanSSID(dev.ssid)) + for _, chart := range *a.Charts() { + if strings.HasPrefix(chart.ID, px) { + chart.MarkRemove() + chart.MarkNotCreated() + } + } +} + +func cleanSSID(ssid string) string { + r := strings.NewReplacer(" ", "_", ".", "_") + return r.Replace(ssid) +} diff --git a/src/go/plugin/go.d/modules/ap/collect.go b/src/go/plugin/go.d/modules/ap/collect.go new file mode 100644 index 00000000..ba32f3ef --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/collect.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ap + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "strconv" + "strings" +) + +const precision = 1000 + +type iwInterface struct { + name string + ssid string + typ string +} + +type stationStats struct { + clients int64 + rxBytes int64 + rxPackets int64 + txBytes int64 + txPackets int64 + txRetries int64 + txFailed int64 + signalAvg int64 + txBitrate float64 + rxBitrate float64 +} + +func (a *AP) collect() (map[string]int64, error) { + bs, err := a.exec.devices() + if err != nil { + return nil, err + } + + // TODO: call this periodically, not on every data collection + apInterfaces, err := parseIwDevices(bs) + if err != nil { + return nil, fmt.Errorf("parsing AP interfaces: %v", err) + } + + if len(apInterfaces) == 0 { + return nil, errors.New("no type AP interfaces found") + } + + mx := make(map[string]int64) + seen := make(map[string]bool) + + for _, iface := range apInterfaces { + bs, err = a.exec.stationStatistics(iface.name) + if err != nil { + return nil, fmt.Errorf("getting station statistics for %s: %v", iface, err) + } + + stats, err := parseIwStationStatistics(bs) + if err != nil { + return nil, fmt.Errorf("parsing station statistics for %s: %v", iface, err) + } + + key := fmt.Sprintf("%s-%s", iface.name, iface.ssid) + + seen[key] = true + + if _, ok := a.seenIfaces[key]; !ok { + a.seenIfaces[key] = iface + a.addInterfaceCharts(iface) + } + + px := fmt.Sprintf("ap_%s_%s_", iface.name, iface.ssid) + + mx[px+"clients"] = stats.clients + mx[px+"bw_received"] = stats.rxBytes + mx[px+"bw_sent"] = stats.txBytes + mx[px+"packets_received"] = stats.rxPackets + mx[px+"packets_sent"] = stats.txPackets + mx[px+"issues_retries"] = stats.txRetries + mx[px+"issues_failures"] = stats.txFailed + mx[px+"average_signal"], mx[px+"bitrate_receive"], mx[px+"bitrate_transmit"] = 0, 0, 0 + if clients := float64(stats.clients); clients > 0 { + mx[px+"average_signal"] = int64(float64(stats.signalAvg) / clients * precision) + mx[px+"bitrate_receive"] = int64(stats.rxBitrate / clients * precision) + mx[px+"bitrate_transmit"] = int64(stats.txBitrate / clients * precision) + } + } + + for key, iface := range a.seenIfaces { + if !seen[key] { + delete(a.seenIfaces, key) + a.removeInterfaceCharts(iface) + } + } + + return mx, nil +} + +func parseIwDevices(resp []byte) ([]*iwInterface, error) { + ifaces := make(map[string]*iwInterface) + var iface *iwInterface + + sc := bufio.NewScanner(bytes.NewReader(resp)) + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + + switch { + case strings.HasPrefix(line, "Interface"): + parts := strings.Fields(line) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid interface line: '%s'", line) + } + name := parts[1] + if _, ok := ifaces[name]; !ok { + iface = &iwInterface{name: name} + ifaces[name] = iface + } + case strings.HasPrefix(line, "ssid") && iface != nil: + parts := strings.Fields(line) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid ssid line: '%s'", line) + } + iface.ssid = parts[1] + case strings.HasPrefix(line, "type") && iface != nil: + parts := strings.Fields(line) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid type line: '%s'", line) + } + iface.typ = parts[1] + } + } + + var apIfaces []*iwInterface + + for _, iface := range ifaces { + if strings.ToLower(iface.typ) == "ap" { + apIfaces = append(apIfaces, iface) + } + } + + return apIfaces, nil +} + +func parseIwStationStatistics(resp []byte) (*stationStats, error) { + var stats stationStats + + sc := bufio.NewScanner(bytes.NewReader(resp)) + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + + var v float64 + var err error + + switch { + case strings.HasPrefix(line, "Station"): + stats.clients++ + case strings.HasPrefix(line, "rx bytes:"): + if v, err = get3rdValue(line); err == nil { + stats.rxBytes += int64(v) + } + case strings.HasPrefix(line, "rx packets:"): + if v, err = get3rdValue(line); err == nil { + stats.rxPackets += int64(v) + } + case strings.HasPrefix(line, "tx bytes:"): + if v, err = get3rdValue(line); err == nil { + stats.txBytes += int64(v) + } + case strings.HasPrefix(line, "tx packets:"): + if v, err = get3rdValue(line); err == nil { + stats.txPackets += int64(v) + } + case strings.HasPrefix(line, "tx retries:"): + if v, err = get3rdValue(line); err == nil { + stats.txRetries += int64(v) + } + case strings.HasPrefix(line, "tx failed:"): + if v, err = get3rdValue(line); err == nil { + stats.txFailed += int64(v) + } + case strings.HasPrefix(line, "signal avg:"): + if v, err = get3rdValue(line); err == nil { + stats.signalAvg += int64(v) + } + case strings.HasPrefix(line, "tx bitrate:"): + if v, err = get3rdValue(line); err == nil { + stats.txBitrate += v + } + case strings.HasPrefix(line, "rx bitrate:"): + if v, err = get3rdValue(line); err == nil { + stats.rxBitrate += v + } + default: + continue + } + + if err != nil { + return nil, fmt.Errorf("parsing line '%s': %v", line, err) + } + } + + return &stats, nil +} + +func get3rdValue(line string) (float64, error) { + parts := strings.Fields(line) + if len(parts) < 3 { + return 0.0, errors.New("invalid format") + } + + v := parts[2] + + if v == "-" { + return 0.0, nil + } + return strconv.ParseFloat(v, 64) +} diff --git a/src/go/plugin/go.d/modules/ap/config_schema.json b/src/go/plugin/go.d/modules/ap/config_schema.json new file mode 100644 index 00000000..4566247f --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/config_schema.json @@ -0,0 +1,47 @@ +{ + "jsonSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Access Point collector configuration.", + "type": "object", + "properties": { + "update_every": { + "title": "Update every", + "description": "Data collection interval, measured in seconds.", + "type": "integer", + "minimum": 1, + "default": 10 + }, + "binary_path": { + "title": "Binary path", + "description": "Path to the `iw` binary.", + "type": "string", + "default": "/usr/sbin/iw" + }, + "timeout": { + "title": "Timeout", + "description": "Timeout for executing the binary, specified in seconds.", + "type": "number", + "minimum": 0.5, + "default": 2 + } + }, + "required": [ + "binary_path" + ], + "additionalProperties": false, + "patternProperties": { + "^name$": {} + } + }, + "uiSchema": { + "uiOptions": { + "fullPage": true + }, + "binary_path": { + "ui:help": "If an absolute path is provided, the collector will use it directly; otherwise, it will search for the binary in directories specified in the PATH environment variable." + }, + "timeout": { + "ui:help": "Accepts decimals for precise control (e.g., type 1.5 for 1.5 seconds)." + } + } +}
\ No newline at end of file diff --git a/src/go/plugin/go.d/modules/ap/exec.go b/src/go/plugin/go.d/modules/ap/exec.go new file mode 100644 index 00000000..8c25f677 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/exec.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ap + +import ( + "context" + "fmt" + "os/exec" + "time" + + "github.com/netdata/netdata/go/plugins/logger" +) + +func newIwExec(binPath string, timeout time.Duration) *iwCliExec { + return &iwCliExec{ + binPath: binPath, + timeout: timeout, + } +} + +type iwCliExec struct { + *logger.Logger + + binPath string + timeout time.Duration +} + +func (e *iwCliExec) devices() ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), e.timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, e.binPath, "dev") + e.Debugf("executing '%s'", cmd) + + bs, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("error on '%s': %v", cmd, err) + } + + return bs, nil +} + +func (e *iwCliExec) stationStatistics(ifaceName string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), e.timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, e.binPath, ifaceName, "station", "dump") + e.Debugf("executing '%s'", cmd) + + bs, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("error on '%s': %v", cmd, err) + } + + return bs, nil +} diff --git a/src/go/plugin/go.d/modules/ap/init.go b/src/go/plugin/go.d/modules/ap/init.go new file mode 100644 index 00000000..6031f6ca --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/init.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ap + +import ( + "errors" + "os" + "os/exec" + "strings" +) + +func (a *AP) validateConfig() error { + if a.BinaryPath == "" { + return errors.New("no iw binary path specified") + } + return nil +} + +func (a *AP) initIwExec() (iwBinary, error) { + binPath := a.BinaryPath + + if !strings.HasPrefix(binPath, "/") { + path, err := exec.LookPath(binPath) + if err != nil { + return nil, err + } + binPath = path + } + + if _, err := os.Stat(binPath); err != nil { + return nil, err + } + + iw := newIwExec(binPath, a.Timeout.Duration()) + + return iw, nil +} diff --git a/src/go/plugin/go.d/modules/ap/integrations/access_points.md b/src/go/plugin/go.d/modules/ap/integrations/access_points.md new file mode 100644 index 00000000..fa2134ed --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/integrations/access_points.md @@ -0,0 +1,202 @@ +<!--startmeta +custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/modules/ap/README.md" +meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/modules/ap/metadata.yaml" +sidebar_label: "Access Points" +learn_status: "Published" +learn_rel_path: "Collecting Metrics/Linux Systems/Network" +most_popular: False +message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE" +endmeta--> + +# Access Points + + +<img src="https://netdata.cloud/img/network-wired.svg" width="150"/> + + +Plugin: go.d.plugin +Module: ap + +<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" /> + +## Overview + +This collector monitors various wireless access point metrics like connected clients, bandwidth, packets, transmit issues, signal strength, and bitrate for each device and its associated SSID. + + +This tool uses the `iw` command-line utility to discover nearby access points. It starts by running `iw dev`, which provides information about all wireless interfaces. Then, for each interface identified as an access point (type AP), the `iw INTERFACE station dump` command is executed to gather relevant metrics. + + +This collector is only supported on the following platforms: + +- Linux + +This collector only supports collecting metrics from a single instance of this integration. + + +### Default Behavior + +#### Auto-Detection + +The plugin is able to auto-detect any access points on your Linux machine. + +#### Limits + +The default configuration for this integration does not impose any limits on data collection. + +#### Performance Impact + +The default configuration for this integration is not expected to impose a significant performance impact on the system. + + +## Metrics + +Metrics grouped by *scope*. + +The scope defines the instance that the metric belongs to. An instance is uniquely identified by a set of labels. + + + +### Per wireless device + +These metrics refer to the entire monitored application. + +Labels: + +| Label | Description | +|:-----------|:----------------| +| device | Wireless interface name | +| ssid | SSID | + +Metrics: + +| Metric | Dimensions | Unit | +|:------|:----------|:----| +| ap.clients | clients | clients | +| ap.net | received, sent | kilobits/s | +| ap.packets | received, sent | packets/s | +| ap.issues | retries, failures | issues/s | +| ap.signal | average signal | dBm | +| ap.bitrate | receive, transmit | Mbps | + + + +## Alerts + +There are no alerts configured by default for this integration. + + +## Setup + +### Prerequisites + +#### `iw` utility. + +Make sure the `iw` utility is installed. + + +### Configuration + +#### File + +The configuration file name for this integration is `go.d/ap.conf`. + + +You can edit the configuration file using the `edit-config` script from the +Netdata [config directory](/docs/netdata-agent/configuration/README.md#the-netdata-config-directory). + +```bash +cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata +sudo ./edit-config go.d/ap.conf +``` +#### Options + +The following options can be defined globally: update_every. + + +<details open><summary>Config options</summary> + +| Name | Description | Default | Required | +|:----|:-----------|:-------|:--------:| +| update_every | Data collection frequency. | 10 | no | +| binary_path | Path to the `iw` binary. If an absolute path is provided, the collector will use it directly; otherwise, it will search for the binary in directories specified in the PATH environment variable. | /usr/sbin/iw | yes | +| timeout | Timeout for executing the binary, specified in seconds. | 2 | no | + +</details> + +#### Examples + +##### Custom binary path + +The executable is not in the directories specified in the PATH environment variable. + +```yaml +jobs: + - name: custom_iw + binary_path: /usr/local/sbin/iw + +``` + + +## Troubleshooting + +### Debug Mode + +**Important**: Debug mode is not supported for data collection jobs created via the UI using the Dyncfg feature. + +To troubleshoot issues with the `ap` collector, run the `go.d.plugin` with the debug option enabled. The output +should give you clues as to why the collector isn't working. + +- Navigate to the `plugins.d` directory, usually at `/usr/libexec/netdata/plugins.d/`. If that's not the case on + your system, open `netdata.conf` and look for the `plugins` setting under `[directories]`. + + ```bash + cd /usr/libexec/netdata/plugins.d/ + ``` + +- Switch to the `netdata` user. + + ```bash + sudo -u netdata -s + ``` + +- Run the `go.d.plugin` to debug the collector: + + ```bash + ./go.d.plugin -d -m ap + ``` + +### Getting Logs + +If you're encountering problems with the `ap` collector, follow these steps to retrieve logs and identify potential issues: + +- **Run the command** specific to your system (systemd, non-systemd, or Docker container). +- **Examine the output** for any warnings or error messages that might indicate issues. These messages should provide clues about the root cause of the problem. + +#### System with systemd + +Use the following command to view logs generated since the last Netdata service restart: + +```bash +journalctl _SYSTEMD_INVOCATION_ID="$(systemctl show --value --property=InvocationID netdata)" --namespace=netdata --grep ap +``` + +#### System without systemd + +Locate the collector log file, typically at `/var/log/netdata/collector.log`, and use `grep` to filter for collector's name: + +```bash +grep ap /var/log/netdata/collector.log +``` + +**Note**: This method shows logs from all restarts. Focus on the **latest entries** for troubleshooting current issues. + +#### Docker Container + +If your Netdata runs in a Docker container named "netdata" (replace if different), use this command: + +```bash +docker logs netdata 2>&1 | grep ap +``` + + diff --git a/src/go/plugin/go.d/modules/ap/metadata.yaml b/src/go/plugin/go.d/modules/ap/metadata.yaml new file mode 100644 index 00000000..848684d3 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/metadata.yaml @@ -0,0 +1,141 @@ +plugin_name: go.d.plugin +modules: + - meta: + plugin_name: go.d.plugin + module_name: ap + monitored_instance: + name: Access Points + link: "" + categories: + - data-collection.linux-systems.network-metrics + icon_filename: "network-wired.svg" + related_resources: + integrations: + list: [] + info_provided_to_referring_integrations: + description: "" + keywords: + - ap + - access + - point + - wireless + - network + most_popular: false + overview: + data_collection: + metrics_description: | + This collector monitors various wireless access point metrics like connected clients, bandwidth, packets, transmit issues, signal strength, and bitrate for each device and its associated SSID. + method_description: > + This tool uses the `iw` command-line utility to discover nearby access points. + It starts by running `iw dev`, which provides information about all wireless interfaces. + Then, for each interface identified as an access point (type AP), the `iw INTERFACE station dump` command is executed to gather relevant metrics. + supported_platforms: + include: [Linux] + exclude: [] + multi_instance: false + additional_permissions: + description: "" + default_behavior: + auto_detection: + description: "The plugin is able to auto-detect any access points on your Linux machine." + limits: + description: "" + performance_impact: + description: "" + setup: + prerequisites: + list: + - title: "`iw` utility." + description: "Make sure the `iw` utility is installed." + configuration: + file: + name: go.d/ap.conf + options: + description: | + The following options can be defined globally: update_every. + folding: + title: Config options + enabled: true + list: + - name: update_every + description: Data collection frequency. + default_value: 10 + required: false + - name: binary_path + description: Path to the `iw` binary. If an absolute path is provided, the collector will use it directly; otherwise, it will search for the binary in directories specified in the PATH environment variable. + default_value: /usr/sbin/iw + required: true + - name: timeout + description: Timeout for executing the binary, specified in seconds. + default_value: 2 + required: false + examples: + folding: + title: "" + enabled: false + list: + - name: Custom binary path + description: The executable is not in the directories specified in the PATH environment variable. + config: | + jobs: + - name: custom_iw + binary_path: /usr/local/sbin/iw + troubleshooting: + problems: + list: [] + alerts: [] + metrics: + folding: + title: Metrics + enabled: false + description: "" + availability: [] + scopes: + - name: wireless device + description: "These metrics refer to the entire monitored application." + labels: + - name: device + description: Wireless interface name + - name: ssid + description: SSID + metrics: + - name: ap.clients + description: Connected clients + unit: "clients" + chart_type: line + dimensions: + - name: clients + - name: ap.net + description: Bandwidth + unit: "kilobits/s" + chart_type: area + dimensions: + - name: received + - name: sent + - name: ap.packets + description: Packets + unit: "packets/s" + chart_type: line + dimensions: + - name: received + - name: sent + - name: ap.issues + description: Transmit Issues + unit: "issues/s" + chart_type: line + dimensions: + - name: retries + - name: failures + - name: ap.signal + description: Average Signal + unit: "dBm" + chart_type: line + dimensions: + - name: average signal + - name: ap.bitrate + description: Bitrate + unit: "Mbps" + chart_type: line + dimensions: + - name: receive + - name: transmit diff --git a/src/go/plugin/go.d/modules/ap/testdata/config.json b/src/go/plugin/go.d/modules/ap/testdata/config.json new file mode 100644 index 00000000..09571319 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/testdata/config.json @@ -0,0 +1,5 @@ +{ + "update_every": 123, + "timeout": 123.123, + "binary_path": "ok" +} diff --git a/src/go/plugin/go.d/modules/ap/testdata/config.yaml b/src/go/plugin/go.d/modules/ap/testdata/config.yaml new file mode 100644 index 00000000..baf3bcd0 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/testdata/config.yaml @@ -0,0 +1,3 @@ +update_every: 123 +timeout: 123.123 +binary_path: "ok" diff --git a/src/go/plugin/go.d/modules/ap/testdata/iw_dev_ap.txt b/src/go/plugin/go.d/modules/ap/testdata/iw_dev_ap.txt new file mode 100644 index 00000000..0b1e4077 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/testdata/iw_dev_ap.txt @@ -0,0 +1,25 @@ +phy#0 + Interface wlp1s0 + ifindex 2 + wdev 0x1 + addr 28:cd:c4:b8:63:cb + ssid testing + type AP + channel 1 (2412 MHz), width: 20 MHz, center1: 2412 MHz + txpower 20.00 dBm + multicast TXQ: + qsz-byt qsz-pkt flows drops marks overlmt hashcol tx-bytes tx-packets + 0 0 2 0 0 0 0 16447 226 + +phy#1 + Interface wlp1s1 + ifindex 3 + wdev 0x1 + addr 28:cd:c4:b8:63:cc + ssid testing + type AP + channel 1 (2412 MHz), width: 20 MHz, center1: 2412 MHz + txpower 20.00 dBm + multicast TXQ: + qsz-byt qsz-pkt flows drops marks overlmt hashcol tx-bytes tx-packets + 0 0 2 0 0 0 0 16447 226 diff --git a/src/go/plugin/go.d/modules/ap/testdata/iw_dev_managed.txt b/src/go/plugin/go.d/modules/ap/testdata/iw_dev_managed.txt new file mode 100644 index 00000000..5bb09a85 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/testdata/iw_dev_managed.txt @@ -0,0 +1,11 @@ +phy#0 + Interface wlp1s0 + ifindex 2 + wdev 0x1 + addr 28:cd:c4:b8:63:cb + type managed + channel 4 (2427 MHz), width: 20 MHz, center1: 2427 MHz + txpower 20.00 dBm + multicast TXQ: + qsz-byt qsz-pkt flows drops marks overlmt hashcol tx-bytes tx-packets + 0 0 0 0 0 0 0 0 0 diff --git a/src/go/plugin/go.d/modules/ap/testdata/station_dump.txt b/src/go/plugin/go.d/modules/ap/testdata/station_dump.txt new file mode 100644 index 00000000..683a6818 --- /dev/null +++ b/src/go/plugin/go.d/modules/ap/testdata/station_dump.txt @@ -0,0 +1,58 @@ +Station 7e:0d:a5:a6:91:2b (on wlp1s0) + inactive time: 58264 ms + rx bytes: 89675 + rx packets: 2446 + tx bytes: 6918 + tx packets: 30 + tx retries: 1 + tx failed: 1 + rx drop misc: 0 + signal: -44 [-51, -44] dBm + signal avg: -38 [-39, -39] dBm + tx bitrate: 65.0 MBit/s MCS 7 + tx duration: 0 us + rx bitrate: 130.0 MBit/s MCS 15 + rx duration: 0 us + authorized: yes + authenticated: yes + associated: yes + preamble: short + WMM/WME: yes + MFP: no + TDLS peer: no + DTIM period: 2 + beacon interval:100 + short slot time:yes + connected time: 796 seconds + associated at [boottime]: 12650.576s + associated at: 1720705279930 ms + current time: 1720706075344 ms +Station fa:50:db:c1:1c:18 (on wlp1s0) + inactive time: 93 ms + rx bytes: 5442 + rx packets: 85 + tx bytes: 1352 + tx packets: 8 + tx retries: 0 + tx failed: 0 + rx drop misc: 0 + signal: -31 [-31, -39] dBm + signal avg: -30 [-30, -38] dBm + tx bitrate: 65.0 MBit/s MCS 7 + tx duration: 0 us + rx bitrate: 1.0 MBit/s + rx duration: 0 us + authorized: yes + authenticated: yes + associated: yes + preamble: short + WMM/WME: yes + MFP: no + TDLS peer: no + DTIM period: 2 + beacon interval:100 + short slot time:yes + connected time: 6 seconds + associated at [boottime]: 13440.167s + associated at: 1720706069520 ms + current time: 1720706075344 ms |