Skip to content

[NDINT-290] Versa SLA Metrics w/ Session Auth #37300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 12, 2025
185 changes: 185 additions & 0 deletions pkg/collector/corechecks/network-devices/versa/client/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2024-present Datadog, Inc.

package client

import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/DataDog/datadog-agent/pkg/util/log"
)

// Login logs in to the Versa Director API, Versa Analytics API, gets CSRF
// tokens, and a session cookie
func (client *Client) login() error {
authPayload := url.Values{}
authPayload.Set("j_username", client.username)
authPayload.Set("j_password", client.password)
// this is a hack to get a CSRF token then
// actually perform login
//
// ideally, we'd have a different endpoint to get a CSRF token
// from at the very least
err := client.runJSpringSecurityCheck(&authPayload)
if err != nil {
return fmt.Errorf("failed to run j_spring_security_check to get CSRF token: %w", err)
}

// now we can actually login and get a session cookie
err = client.runJSpringSecurityCheck(&authPayload)
if err != nil {
return fmt.Errorf("failed to run j_spring_security_check to get session token: %w", err)
}

// Request to /versa/analytics/login to obtain Analytics CSRF prevention token
analyticsPayload := url.Values{}
analyticsPayload.Set("endpoint", client.analyticsEndpoint) // TODO: WHY? Where can we get this for others?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non blocking question
i forget if during our 1:1 you made a successful call to /versa/login that doesn't require this endpoint parameter but if i recall correctly, returns the token we're looking for?

tbh it's very close to this /versa/analytics/login so maybe i mixed it up 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flow requires the analytics endpoint parameter to be set. I haven't been able to get that version working, it's something I'm going to look at when I get back.


// Run this request twice to get the CSRF token from analytics
// the first succeeds but does not return the token
err = client.runAnalyticsLogin(&analyticsPayload)
if err != nil {
return fmt.Errorf("failed to run analytics login: %w", err)
}
err = client.runAnalyticsLogin(&analyticsPayload)
if err != nil {
return fmt.Errorf("failed to get analytics CSRF token: %w", err)
}

return nil
}

// authenticate logins if no token or token is expired
func (client *Client) authenticate() error {
now := timeNow()

client.authenticationMutex.Lock()
defer client.authenticationMutex.Unlock()

if client.token == "" || client.tokenExpiry.Before(now) {
return client.login()
}
return nil
}

// clearAuth clears auth state
func (client *Client) clearAuth() {
client.authenticationMutex.Lock()
client.token = ""
client.authenticationMutex.Unlock()
}

// isAuthenticated determine if a request was successful from the headers
// Vera can return HTTP 200 when auth is invalid, with the HTML login page
// API calls returns application/json when successful. We assume receiving HTML means we're unauthenticated.
func isAuthenticated(headers http.Header) bool {
content := headers.Get("content-type")
return !strings.HasPrefix(content, "text/html")
}

func (client *Client) runJSpringSecurityCheck(authPayload *url.Values) error {
// TODO: this is pretty hacky at the moment, we're investigating
// how to properly handle the CSRF token and see if we could just
// use OAuth instead. For now, this gets us off the ground
//
// From support questions we've found, we might be able to perform a single
// GET request, then perform analytics login

// Request to /j_spring_security_check to obtain CSRF token and session cookie
// TODO: use scheme from config, this is for testing
req, err := client.newRequest("POST", "/versa/j_spring_security_check", strings.NewReader(authPayload.Encode()), true)
if err != nil {
return err
}

// if we have a CSRF token, add it to the request
if client.token != "" {
req.Header.Add("X-CSRF-TOKEN", client.token)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
sessionRes, err := client.httpClient.Do(req)
if err != nil {
return fmt.Errorf("invalid request: %w", err)
}

defer sessionRes.Body.Close()

bodyBytes, err := io.ReadAll(sessionRes.Body)
if err != nil {
return err
}

// TODO: remove this, we don't need it, just using it for debugging
endpointURL, err := url.Parse(client.directorEndpoint + "/versa")
if err != nil {
return fmt.Errorf("url parsing failed: %w", err)
}

cookies := client.httpClient.Jar.Cookies(endpointURL)

log.Tracef("Client login URL: %s", endpointURL)
log.Tracef("Client login response headers: %+v", sessionRes.Header)
for _, cookie := range cookies {
log.Tracef("Versa Director cookie: %s=%s;Secure:%T", cookie.Name, cookie.Value, cookie.Secure)
// TODO: replace with OAuth token
if cookie.Name == "VD-CSRF-TOKEN" {
client.token = cookie.Value
client.tokenExpiry = timeNow().Add(time.Minute * 15)
}
}

if sessionRes.StatusCode != 200 {
return fmt.Errorf("authentication failed, status code: %v: %s", sessionRes.StatusCode, string(bodyBytes))
}

return nil
}

func (client *Client) runAnalyticsLogin(analyticsPayload *url.Values) error {
// TODO: use proper client request creation, this is a testing work around
req, err := client.newRequest("POST", "/versa/analytics/login", strings.NewReader(analyticsPayload.Encode()), true)
if err != nil {
return err
}
req.Header.Add("X-CSRF-TOKEN", client.token)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

loginRes, err := client.httpClient.Do(req)
if err != nil {
return fmt.Errorf("invalid request: %w", err)
}

defer loginRes.Body.Close()

bodyBytes, err := io.ReadAll(loginRes.Body)
if err != nil {
return err
}

endpointURL, err := url.Parse(client.directorEndpoint + "/versa")
if err != nil {
return fmt.Errorf("url parsing failed: %w", err)
}

cookies := client.httpClient.Jar.Cookies(endpointURL)

log.Tracef("Client ANALYTICS login URL: %s", endpointURL)
log.Tracef("Client ANALYTICS login response headers: %+v", loginRes.Header)
for _, cookie := range cookies {
log.Tracef("Versa Analytics cookie: %s=%s;Secure:%t;Path:%s", cookie.Name, cookie.Value, cookie.Secure, cookie.Path)
}

if loginRes.StatusCode != 200 {
return fmt.Errorf("analytics authentication failed, status code: %v: %s", loginRes.StatusCode, string(bodyBytes))
}
log.Tracef("Analytics login successful!! %s", string(bodyBytes))

return nil
}
53 changes: 34 additions & 19 deletions pkg/collector/corechecks/network-devices/versa/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ const (
defaultHTTPScheme = "https"
)

// Useful for mocking
var timeNow = time.Now

// Client is an HTTP Versa client.
type Client struct {
httpClient *http.Client
endpoint string
// TODO: add back with OAuth
// token string
// tokenExpiry time.Time
httpClient *http.Client
directorEndpoint string
directorAPIPort int
analyticsEndpoint string
// TODO: replace with OAuth
token string
tokenExpiry time.Time
username string
password string
authenticationMutex *sync.Mutex
Expand All @@ -49,8 +54,8 @@ type Client struct {
type ClientOptions func(*Client)

// NewClient creates a new Versa HTTP client.
func NewClient(endpoint, username, password string, useHTTP bool, options ...ClientOptions) (*Client, error) {
err := validateParams(endpoint, username, password)
func NewClient(directorEndpoint, analyticsEndpoint, username, password string, useHTTP bool, options ...ClientOptions) (*Client, error) {
err := validateParams(directorEndpoint, analyticsEndpoint, username, password)
if err != nil {
return nil, err
}
Expand All @@ -70,14 +75,21 @@ func NewClient(endpoint, username, password string, useHTTP bool, options ...Cli
scheme = "http"
}

endpointURL := url.URL{
directorEndpointURL := url.URL{
Scheme: scheme,
Host: endpoint,
Host: directorEndpoint,
}

analyticsEndpointURL := url.URL{
Scheme: scheme,
Host: analyticsEndpoint,
}

client := &Client{
httpClient: httpClient,
endpoint: endpointURL.String(),
directorEndpoint: directorEndpointURL.String(),
directorAPIPort: 9182, // TODO: make configurable based on auth type
analyticsEndpoint: analyticsEndpointURL.String(),
username: username,
password: password,
authenticationMutex: &sync.Mutex{},
Expand All @@ -94,9 +106,12 @@ func NewClient(endpoint, username, password string, useHTTP bool, options ...Cli
return client, nil
}

func validateParams(endpoint, username, password string) error {
if endpoint == "" {
return fmt.Errorf("invalid endpoint")
func validateParams(directorEndpoint, analyticsEndpoint, username, password string) error {
if directorEndpoint == "" {
return fmt.Errorf("invalid director endpoint")
}
if analyticsEndpoint == "" {
return fmt.Errorf("invalid analytics endpoint")
}
if username == "" {
return fmt.Errorf("invalid username")
Expand Down Expand Up @@ -169,7 +184,7 @@ func WithLookback(lookback time.Duration) ClientOptions {
// GetOrganizations retrieves a list of organizations
func (client *Client) GetOrganizations() ([]Organization, error) {
var organizations []Organization
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", nil)
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", nil, false)
if err != nil {
return nil, fmt.Errorf("failed to get organizations: %v", err)
}
Expand All @@ -183,7 +198,7 @@ func (client *Client) GetOrganizations() ([]Organization, error) {
"limit": client.maxCount,
"offset": strconv.Itoa(i * maxCount),
}
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", params)
resp, err := get[OrganizationListResponse](client, "/vnms/organization/orgs", params, false)
if err != nil {
return nil, fmt.Errorf("failed to get organizations: %v", err)
}
Expand All @@ -209,7 +224,7 @@ func (client *Client) GetChildAppliancesDetail(tenant string) ([]Appliance, erro
}

// Get the total count of appliances
totalCount, err := get[int](client, uri, params)
totalCount, err := get[int](client, uri, params, false)
if err != nil {
return nil, fmt.Errorf("failed to get appliance detail response: %v", err)
}
Expand All @@ -223,7 +238,7 @@ func (client *Client) GetChildAppliancesDetail(tenant string) ([]Appliance, erro
for i := 0; i < totalPages; i++ {
params["fetch"] = "all"
params["offset"] = fmt.Sprintf("%d", i*maxCount)
resp, err := get[[]Appliance](client, uri, params)
resp, err := get[[]Appliance](client, uri, params, false)
if err != nil {
return nil, fmt.Errorf("failed to get appliance detail response: %v", err)
}
Expand All @@ -238,7 +253,7 @@ func (client *Client) GetChildAppliancesDetail(tenant string) ([]Appliance, erro

// GetDirectorStatus retrieves the director status
func (client *Client) GetDirectorStatus() (*DirectorStatus, error) {
resp, err := get[DirectorStatus](client, "/vnms/dashboard/vdStatus", nil)
resp, err := get[DirectorStatus](client, "/vnms/dashboard/vdStatus", nil, false)
if err != nil {
return nil, fmt.Errorf("failed to get director status: %v", err)
}
Expand Down Expand Up @@ -303,7 +318,7 @@ func (client *Client) GetSLAMetrics() ([]SLAMetrics, error) {
"pduLossRatio",
})

resp, err := get[SLAMetricsResponse](client, analyticsURL, nil)
resp, err := get[SLAMetricsResponse](client, analyticsURL, nil, true)
if err != nil {
return nil, fmt.Errorf("failed to get SLA metrics: %v", err)
}
Expand Down
Loading
Loading