Skip to content

Commit c7fefaf

Browse files
[NDINT-290] Versa SLA Metrics w/ Session Auth (#37300)
1 parent 67b39b7 commit c7fefaf

File tree

8 files changed

+306
-67
lines changed

8 files changed

+306
-67
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2024-present Datadog, Inc.
5+
6+
package client
7+
8+
import (
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"strings"
14+
"time"
15+
16+
"github.com/DataDog/datadog-agent/pkg/util/log"
17+
)
18+
19+
// Login logs in to the Versa Director API, Versa Analytics API, gets CSRF
20+
// tokens, and a session cookie
21+
func (client *Client) login() error {
22+
authPayload := url.Values{}
23+
authPayload.Set("j_username", client.username)
24+
authPayload.Set("j_password", client.password)
25+
26+
// Run GET request to get session cookie and CSRF token
27+
err := client.runGetCSRFToken()
28+
if err != nil {
29+
return fmt.Errorf("failed to get CSRF token: %w", err)
30+
}
31+
32+
// now we can actually login and get a session cookie
33+
err = client.runJSpringSecurityCheck(&authPayload)
34+
if err != nil {
35+
return fmt.Errorf("failed to run j_spring_security_check to get session token: %w", err)
36+
}
37+
38+
// Request to /versa/analytics/login to obtain Analytics CSRF prevention token
39+
analyticsPayload := url.Values{}
40+
analyticsPayload.Set("endpoint", client.analyticsEndpoint)
41+
42+
err = client.runAnalyticsLogin(&analyticsPayload)
43+
if err != nil {
44+
return fmt.Errorf("failed to perform analytics login: %w", err)
45+
}
46+
47+
return nil
48+
}
49+
50+
// authenticate logins if no token or token is expired
51+
func (client *Client) authenticate() error {
52+
now := timeNow()
53+
54+
client.authenticationMutex.Lock()
55+
defer client.authenticationMutex.Unlock()
56+
57+
if client.token == "" || client.tokenExpiry.Before(now) {
58+
return client.login()
59+
}
60+
return nil
61+
}
62+
63+
// clearAuth clears auth state
64+
func (client *Client) clearAuth() {
65+
client.authenticationMutex.Lock()
66+
client.token = ""
67+
client.authenticationMutex.Unlock()
68+
}
69+
70+
// isAuthenticated determine if a request was successful from the headers
71+
// Vera can return HTTP 200 when auth is invalid, with the HTML login page
72+
// API calls returns application/json when successful. We assume receiving HTML means we're unauthenticated.
73+
func isAuthenticated(headers http.Header) bool {
74+
content := headers.Get("content-type")
75+
return !strings.HasPrefix(content, "text/html")
76+
}
77+
78+
func (client *Client) runGetCSRFToken() error {
79+
req, err := client.newRequest("GET", "/versa/analytics/auth/user", nil, true)
80+
if err != nil {
81+
return err
82+
}
83+
84+
resp, err := client.httpClient.Do(req)
85+
if err != nil {
86+
return nil
87+
}
88+
defer resp.Body.Close()
89+
90+
bodyBytes, err := io.ReadAll(resp.Body)
91+
if err != nil {
92+
return err
93+
}
94+
95+
endpointURL, err := url.Parse(client.directorEndpoint + "/versa")
96+
if err != nil {
97+
return fmt.Errorf("url parsing failed: %w", err)
98+
}
99+
cookies := client.httpClient.Jar.Cookies(endpointURL)
100+
for _, cookie := range cookies {
101+
if cookie.Name == "VD-CSRF-TOKEN" {
102+
client.token = cookie.Value
103+
client.tokenExpiry = timeNow().Add(time.Minute * 15)
104+
}
105+
}
106+
107+
if resp.StatusCode != 200 {
108+
return fmt.Errorf("authentication failed, status code: %v: %s", resp.StatusCode, string(bodyBytes))
109+
}
110+
log.Trace("get CSRF token successful")
111+
112+
return nil
113+
}
114+
115+
func (client *Client) runJSpringSecurityCheck(authPayload *url.Values) error {
116+
// Request to /j_spring_security_check to obtain CSRF token and session cookie
117+
req, err := client.newRequest("POST", "/versa/j_spring_security_check", strings.NewReader(authPayload.Encode()), true)
118+
if err != nil {
119+
return err
120+
}
121+
122+
// if we have a CSRF token, add it to the request
123+
if client.token != "" {
124+
req.Header.Add("X-CSRF-TOKEN", client.token)
125+
}
126+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
127+
sessionRes, err := client.httpClient.Do(req)
128+
if err != nil {
129+
return fmt.Errorf("invalid request: %w", err)
130+
}
131+
132+
defer sessionRes.Body.Close()
133+
134+
bodyBytes, err := io.ReadAll(sessionRes.Body)
135+
if err != nil {
136+
return err
137+
}
138+
139+
endpointURL, err := url.Parse(client.directorEndpoint + "/versa")
140+
if err != nil {
141+
return fmt.Errorf("url parsing failed: %w", err)
142+
}
143+
cookies := client.httpClient.Jar.Cookies(endpointURL)
144+
for _, cookie := range cookies {
145+
if cookie.Name == "VD-CSRF-TOKEN" {
146+
client.token = cookie.Value
147+
client.tokenExpiry = timeNow().Add(time.Minute * 15)
148+
}
149+
}
150+
151+
if sessionRes.StatusCode != 200 {
152+
return fmt.Errorf("authentication failed, status code: %v: %s", sessionRes.StatusCode, string(bodyBytes))
153+
}
154+
log.Trace("j_spring_security_check successful")
155+
156+
return nil
157+
}
158+
159+
func (client *Client) runAnalyticsLogin(analyticsPayload *url.Values) error {
160+
// TODO: use proper client request creation, this is a testing work around
161+
req, err := client.newRequest("POST", "/versa/analytics/login", strings.NewReader(analyticsPayload.Encode()), true)
162+
if err != nil {
163+
return err
164+
}
165+
req.Header.Add("X-CSRF-TOKEN", client.token)
166+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
167+
168+
loginRes, err := client.httpClient.Do(req)
169+
if err != nil {
170+
return fmt.Errorf("invalid request: %w", err)
171+
}
172+
173+
defer loginRes.Body.Close()
174+
175+
bodyBytes, err := io.ReadAll(loginRes.Body)
176+
if err != nil {
177+
return err
178+
}
179+
180+
if loginRes.StatusCode != 200 {
181+
return fmt.Errorf("analytics authentication failed, status code: %v: %s", loginRes.StatusCode, string(bodyBytes))
182+
}
183+
log.Trace("analytics login successful")
184+
185+
return nil
186+
}

pkg/collector/corechecks/network-devices/versa/client/client.go

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,18 @@ const (
2929
defaultHTTPScheme = "https"
3030
)
3131

32+
// Useful for mocking
33+
var timeNow = time.Now
34+
3235
// Client is an HTTP Versa client.
3336
type Client struct {
34-
httpClient *http.Client
35-
endpoint string
36-
// TODO: add back with OAuth
37-
// token string
38-
// tokenExpiry time.Time
37+
httpClient *http.Client
38+
directorEndpoint string
39+
directorAPIPort int
40+
analyticsEndpoint string
41+
// TODO: replace with OAuth
42+
token string
43+
tokenExpiry time.Time
3944
username string
4045
password string
4146
authenticationMutex *sync.Mutex
@@ -49,8 +54,8 @@ type Client struct {
4954
type ClientOptions func(*Client)
5055

5156
// NewClient creates a new Versa HTTP client.
52-
func NewClient(endpoint, username, password string, useHTTP bool, options ...ClientOptions) (*Client, error) {
53-
err := validateParams(endpoint, username, password)
57+
func NewClient(directorEndpoint, analyticsEndpoint, username, password string, useHTTP bool, options ...ClientOptions) (*Client, error) {
58+
err := validateParams(directorEndpoint, analyticsEndpoint, username, password)
5459
if err != nil {
5560
return nil, err
5661
}
@@ -70,14 +75,21 @@ func NewClient(endpoint, username, password string, useHTTP bool, options ...Cli
7075
scheme = "http"
7176
}
7277

73-
endpointURL := url.URL{
78+
directorEndpointURL := url.URL{
7479
Scheme: scheme,
75-
Host: endpoint,
80+
Host: directorEndpoint,
81+
}
82+
83+
analyticsEndpointURL := url.URL{
84+
Scheme: scheme,
85+
Host: analyticsEndpoint,
7686
}
7787

7888
client := &Client{
7989
httpClient: httpClient,
80-
endpoint: endpointURL.String(),
90+
directorEndpoint: directorEndpointURL.String(),
91+
directorAPIPort: 9182, // TODO: make configurable based on auth type
92+
analyticsEndpoint: analyticsEndpointURL.String(),
8193
username: username,
8294
password: password,
8395
authenticationMutex: &sync.Mutex{},
@@ -94,9 +106,12 @@ func NewClient(endpoint, username, password string, useHTTP bool, options ...Cli
94106
return client, nil
95107
}
96108

97-
func validateParams(endpoint, username, password string) error {
98-
if endpoint == "" {
99-
return fmt.Errorf("invalid endpoint")
109+
func validateParams(directorEndpoint, analyticsEndpoint, username, password string) error {
110+
if directorEndpoint == "" {
111+
return fmt.Errorf("invalid director endpoint")
112+
}
113+
if analyticsEndpoint == "" {
114+
return fmt.Errorf("invalid analytics endpoint")
100115
}
101116
if username == "" {
102117
return fmt.Errorf("invalid username")
@@ -169,7 +184,7 @@ func WithLookback(lookback time.Duration) ClientOptions {
169184
// GetOrganizations retrieves a list of organizations
170185
func (client *Client) GetOrganizations() ([]Organization, error) {
171186
var organizations []Organization
172-
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", nil)
187+
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", nil, false)
173188
if err != nil {
174189
return nil, fmt.Errorf("failed to get organizations: %v", err)
175190
}
@@ -183,7 +198,7 @@ func (client *Client) GetOrganizations() ([]Organization, error) {
183198
"limit": client.maxCount,
184199
"offset": strconv.Itoa(i * maxCount),
185200
}
186-
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", params)
201+
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", params, false)
187202
if err != nil {
188203
return nil, fmt.Errorf("failed to get organizations: %v", err)
189204
}
@@ -209,7 +224,7 @@ func (client *Client) GetChildAppliancesDetail(tenant string) ([]Appliance, erro
209224
}
210225

211226
// Get the total count of appliances
212-
totalCount, err := get[int](client, uri, params)
227+
totalCount, err := get[int](client, uri, params, false)
213228
if err != nil {
214229
return nil, fmt.Errorf("failed to get appliance detail response: %v", err)
215230
}
@@ -223,7 +238,7 @@ func (client *Client) GetChildAppliancesDetail(tenant string) ([]Appliance, erro
223238
for i := 0; i < totalPages; i++ {
224239
params["fetch"] = "all"
225240
params["offset"] = fmt.Sprintf("%d", i*maxCount)
226-
resp, err := get[[]Appliance](client, uri, params)
241+
resp, err := get[[]Appliance](client, uri, params, false)
227242
if err != nil {
228243
return nil, fmt.Errorf("failed to get appliance detail response: %v", err)
229244
}
@@ -238,7 +253,7 @@ func (client *Client) GetChildAppliancesDetail(tenant string) ([]Appliance, erro
238253

239254
// GetDirectorStatus retrieves the director status
240255
func (client *Client) GetDirectorStatus() (*DirectorStatus, error) {
241-
resp, err := get[DirectorStatus](client, "/vnms/dashboard/vdStatus", nil)
256+
resp, err := get[DirectorStatus](client, "/vnms/dashboard/vdStatus", nil, false)
242257
if err != nil {
243258
return nil, fmt.Errorf("failed to get director status: %v", err)
244259
}
@@ -303,7 +318,7 @@ func (client *Client) GetSLAMetrics() ([]SLAMetrics, error) {
303318
"pduLossRatio",
304319
})
305320

306-
resp, err := get[SLAMetricsResponse](client, analyticsURL, nil)
321+
resp, err := get[SLAMetricsResponse](client, analyticsURL, nil, true)
307322
if err != nil {
308323
return nil, fmt.Errorf("failed to get SLA metrics: %v", err)
309324
}

pkg/collector/corechecks/network-devices/versa/client/client_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,9 @@ func TestGetSLAMetrics(t *testing.T) {
391391
defer server.Close()
392392

393393
client, err := testClient(server)
394+
// TODO: remove this override when single auth
395+
// method is being used
396+
client.directorEndpoint = server.URL
394397
require.NoError(t, err)
395398

396399
slaMetrics, err := client.GetSLAMetrics()

0 commit comments

Comments
 (0)