VictoriaMetrics/lib/promscrape/discovery/azure/api.go
Mattias Ängehov 3904b8959e
Azure Service Discovery - Fix token fetch for Container Apps/App Services (#3832)
* Modify API version when running in Container App

* Handle expires on from token response

Response from IMDS does not always contain expires in value which is
currently used to get the token expiry time. An example resources that
doesn't provide it are Container Apps and App Service.

Signed-off-by: Mattias Ängehov <mattias.angehov@castoredc.com>

* Fix client id parameter for user assigned identity

* Apply suggestions from code review

---------

Signed-off-by: Mattias Ängehov <mattias.angehov@castoredc.com>
Co-authored-by: Aliaksandr Valialkin <valyala@gmail.com>
2023-02-22 19:24:23 -08:00

306 lines
9.7 KiB
Go

package azure
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promauth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape/discoveryutils"
)
var configMap = discoveryutils.NewConfigMap()
// Extract from the needed params from https://github.com/Azure/go-autorest/blob/7dd32b67be4e6c9386b9ba7b1c44a51263f05270/autorest/azure/environments.go#L61
type cloudEnvironmentEndpoints struct {
ActiveDirectoryEndpoint string `json:"activeDirectoryEndpoint"`
ResourceManagerEndpoint string `json:"resourceManagerEndpoint"`
}
// well-known azure cloud endpoints
// See https://github.com/Azure/go-autorest/blob/7dd32b67be4e6c9386b9ba7b1c44a51263f05270/autorest/azure/environments.go#L34
var cloudEnvironments = map[string]*cloudEnvironmentEndpoints{
"AZURECHINACLOUD": {
ActiveDirectoryEndpoint: "https://login.chinacloudapi.cn",
ResourceManagerEndpoint: "https://management.chinacloudapi.cn",
},
"AZUREGERMANCLOUD": {
ActiveDirectoryEndpoint: "https://login.microsoftonline.de",
ResourceManagerEndpoint: "https://management.microsoftazure.de",
},
"AZURECLOUD": {
ActiveDirectoryEndpoint: "https://login.microsoftonline.com",
ResourceManagerEndpoint: "https://management.azure.com",
},
"AZUREPUBLICCLOUD": {
ActiveDirectoryEndpoint: "https://login.microsoftonline.com",
ResourceManagerEndpoint: "https://management.azure.com",
},
"AZUREUSGOVERNMENT": {
ActiveDirectoryEndpoint: "https://login.microsoftonline.us",
ResourceManagerEndpoint: "https://management.usgovcloudapi.net",
},
"AZUREUSGOVERNMENTCLOUD": {
ActiveDirectoryEndpoint: "https://login.microsoftonline.us",
ResourceManagerEndpoint: "https://management.usgovcloudapi.net",
},
}
// apiConfig contains config for API server.
type apiConfig struct {
c *discoveryutils.Client
port int
resourceGroup string
subscriptionID string
tenantID string
refreshToken refreshTokenFunc
// tokenLock guards auth token and tokenExpireDeadline
tokenLock sync.Mutex
token string
tokenExpireDeadline time.Time
}
type refreshTokenFunc func() (string, time.Duration, error)
func getAPIConfig(sdc *SDConfig, baseDir string) (*apiConfig, error) {
v, err := configMap.Get(sdc, func() (interface{}, error) { return newAPIConfig(sdc, baseDir) })
if err != nil {
return nil, err
}
return v.(*apiConfig), nil
}
func newAPIConfig(sdc *SDConfig, baseDir string) (*apiConfig, error) {
if sdc.SubscriptionID == "" {
return nil, fmt.Errorf("missing `subscription_id` config option")
}
port := sdc.Port
if port == 0 {
port = 80
}
ac, err := sdc.HTTPClientConfig.NewConfig(baseDir)
if err != nil {
return nil, fmt.Errorf("cannot parse auth config: %w", err)
}
proxyAC, err := sdc.ProxyClientConfig.NewConfig(baseDir)
if err != nil {
return nil, fmt.Errorf("cannot parse proxy auth config: %w", err)
}
environment := sdc.Environment
if environment == "" {
environment = "AZURECLOUD"
}
env, err := getCloudEnvByName(environment)
if err != nil {
return nil, fmt.Errorf("cannot read configs for `environment: %q`: %w", environment, err)
}
refreshToken, err := getRefreshTokenFunc(sdc, ac, proxyAC, env)
if err != nil {
return nil, err
}
c, err := discoveryutils.NewClient(env.ResourceManagerEndpoint, ac, sdc.ProxyURL, proxyAC)
if err != nil {
return nil, fmt.Errorf("cannot create client for %q: %w", env.ResourceManagerEndpoint, err)
}
cfg := &apiConfig{
c: c,
port: port,
resourceGroup: sdc.ResourceGroup,
subscriptionID: sdc.SubscriptionID,
tenantID: sdc.TenantID,
refreshToken: refreshToken,
}
return cfg, nil
}
func getCloudEnvByName(name string) (*cloudEnvironmentEndpoints, error) {
name = strings.ToUpper(name)
// Special case, azure cloud k8s cluster, read content from file.
// See https://github.com/Azure/go-autorest/blob/7dd32b67be4e6c9386b9ba7b1c44a51263f05270/autorest/azure/environments.go#L301
if name == "AZURESTACKCLOUD" {
return readCloudEndpointsFromFile(os.Getenv("AZURE_ENVIRONMENT_FILEPATH"))
}
env := cloudEnvironments[name]
if env == nil {
var supportedEnvs []string
for envName := range cloudEnvironments {
supportedEnvs = append(supportedEnvs, envName)
}
return nil, fmt.Errorf("unsupported `environment: %q`; supported values: %s", name, strings.Join(supportedEnvs, ","))
}
return env, nil
}
func readCloudEndpointsFromFile(filePath string) (*cloudEnvironmentEndpoints, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("cannot file %q: %w", filePath, err)
}
var cee cloudEnvironmentEndpoints
if err := json.Unmarshal(data, &cee); err != nil {
return nil, fmt.Errorf("cannot parse cloud environment endpoints from file %q: %w", filePath, err)
}
return &cee, nil
}
func getRefreshTokenFunc(sdc *SDConfig, ac, proxyAC *promauth.Config, env *cloudEnvironmentEndpoints) (refreshTokenFunc, error) {
var tokenEndpoint, tokenAPIPath string
var modifyRequest func(request *http.Request)
authenticationMethod := sdc.AuthenticationMethod
if authenticationMethod == "" {
authenticationMethod = "OAuth"
}
switch strings.ToLower(authenticationMethod) {
case "oauth":
if sdc.TenantID == "" {
return nil, fmt.Errorf("missing `tenant_id` config option for `authentication_method: Oauth`")
}
if sdc.ClientID == "" {
return nil, fmt.Errorf("missing `client_id` config option for `authentication_method: OAuth`")
}
if sdc.ClientSecret.String() == "" {
return nil, fmt.Errorf("missing `client_secrect` config option for `authentication_method: OAuth`")
}
q := url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{sdc.ClientID},
"client_secret": []string{sdc.ClientSecret.String()},
"resource": []string{env.ResourceManagerEndpoint},
}
authParams := q.Encode()
tokenAPIPath = "/" + sdc.TenantID + "/oauth2/token"
tokenEndpoint = env.ActiveDirectoryEndpoint
modifyRequest = func(request *http.Request) {
request.Body = io.NopCloser(strings.NewReader(authParams))
request.Method = http.MethodPost
}
case "managedidentity":
endpoint := "http://169.254.169.254/metadata/identity/oauth2/token"
if ep := os.Getenv("MSI_ENDPOINT"); ep != "" {
endpoint = ep
}
endpointURL, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("cannot parse MSI endpoint url %q: %w", endpoint, err)
}
q := endpointURL.Query()
msiSecret := os.Getenv("MSI_SECRET")
identityHeader := os.Getenv("IDENTITY_HEADER")
clientIDParam := "client_id"
apiVersion := "2018-02-01"
if msiSecret != "" {
clientIDParam = "clientid"
apiVersion = "2017-09-01"
}
if identityHeader != "" {
clientIDParam = "client_id"
apiVersion = "2019-08-01"
}
q.Set("api-version", apiVersion)
q.Set(clientIDParam, sdc.ClientID)
q.Set("resource", env.ResourceManagerEndpoint)
endpointURL.RawQuery = q.Encode()
tokenAPIPath = endpointURL.RequestURI()
tokenEndpoint = endpointURL.Scheme + "://" + endpointURL.Host
modifyRequest = func(request *http.Request) {
if msiSecret != "" {
request.Header.Set("secret", msiSecret)
if identityHeader != "" {
request.Header.Set("X-IDENTITY-HEADER", msiSecret)
}
} else {
request.Header.Set("Metadata", "true")
}
}
default:
return nil, fmt.Errorf("unsupported `authentication_method: %q` only `OAuth` and `ManagedIdentity` are supported", authenticationMethod)
}
authClient, err := discoveryutils.NewClient(tokenEndpoint, ac, sdc.ProxyURL, proxyAC)
if err != nil {
return nil, fmt.Errorf("cannot build auth client: %w", err)
}
refreshToken := func() (string, time.Duration, error) {
data, err := authClient.GetAPIResponseWithReqParams(tokenAPIPath, modifyRequest)
if err != nil {
return "", 0, err
}
var tr tokenResponse
if err := json.Unmarshal(data, &tr); err != nil {
return "", 0, fmt.Errorf("cannot parse token auth response %q: %w", data, err)
}
expiresInSeconds, err := parseTokenExpiry(tr)
if err != nil {
return "", 0, err
}
return tr.AccessToken, time.Second * time.Duration(expiresInSeconds), nil
}
return refreshToken, nil
}
// parseTokenExpiry returns token expiry in seconds
func parseTokenExpiry(tr tokenResponse) (int64, error) {
var expiresInSeconds int64
var err error
if tr.ExpiresIn == "" {
var expiresOnSeconds int64
expiresOnSeconds, err = strconv.ParseInt(tr.ExpiresOn, 10, 64)
if err != nil {
return 0, fmt.Errorf("cannot parse expiresOn=%q in auth token response: %w", tr.ExpiresOn, err)
}
expiresInSeconds = expiresOnSeconds - time.Now().Unix()
} else {
expiresInSeconds, err = strconv.ParseInt(tr.ExpiresIn, 10, 64)
if err != nil {
return 0, fmt.Errorf("cannot parse expiresIn=%q auth token response: %w", tr.ExpiresIn, err)
}
}
return expiresInSeconds, nil
}
// mustGetAuthToken returns auth token
// in case of error, logs error and return empty token
func (ac *apiConfig) mustGetAuthToken() string {
ac.tokenLock.Lock()
defer ac.tokenLock.Unlock()
ct := time.Now()
if ac.tokenExpireDeadline.Sub(ct) > time.Second*30 {
return ac.token
}
token, expiresDuration, err := ac.refreshToken()
if err != nil {
logger.Errorf("cannot refresh azure auth token: %s", err)
return ""
}
ac.token = token
ac.tokenExpireDeadline = ct.Add(expiresDuration)
return ac.token
}
// tokenResponse represent response from oauth2 azure token service
//
// https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-go
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn string `json:"expires_in"`
ExpiresOn string `json:"expires_on"`
}