123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530 |
- /*
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
- */
- package logicmonclient
- // https://www.logicmonitor.com/support/rest-api-developers-guide/overview/using-logicmonitors-rest-api
- // https://www.logicmonitor.com/swagger-ui-master/api-v3/lm-sdkv3-docs.html
- import (
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "log"
- "net/http"
- "net/url"
- "strconv"
- "time"
- dp "idio.link/go/depager"
- )
- var Debug = false
- // LogicMonitor REST API v3 supports maximum size of 1000
- // records. Even if you specify a number above 1000, the
- // response will always return 1000 records.
- const MaxPageSize = 1000
- func debug(format string, msg ...any) {
- if Debug {
- log.Printf("DEBUG: "+format, msg...)
- }
- }
- type LMv1TokenBasedCredential struct {
- AccessId string
- AccessKey string
- }
- func NewLMv1TokenBasedCredential(
- accessId,
- accessKey string,
- ) *LMv1TokenBasedCredential {
- return &LMv1TokenBasedCredential{accessId, accessKey}
- }
- func generateAuthorization(
- timestamp,
- httpMethod,
- resource string,
- requestBody []byte,
- cred *LMv1TokenBasedCredential,
- ) string {
- /* From https://www.logicmonitor.com/support/rest-api-authentication:
- signature =
- base64(HMAC-SHA256(
- Access Key,
- HTTP VERB +
- TIMESTAMP (in epoch milliseconds) +
- POST/PUT DATA (if any) +
- RESOURCE PATH,
- ))
- Authorization: LMv1 AccessId:Signature:Timestamp
- */
- mac := hmac.New(sha256.New, []byte(cred.AccessKey))
- mac.Write([]byte(httpMethod))
- mac.Write([]byte(timestamp))
- mac.Write(requestBody)
- mac.Write([]byte(resource))
- sum := hex.EncodeToString(mac.Sum(nil))
- sig := base64.StdEncoding.EncodeToString([]byte(sum))
- auth := fmt.Sprintf(
- "LMv1 %s:%s:%s",
- cred.AccessId,
- sig,
- timestamp,
- )
- debug("authorization: %v", auth)
- return auth
- }
- func NewLMClient(
- accountName string,
- ctx context.Context,
- httpClient *http.Client,
- credential *LMv1TokenBasedCredential,
- ) *LMClient {
- return &LMClient{
- accountName: accountName,
- domain: "logicmonitor.com",
- basePath: "/santaba/rest",
- ctx: ctx,
- httpClient: httpClient,
- credential: credential,
- retries: 3,
- retryWait: 10 * time.Second,
- }
- }
- type LMClient struct {
- accountName string
- domain string
- basePath string
- ctx context.Context
- httpClient *http.Client
- credential *LMv1TokenBasedCredential
- blockReqsBefore time.Time
- retries int
- retryWait time.Duration
- }
- func (c *LMClient) request(
- method string,
- uri *url.URL,
- body io.Reader,
- ) (respBody []byte, err error) {
- uri.Scheme = "https"
- uri.Host = c.accountName + "." + c.domain
- resource := uri.EscapedPath() // save for authentication
- path, err := url.JoinPath(c.basePath, uri.EscapedPath())
- if err != nil {
- panic(fmt.Sprintf("BUG: lmclient: request: failed to join path: %s + %s", c.basePath, uri.RequestURI()))
- }
- uri.Path = path
- debug("request %s", uri)
- req, err :=
- http.NewRequestWithContext(
- c.ctx,
- method,
- uri.String(),
- body,
- )
- if err != nil {
- err = fmt.Errorf("request %v: %v", uri, err)
- return
- }
- var reqBody []byte
- if req.Body != nil {
- reqBody, err = io.ReadAll(req.Body)
- if err != nil {
- err = fmt.Errorf("request %v: failed to read request body: %v", uri, err)
- return
- }
- }
- // Only support API version 3
- req.Header.Set("X-version", "3")
- req.Header.Set("User-Agent", "logicmonclient")
- /* From https://www.logicmonitor.com/support/rest-api-authentication:
- > When LogicMonitor servers receive an API request, they
- > ensure the specified timestamp is within 30 minutes of
- > the current time.
- In the worst case, the below logic will fail long before
- the timestamp disparity reaches 30 minutes.
- */
- timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
- auth := generateAuthorization(
- timestamp,
- req.Method,
- resource,
- reqBody,
- c.credential,
- )
- req.Header.Set("Authorization", auth)
- // retry when server cannot handle request
- var resp *http.Response
- for i := 0; i < c.retries+1; i++ {
- resp, err = c.httpClient.Do(req)
- if err != nil {
- err = fmt.Errorf("request %v: %v", uri, err)
- return
- }
- if resp.StatusCode != http.StatusTooManyRequests {
- break
- }
- // Offer an async option in future, but this is
- // acceptable, for now.
- time.Sleep(c.retryWait / time.Millisecond)
- }
- defer resp.Body.Close()
- respBody, err = io.ReadAll(resp.Body)
- if err != nil {
- err = fmt.Errorf("request %v: failed to read response body: %v", uri, err)
- return
- }
- // Throttle requests
- // See https://www.logicmonitor.com/support/rest-api-developers-guide/overview/rate-limiting
- remStr := resp.Header.Get("X-Rate-Limit-Remaining")
- winStr := resp.Header.Get("X-Rate-Limit-Window")
- remaining, err := strconv.ParseInt(remStr, 10, 64)
- if remStr != "" && err != nil {
- err = fmt.Errorf("request %v: failed to parse header X-Rate-Limit-Remaining: %v", uri, err)
- return
- }
- window, err := time.ParseDuration(winStr + "s")
- if winStr != "" && err != nil {
- err = fmt.Errorf("request %v: failed to parse header X-Rate-Limit-Window: %v", uri, err)
- return
- }
- if remaining != 0 && window != 0 {
- // We postpone subsequent requests by at least win/rem
- msDelay := int64(window/time.Millisecond) / remaining
- delay := time.Duration(msDelay) * time.Millisecond
- c.blockReqsBefore = time.Now().Add(delay)
- }
- // Success response
- if 200 <= resp.StatusCode && resp.StatusCode <= 299 {
- return
- }
- // Non-success HTTP status codes with error body
- //err, ok := httpStatusToError[resp.StatusCode]
- // Error JSON in LogicMonitor response
- lmerr := &LMError{}
- err = json.Unmarshal(respBody, lmerr)
- if err == nil {
- err = fmt.Errorf("request %v: %w", uri, lmerr)
- return
- }
- /* Non-standard HTTP status codes
- This may not be necessary, but I can't tell from the
- documentation alone, which claims they "use standard HTTP
- error codes to indicate the error in the response body."
- This may be removed at some point.
- */
- err, ok := lmStatusToError[resp.StatusCode]
- if ok {
- return
- }
- /* Non-success HTTP status codes without error body */
- err, ok = httpStatusToError[resp.StatusCode]
- if ok {
- return
- }
- panic(fmt.Sprintf("BUG: unexpected response: %v", string(respBody)))
- }
- func (c *LMClient) Get(resource url.URL) ([]byte, error) {
- return c.request(http.MethodGet, &resource, nil)
- }
- type pagedURI struct {
- uri *url.URL
- }
- func (u pagedURI) PageURI(limit, offset uint64) url.URL {
- if limit > MaxPageSize {
- limit = MaxPageSize
- }
- uri := *u.uri
- q := (&uri).Query()
- q.Add("offset", strconv.FormatUint(offset, 10))
- q.Add("size", strconv.FormatUint(limit, 10))
- (&uri).RawQuery = q.Encode()
- return uri
- }
- func NewLMAggregate[T any]() *LMAggregate[T] {
- return &LMAggregate[T]{Items: make([]T, 0, 64)}
- }
- type LMAggregate[T any] struct {
- Total int32 `json:"total"`
- Items []T `json:"items"`
- }
- func (a *LMAggregate[_]) Count() uint64 {
- return uint64(a.Total)
- }
- func (a *LMAggregate[T]) Elems() []T {
- return a.Items
- }
- func makeLMPager[T any](
- client *LMClient,
- resource *url.URL,
- ) dp.Pager[T] {
- return dp.NewPager(
- pagedURI{resource},
- client,
- MaxPageSize,
- func() dp.Page[T] {
- return NewLMAggregate[T]()
- },
- )
- }
- type LMGraphLine struct {
- Legend string `json:"legend"` // "192.0.2.1:0"
- Type string `json:"type"` // "Stack"
- Data []float32
- }
- type LMTopTalkersGraph struct {
- Type string `json:"type"` // "graph"
- Id int32 `json:"id"`
- Name string `json:"name"` // "total"
- Title string `json:"title"` // "Throughput"
- VerticalLabel string `json:"verticalLabel"`
- Rigid bool `json:"rigid"` // false
- Width int32 `json:"width"`
- Height int32 `json:"height"`
- DisplayPrio int32 `json:"displayPrio"`
- TimeScale string `json:"timeScale"`
- Base1024 bool `json:"base1024"`
- //MaxValue int32 `json:"maxValue"` may return NaN
- //MinValue int32 `json:"minValue"` may return NaN
- StartTime int64 `json:"startTime"`
- EndTime int64 `json:"endTime"`
- TimeZone string `json:"timeZone"` // "CDT"
- TimeZoneId string `json:"timeZoneId"` // America/Chicago
- StartTZOffset int32 `json:"startTZOffset"`
- EndTZOffset int32 `json:"endTZOffset"`
- Step int32 `json:"step"` // 0
- DsName string `json:"dsName"` // null
- XAxisName string `json:"xAxisName"` // "2023-08-08 06:30 to 2023-08-08 19:30 CDT"
- Base int32 `json:"base"` // 1000
- ExportFileName string `json:"exportFileName"` // "Adams2120-RTR-jds-2023-08-08_06:30_to_2023-08-08_19:30_CDT"
- Scopes []*struct { // probably don't need this
- Type string `json:"type"` // "device"
- Id int32 `json:"id"`
- } `json:"scopes"`
- // Instances []? not sure what this is
- Timestamps []int64 `json:"timestamps"`
- Lines []*LMGraphLine `json:"lines"`
- }
- type LMFlowDirection string
- const (
- LMFlowDirectionIngress LMFlowDirection = "ingress"
- LMFlowDirectionEgress LMFlowDirection = "egress"
- LMFlowDirectionBoth LMFlowDirection = "both"
- )
- type LMProtocol string
- const (
- LMProtocolGRE LMProtocol = "gre"
- )
- type LMTopCount int32
- const (
- LMTopCount10 LMTopCount = 10
- LMTopCount20 LMTopCount = 20
- LMTopCount50 LMTopCount = 50
- LMTopCount100 LMTopCount = 100
- )
- type LMIPVersion string
- const (
- LMIPVersion4 LMIPVersion = "ipv4"
- LMIPVersion6 LMIPVersion = "ipv6"
- LMIPVersionBoth LMIPVersion = "both"
- )
- type LMNetflowFilter struct {
- Direction LMFlowDirection `json:"direction"`
- IPVersion LMIPVersion `json:"ipVersion"`
- Protocol LMProtocol `json:"protocol"`
- Top LMTopCount `json:"top"`
- }
- func (f LMNetflowFilter) String() string {
- bytes, err := json.Marshal(f)
- if err != nil {
- panic(fmt.Sprintf("BUG: failed to marshal lmnetflowfilter: %v", err))
- }
- return string(bytes)
- }
- func (c *LMClient) GetTopTalkersGraph(
- id int32,
- start,
- end int64,
- netflowFilter LMNetflowFilter,
- ) (graph *LMTopTalkersGraph, err error) {
- resStr :=
- fmt.Sprintf("/device/devices/%d/topTalkersGraph", id)
- resource, err := url.Parse(resStr)
- if err != nil {
- panic(fmt.Sprintf("BUG: get top talkers graph: failed to parse uri path '%s': %+v", resStr, err))
- }
- query := resource.Query()
- query.Add("netflowFilter", netflowFilter.String())
- query.Add("time", "zoom")
- query.Add("start", strconv.FormatInt(start, 10))
- query.Add("end", strconv.FormatInt(end, 10))
- resource.RawQuery = query.Encode()
- body, err := c.Get(*resource)
- if err != nil {
- return
- }
- graph = &LMTopTalkersGraph{}
- err = json.Unmarshal(body, graph)
- if err != nil {
- err = fmt.Errorf("get top talkers graph: %v", err)
- return
- }
- return
- }
- type LMWidget struct {
- Id int32 `json:"id"`
- Type string `json:"type"`
- Name string `json:"name"`
- Description string `json:"description"`
- Timescale string `json:"timescale"`
- Interval int32 `json:"interval"`
- LastUpdateOn int64 `json:"lastUpdatedOn"`
- LastUpdateBy string `json:"lastUpdatedBy"`
- UserPermission string `json:"userPermission"`
- DashboardId int32 `json:"dashboardId"`
- Theme string `json:"theme"`
- //NetflowFilter string `json:"netflowFilter"`
- DeviceId int32 `json:"deviceId"`
- Graph string `json:"graph"`
- DeviceDisplayName string `json:"deviceDisplayName"`
- }
- // Get widgets
- func (c *LMClient) GetWidgetList() dp.Pager[*LMWidget] {
- resStr := "/dashboard/widgets"
- resource, err := url.Parse(resStr)
- if err != nil {
- panic(fmt.Sprintf("BUG: get widget list: failed to parse uri path '%s': %+v", resStr, err))
- }
- return makeLMPager[*LMWidget](c, resource)
- }
- type LMDevice struct {
- Id int32 `json:"id"`
- Name string `json:"name"`
- DisplayName string `json:"displayName"`
- PreferredCollectorId int32 `json:"preferredCollectorId"`
- }
- // Get devices by group id
- func (
- c *LMClient,
- ) GetImmediateDeviceList(
- id int32,
- ) dp.Pager[*LMDevice] {
- resStr := fmt.Sprintf("/device/groups/%d/devices", id)
- resource, err := url.Parse(resStr)
- if err != nil {
- panic(fmt.Sprintf("BUG: get widget list: failed to parse uri path '%s': %+v", resStr, err))
- }
- return makeLMPager[*LMDevice](c, resource)
- }
- type LMNetflowFlow struct {
- DataType string `json:"dataType"`
- FirstEpochInSec int64 `json:"firstEpochInSec"`
- LastEpochInSec int64 `json:"lastEpochInSec"`
- Usage float32 `json:"usage"`
- PercentUsage float32 `json:"percentUsage"`
- IfIn int64 `json:"ifIn"`
- IfOut int64 `json:"ifOut"`
- SrcASN int64 `json:"srcASN"`
- SrcAsnName string `json:"srcAsnName"`
- DstASN int64 `json:"dstASN"`
- DstAsnName string `json:"destAsnName"`
- SrcIP string `json:"srcIP"`
- DstIP string `json:"dstIP"`
- Protocol string `json:"protocol"`
- SrcPort int32 `json:"srcPort"`
- DstPort int32 `json:"dstPort"`
- SrcDNS string `json:"srcDNS"`
- DstDNS string `json:"dstDNS"`
- SrcMBytes float32 `json:"sourceMBytes"`
- DstMBytes float32 `json:"destinationMBytes"`
- }
- // Get Netflow flows by device id, date range, and Netflow
- // filter
- func (
- c *LMClient,
- ) GetNetflowFlowList(
- id int32,
- start,
- end int64,
- netflowFilter string,
- ) dp.Pager[*LMNetflowFlow] {
- resStr := fmt.Sprintf("/device/devices/%d/flows", id)
- resource, err := url.Parse(resStr)
- if err != nil {
- panic(fmt.Sprintf("BUG: get widget list: failed to parse uri path '%s': %+v", resStr, err))
- }
- query := resource.Query()
- query.Add("netflowFilter", netflowFilter)
- query.Add("start", strconv.FormatInt(start, 10))
- query.Add("end", strconv.FormatInt(end, 10))
- resource.RawQuery = query.Encode()
- return makeLMPager[*LMNetflowFlow](c, resource)
- }
|