diff --git a/collector.go b/collector.go index 55804a5..a6ac62c 100644 --- a/collector.go +++ b/collector.go @@ -12,11 +12,11 @@ import ( // Collector collects metrics from a remote ConnectBox router. type Collector struct { - targets map[string]MetricsClient + targets map[string]ConnectBox } // NewCollector creates new collector. -func NewCollector(targets map[string]MetricsClient) *Collector { +func NewCollector(targets map[string]ConnectBox) *Collector { return &Collector{targets: targets} } @@ -59,7 +59,7 @@ func (c *Collector) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (c *Collector) collectCMSSystemInfo( ctx context.Context, reg *prometheus.Registry, - client MetricsClient, + client ConnectBox, ) { cmDocsisModeGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "connect_box_cm_docsis_mode", @@ -94,7 +94,7 @@ func (c *Collector) collectCMSSystemInfo( reg.MustRegister(cmNetworkAccessGauge) var data CMSystemInfo - err := client.GetMetrics(ctx, FnCMSystemInfo, &data) + err := client.Get(ctx, FnCMSystemInfo, &data) if err != nil { log.Printf("Failed to get CMSSystemInfo: %v", err) return @@ -115,7 +115,7 @@ func (c *Collector) collectCMSSystemInfo( func (c *Collector) collectLANUserTable( ctx context.Context, reg *prometheus.Registry, - client MetricsClient, + client ConnectBox, ) { clientGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "connect_box_lan_client", @@ -131,7 +131,7 @@ func (c *Collector) collectLANUserTable( reg.MustRegister(clientGauge) var data LANUserTable - err := client.GetMetrics(ctx, FnLANUserTable, &data) + err := client.Get(ctx, FnLANUserTable, &data) if err != nil { log.Printf("Failed to get LANUserTable: %v", err) return @@ -160,7 +160,7 @@ func (c *Collector) collectLANUserTable( func (c *Collector) collectCMState( ctx context.Context, reg *prometheus.Registry, - client MetricsClient, + client ConnectBox, ) { tunnerTemperatureGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "connect_box_tunner_temperature", @@ -190,7 +190,7 @@ func (c *Collector) collectCMState( reg.MustRegister(wanIPv6AddrGauge) var data CMState - err := client.GetMetrics(ctx, FnCMState, &data) + err := client.Get(ctx, FnCMState, &data) if err != nil { log.Printf("Failed to get CMState: %v", err) return diff --git a/collector_test.go b/collector_test.go index 2840965..ae06567 100644 --- a/collector_test.go +++ b/collector_test.go @@ -11,11 +11,12 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/tetafro/connectbox" "go.uber.org/mock/gomock" ) func TestNewCollector(t *testing.T) { - c := NewCollector(map[string]MetricsClient{"test": &ConnectBox{}}) + c := NewCollector(map[string]ConnectBox{"test": &connectbox.Client{}}) require.Len(t, c.targets, 1) } @@ -25,12 +26,12 @@ func TestCollector_ServeHTTP(t *testing.T) { t.Run("success", func(t *testing.T) { ctrl := gomock.NewController(t) - metrics := NewMockMetricsClient(ctrl) + metrics := NewMockConnectBox(ctrl) metrics.EXPECT().Login(gomock.Any()).Return(nil) var cmSystemInfoData CMSystemInfo - metrics.EXPECT().GetMetrics( + metrics.EXPECT().Get( gomock.Any(), FnCMSystemInfo, &cmSystemInfoData, ).Do(func(ctx context.Context, fn string, out any) error { data := out.(*CMSystemInfo) @@ -44,7 +45,7 @@ func TestCollector_ServeHTTP(t *testing.T) { }) var lanUserTableData LANUserTable - metrics.EXPECT().GetMetrics( + metrics.EXPECT().Get( gomock.Any(), FnLANUserTable, &lanUserTableData, ).Do(func(ctx context.Context, fn string, out any) error { data := out.(*LANUserTable) @@ -74,7 +75,7 @@ func TestCollector_ServeHTTP(t *testing.T) { }) var cmStateData CMState - metrics.EXPECT().GetMetrics( + metrics.EXPECT().Get( gomock.Any(), FnCMState, &cmStateData, ).Do(func(ctx context.Context, fn string, out any) error { data := out.(*CMState) @@ -89,7 +90,7 @@ func TestCollector_ServeHTTP(t *testing.T) { metrics.EXPECT().Logout(gomock.Any()).Return(nil) col := &Collector{ - targets: map[string]MetricsClient{ + targets: map[string]ConnectBox{ "127.0.0.1": metrics, }, } @@ -152,7 +153,7 @@ func TestCollector_ServeHTTP(t *testing.T) { t.Run("no target", func(t *testing.T) { col := &Collector{ - targets: map[string]MetricsClient{}, + targets: map[string]ConnectBox{}, } req, err := http.NewRequest(http.MethodGet, "/probe?target=127.0.0.1", nil) @@ -167,11 +168,11 @@ func TestCollector_ServeHTTP(t *testing.T) { t.Run("failed to login", func(t *testing.T) { ctrl := gomock.NewController(t) - metrics := NewMockMetricsClient(ctrl) + metrics := NewMockConnectBox(ctrl) metrics.EXPECT().Login(gomock.Any()).Return(errors.New("fail")) col := &Collector{ - targets: map[string]MetricsClient{ + targets: map[string]ConnectBox{ "127.0.0.1": metrics, }, } @@ -188,19 +189,19 @@ func TestCollector_ServeHTTP(t *testing.T) { t.Run("failed to get metrics", func(t *testing.T) { ctrl := gomock.NewController(t) - metrics := NewMockMetricsClient(ctrl) + metrics := NewMockConnectBox(ctrl) metrics.EXPECT().Login(gomock.Any()).Return(nil) - metrics.EXPECT().GetMetrics(gomock.Any(), FnCMSystemInfo, gomock.Any()). + metrics.EXPECT().Get(gomock.Any(), FnCMSystemInfo, gomock.Any()). Return(errors.New("fail")) - metrics.EXPECT().GetMetrics(gomock.Any(), FnLANUserTable, gomock.Any()). + metrics.EXPECT().Get(gomock.Any(), FnLANUserTable, gomock.Any()). Return(errors.New("fail")) - metrics.EXPECT().GetMetrics(gomock.Any(), FnCMState, gomock.Any()). + metrics.EXPECT().Get(gomock.Any(), FnCMState, gomock.Any()). Return(errors.New("fail")) metrics.EXPECT().Logout(gomock.Any()).Return(nil) col := &Collector{ - targets: map[string]MetricsClient{ + targets: map[string]ConnectBox{ "127.0.0.1": metrics, }, } @@ -218,19 +219,19 @@ func TestCollector_ServeHTTP(t *testing.T) { t.Run("failed to get metrics and to logout", func(t *testing.T) { ctrl := gomock.NewController(t) - metrics := NewMockMetricsClient(ctrl) + metrics := NewMockConnectBox(ctrl) metrics.EXPECT().Login(gomock.Any()).Return(nil) - metrics.EXPECT().GetMetrics(gomock.Any(), FnCMSystemInfo, gomock.Any()). + metrics.EXPECT().Get(gomock.Any(), FnCMSystemInfo, gomock.Any()). Return(errors.New("fail")) - metrics.EXPECT().GetMetrics(gomock.Any(), FnLANUserTable, gomock.Any()). + metrics.EXPECT().Get(gomock.Any(), FnLANUserTable, gomock.Any()). Return(errors.New("fail")) - metrics.EXPECT().GetMetrics(gomock.Any(), FnCMState, gomock.Any()). + metrics.EXPECT().Get(gomock.Any(), FnCMState, gomock.Any()). Return(errors.New("fail")) metrics.EXPECT().Logout(gomock.Any()).Return(errors.New("fail")) col := &Collector{ - targets: map[string]MetricsClient{ + targets: map[string]ConnectBox{ "127.0.0.1": metrics, }, } diff --git a/connectbox.go b/connectbox.go index 7012e85..92d83e3 100644 --- a/connectbox.go +++ b/connectbox.go @@ -1,244 +1,11 @@ package main -import ( - "context" - "crypto/sha256" - "encoding/xml" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" -) +import "context" -// List of cookie names. -const ( - sessionTokenName = "sessionToken" - sessionIDName = "SID" -) - -// List of XML API endpoints. -const ( - xmlGetter = "/xml/getter.xml" - xmlSetter = "/xml/setter.xml" -) - -// MetricsClient is a general purpose client, that gets metrics from +// ConnectBox is a ConnectBox router client, that gets metrics from // a remote source. -type MetricsClient interface { +type ConnectBox interface { Login(ctx context.Context) error Logout(ctx context.Context) error - GetMetrics(ctx context.Context, fn string, out any) error -} - -// ConnectBox is a client for ConnectBox HTTP API. -type ConnectBox struct { - http *http.Client - addr string - token string - username string - password string -} - -// NewConnectBox creates new ConnectBox client. -func NewConnectBox(addr, username, password string) (*ConnectBox, error) { - if !strings.HasPrefix(addr, "http") { - addr = "http://" + addr - } - - _, err := url.Parse(addr) - if err != nil { - return nil, fmt.Errorf("invalid address: %s", addr) - } - - z := ConnectBox{ - addr: strings.TrimSuffix(addr, "/"), - username: username, - password: hashPassword(password), - } - - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("init cookie jar: %w", err) - } - z.http = &http.Client{ - Jar: jar, - // Don't follow redirects - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - return &z, nil -} - -// Login gets auth token and session ID for further interactions -// with ConnectBox. -func (z *ConnectBox) Login(ctx context.Context) error { - // Send a request just to set initial token - _, err := z.get(ctx, "/common_page/login.html") - if err != nil { - return fmt.Errorf("get initial token: %w", err) - } - - args := xmlArgs{ - {"Username", z.username}, - {"Password", z.password}, - } - resp, err := z.xmlRequest(ctx, xmlSetter, FnLogin, args) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - if !strings.HasPrefix(resp, "success") { - return fmt.Errorf("invalid response: %s", resp) - } - - var sid string - for _, item := range strings.Split(resp, ";") { - kv := strings.Split(item, "=") - if len(kv) != 2 { - continue - } - if kv[0] == "SID" { - sid = kv[1] - break - } - } - if sid == "" { - return fmt.Errorf("no SID in response from router: %s", resp) - } - z.setCookie(sessionIDName, sid) - - return nil -} - -// Logout closes current session. This is important because ConnectBox -// is a single user device. -func (z *ConnectBox) Logout(ctx context.Context) error { - _, err := z.xmlRequest(ctx, xmlSetter, FnLogout, xmlArgs{}) - return err -} - -// GetMetrics reads metrics using XML RPC function with `fn` code, and -// unmarshals it to `out`. -func (z *ConnectBox) GetMetrics(ctx context.Context, fn string, out any) error { - resp, err := z.xmlRequest(ctx, xmlGetter, fn, xmlArgs{}) - if err != nil { - return fmt.Errorf("get response: %w", err) - } - if err := xml.Unmarshal([]byte(resp), out); err != nil { - return fmt.Errorf("unmarshal response: %w", err) - } - return nil -} - -func (z *ConnectBox) getCookie(name string) string { - u, _ := url.Parse(z.addr) - for _, cookie := range z.http.Jar.Cookies(u) { - if cookie.Name == name { - return cookie.Value - } - } - return "" -} - -func (z *ConnectBox) setCookie(name, value string) { - u, _ := url.Parse(z.addr) - z.http.Jar.SetCookies(u, []*http.Cookie{{Name: name, Value: value}}) -} - -func (z *ConnectBox) xmlRequest( - ctx context.Context, - path string, - fn string, - args xmlArgs, -) (string, error) { - // Token and function must be first arguments - args = append( - xmlArgs{{"token", z.token}, {"fun", fn}}, - args..., - ) - return z.post(ctx, path, args.Encode()) -} - -func (z *ConnectBox) get(ctx context.Context, path string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, z.addr+path, nil) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - - resp, err := z.http.Do(req) - if err != nil { - return "", fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("invalid response status: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read body: %w", err) - } - - // Token must be updated after each request - z.token = z.getCookie(sessionTokenName) - - return string(body), nil -} - -func (z *ConnectBox) post(ctx context.Context, path, data string) (string, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - z.addr+path, - strings.NewReader(data), - ) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := z.http.Do(req) - if err != nil { - return "", fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("invalid response status: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read body: %w", err) - } - - // Token must be updated after each request - z.token = z.getCookie(sessionTokenName) - - return string(body), nil -} - -func hashPassword(p string) string { - h := sha256.New() - h.Write([]byte(p)) - sum := h.Sum(nil) - return fmt.Sprintf("%x", sum) -} - -// xmlArgs is a helper type for ConnectBox XML RPC, which requires ordered -// url-encoded requests. For example, `token` field must be always at the -// first place. -type xmlArgs [][2]string - -// Encode returns url-encoded string with all keys and values. -func (args xmlArgs) Encode() (s string) { - for _, arg := range args { - if len(s) > 0 { - s += "&" - } - s += url.QueryEscape(arg[0]) + "=" + url.QueryEscape(arg[1]) - } - return s + Get(ctx context.Context, fn string, out any) error } diff --git a/connectbox_mock.go b/connectbox_mock.go index 44ec656..37607cc 100644 --- a/connectbox_mock.go +++ b/connectbox_mock.go @@ -11,45 +11,45 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockMetricsClient is a mock of MetricsClient interface. -type MockMetricsClient struct { +// MockConnectBox is a mock of ConnectBox interface. +type MockConnectBox struct { ctrl *gomock.Controller - recorder *MockMetricsClientMockRecorder + recorder *MockConnectBoxMockRecorder } -// MockMetricsClientMockRecorder is the mock recorder for MockMetricsClient. -type MockMetricsClientMockRecorder struct { - mock *MockMetricsClient +// MockConnectBoxMockRecorder is the mock recorder for MockConnectBox. +type MockConnectBoxMockRecorder struct { + mock *MockConnectBox } -// NewMockMetricsClient creates a new mock instance. -func NewMockMetricsClient(ctrl *gomock.Controller) *MockMetricsClient { - mock := &MockMetricsClient{ctrl: ctrl} - mock.recorder = &MockMetricsClientMockRecorder{mock} +// NewMockConnectBox creates a new mock instance. +func NewMockConnectBox(ctrl *gomock.Controller) *MockConnectBox { + mock := &MockConnectBox{ctrl: ctrl} + mock.recorder = &MockConnectBoxMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockMetricsClient) EXPECT() *MockMetricsClientMockRecorder { +func (m *MockConnectBox) EXPECT() *MockConnectBoxMockRecorder { return m.recorder } -// GetMetrics mocks base method. -func (m *MockMetricsClient) GetMetrics(ctx context.Context, fn string, out any) error { +// Get mocks base method. +func (m *MockConnectBox) Get(ctx context.Context, fn string, out any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMetrics", ctx, fn, out) + ret := m.ctrl.Call(m, "Get", ctx, fn, out) ret0, _ := ret[0].(error) return ret0 } -// GetMetrics indicates an expected call of GetMetrics. -func (mr *MockMetricsClientMockRecorder) GetMetrics(ctx, fn, out interface{}) *gomock.Call { +// Get indicates an expected call of Get. +func (mr *MockConnectBoxMockRecorder) Get(ctx, fn, out interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMetrics", reflect.TypeOf((*MockMetricsClient)(nil).GetMetrics), ctx, fn, out) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockConnectBox)(nil).Get), ctx, fn, out) } // Login mocks base method. -func (m *MockMetricsClient) Login(ctx context.Context) error { +func (m *MockConnectBox) Login(ctx context.Context) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Login", ctx) ret0, _ := ret[0].(error) @@ -57,13 +57,13 @@ func (m *MockMetricsClient) Login(ctx context.Context) error { } // Login indicates an expected call of Login. -func (mr *MockMetricsClientMockRecorder) Login(ctx interface{}) *gomock.Call { +func (mr *MockConnectBoxMockRecorder) Login(ctx interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Login", reflect.TypeOf((*MockMetricsClient)(nil).Login), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Login", reflect.TypeOf((*MockConnectBox)(nil).Login), ctx) } // Logout mocks base method. -func (m *MockMetricsClient) Logout(ctx context.Context) error { +func (m *MockConnectBox) Logout(ctx context.Context) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Logout", ctx) ret0, _ := ret[0].(error) @@ -71,7 +71,7 @@ func (m *MockMetricsClient) Logout(ctx context.Context) error { } // Logout indicates an expected call of Logout. -func (mr *MockMetricsClientMockRecorder) Logout(ctx interface{}) *gomock.Call { +func (mr *MockConnectBoxMockRecorder) Logout(ctx interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockMetricsClient)(nil).Logout), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockConnectBox)(nil).Logout), ctx) } diff --git a/connectbox_test.go b/connectbox_test.go deleted file mode 100644 index 9bd1f26..0000000 --- a/connectbox_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package main - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewConnectBox(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - client, err := NewConnectBox("127.0.0.1:8080", "bob", "qwerty") - require.NoError(t, err) - require.Equal(t, "http://127.0.0.1:8080", client.addr) - require.Equal(t, "bob", client.username) - require.Equal(t, - "65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5", - client.password) - }) - - t.Run("invalid address", func(t *testing.T) { - _, err := NewConnectBox("hello, world!", "bob", "qwerty") - require.ErrorContains(t, err, "invalid address") - }) -} - -func TestConnectBox_Logout(t *testing.T) { - ctx := context.Background() - - connectbox := testConnectBox{ - status: http.StatusOK, - } - server := httptest.NewServer(&connectbox) - defer server.Close() - - client, err := NewConnectBox(server.URL, "bob", "qwerty") - require.NoError(t, err) - client.token = "abc" - - err = client.Logout(ctx) - require.NoError(t, err) - - want := "token=abc&fun=16" - require.Equal(t, want, connectbox.req) -} - -func TestConnectBox_GetMetrics(t *testing.T) { - t.Run("valid response", func(t *testing.T) { - ctx := context.Background() - - connectbox := testConnectBox{ - status: http.StatusOK, - resp: `50`, - } - server := httptest.NewServer(&connectbox) - defer server.Close() - - client, err := NewConnectBox(server.URL, "bob", "qwerty") - require.NoError(t, err) - client.token = "abc" - - var data struct { - Field string `xml:"field"` - } - err = client.GetMetrics(ctx, "100", &data) - require.NoError(t, err) - require.Equal(t, "token=abc&fun=100", connectbox.req) - require.Equal(t, "50", data.Field) - }) - - t.Run("invalid response", func(t *testing.T) { - ctx := context.Background() - - connectbox := testConnectBox{ - status: http.StatusOK, - resp: `