argo-cd/server/extension/extension.go
Ville Vesilehto 5101db5225
chore(deps): migrate to go.yaml.in/yaml/v3 (#27063)
Signed-off-by: Ville Vesilehto <ville@vesilehto.fi>
2026-04-01 16:34:18 +02:00

857 lines
30 KiB
Go

package extension
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strings"
"time"
"github.com/felixge/httpsnoop"
log "github.com/sirupsen/logrus"
"go.yaml.in/yaml/v3"
"github.com/argoproj/argo-cd/v3/util/rbac"
"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
applisters "github.com/argoproj/argo-cd/v3/pkg/client/listers/application/v1alpha1"
"github.com/argoproj/argo-cd/v3/server/rbacpolicy"
"github.com/argoproj/argo-cd/v3/util/argo"
"github.com/argoproj/argo-cd/v3/util/db"
"github.com/argoproj/argo-cd/v3/util/security"
"github.com/argoproj/argo-cd/v3/util/session"
"github.com/argoproj/argo-cd/v3/util/settings"
)
const (
URLPrefix = "/extensions"
DefaultConnectionTimeout = 2 * time.Second
DefaultKeepAlive = 15 * time.Second
DefaultIdleConnectionTimeout = 60 * time.Second
DefaultMaxIdleConnections = 30
// HeaderArgoCDNamespace defines the namespace of the
// argo control plane to be passed to the extension handler.
// Example:
// Argocd-Namespace: "namespace"
HeaderArgoCDNamespace = "Argocd-Namespace"
// HeaderArgoCDApplicationName defines the name of the
// expected application header to be passed to the extension
// handler. The header value must follow the format:
// "<namespace>:<app-name>"
// Example:
// Argocd-Application-Name: "namespace:app-name"
HeaderArgoCDApplicationName = "Argocd-Application-Name"
// HeaderArgoCDProjectName defines the name of the expected
// project header to be passed to the extension handler.
// Example:
// Argocd-Project-Name: "default"
HeaderArgoCDProjectName = "Argocd-Project-Name"
// HeaderArgoCDTargetClusterURL defines the target cluster URL
// that the Argo CD application is associated with. This header
// will be populated by the extension proxy and passed to the
// configured backend service. If this header is passed by
// the client, its value will be overridden by the extension
// handler.
//
// Example:
// Argocd-Target-Cluster-URL: "https://kubernetes.default.svc.cluster.local"
HeaderArgoCDTargetClusterURL = "Argocd-Target-Cluster-URL"
// HeaderArgoCDTargetClusterName defines the target cluster name
// that the Argo CD application is associated with. This header
// will be populated by the extension proxy and passed to the
// configured backend service. If this header is passed by
// the client, its value will be overridden by the extension
// handler.
HeaderArgoCDTargetClusterName = "Argocd-Target-Cluster-Name"
// HeaderArgoCDUsername is the header name that defines the username of the logged
// in user authenticated by Argo CD.
HeaderArgoCDUsername = "Argocd-Username"
// HeaderArgoCDUserId is the header name that defines the internal user id of the logged
// in user authenticated by Argo CD.
HeaderArgoCDUserId = "Argocd-User-Id"
// HeaderArgoCDGroups is the header name that provides the 'groups'
// claim from the users authenticated in Argo CD.
HeaderArgoCDGroups = "Argocd-User-Groups"
)
// RequestResources defines the authorization scope for
// an incoming request to a given extension. This struct
// is populated from pre-defined Argo CD headers.
type RequestResources struct {
ApplicationName string
ApplicationNamespace string
ProjectName string
}
// ValidateHeaders will validate the pre-defined Argo CD
// request headers for extensions and extract the resources
// information populating and returning a RequestResources
// object.
// The pre-defined headers are:
// - Argocd-Application-Name
// - Argocd-Project-Name
//
// The headers expected format is documented in each of the constant
// types defined for them.
func ValidateHeaders(r *http.Request) (*RequestResources, error) {
appHeader := r.Header.Get(HeaderArgoCDApplicationName)
if appHeader == "" {
return nil, fmt.Errorf("header %q must be provided", HeaderArgoCDApplicationName)
}
appNamespace, appName, err := getAppName(appHeader)
if err != nil {
return nil, fmt.Errorf("error getting app details: %w", err)
}
if !argo.IsValidNamespaceName(appNamespace) {
return nil, errors.New("invalid value for namespace")
}
if !argo.IsValidAppName(appName) {
return nil, errors.New("invalid value for application name")
}
projName := r.Header.Get(HeaderArgoCDProjectName)
if projName == "" {
return nil, fmt.Errorf("header %q must be provided", HeaderArgoCDProjectName)
}
if !argo.IsValidProjectName(projName) {
return nil, errors.New("invalid value for project name")
}
return &RequestResources{
ApplicationName: appName,
ApplicationNamespace: appNamespace,
ProjectName: projName,
}, nil
}
func getAppName(appHeader string) (string, string, error) {
parts := strings.Split(appHeader, ":")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid value for %q header: expected format: <namespace>:<app-name>", HeaderArgoCDApplicationName)
}
return parts[0], parts[1], nil
}
// ExtensionConfigs defines the configurations for all extensions
// retrieved from Argo CD configmap (argocd-cm).
type ExtensionConfigs struct {
Extensions []ExtensionConfig `yaml:"extensions"`
}
// ExtensionConfig defines the configuration for one extension.
type ExtensionConfig struct {
// Name defines the endpoint that will be used to register
// the extension route. Mandatory field.
Name string `yaml:"name"`
Backend BackendConfig `yaml:"backend"`
}
// BackendConfig defines the backend service configurations that will
// be used by an specific extension. An extension can have multiple services
// associated. This is necessary when Argo CD is managing applications in
// external clusters. In this case, each cluster may have its own backend
// service.
type BackendConfig struct {
ProxyConfig
Services []ServiceConfig `yaml:"services"`
}
// ServiceConfig provides the configuration for a backend service.
type ServiceConfig struct {
// URL is the address where the extension backend must be available.
// Mandatory field.
URL string `yaml:"url"`
// Cluster if provided, will have to match the application
// destination name to have requests properly forwarded to this
// service URL.
Cluster *ClusterConfig `yaml:"cluster,omitempty"`
// Headers if provided, the headers list will be added on all
// outgoing requests for this service config.
Headers []Header `yaml:"headers"`
}
// Header defines the header to be added in the proxy requests.
type Header struct {
// Name defines the name of the header. It is a mandatory field if
// a header is provided.
Name string `yaml:"name"`
// Value defines the value of the header. The actual value can be
// provided as verbatim or as a reference to an Argo CD secret key.
// In order to provide it as a reference, it is necessary to prefix
// it with a dollar sign.
// Example:
// value: '$some.argocd.secret.key'
// In the example above, the value will be replaced with the one from
// the argocd-secret with key 'some.argocd.secret.key'.
Value string `yaml:"value"`
}
type ClusterConfig struct {
// Server specifies the URL of the target cluster's Kubernetes control plane API. This must be set if Name is not set.
Server string `yaml:"server"`
// Name is an alternate way of specifying the target cluster by its symbolic name. This must be set if Server is not set.
Name string `yaml:"name"`
}
// ProxyConfig allows configuring connection behaviour between Argo CD
// API Server and the backend service.
type ProxyConfig struct {
// ConnectionTimeout is the maximum amount of time a dial to
// the extension server will wait for a connect to complete.
// Default: 2 seconds
ConnectionTimeout time.Duration `yaml:"connectionTimeout"`
// KeepAlive specifies the interval between keep-alive probes
// for an active network connection between the API server and
// the extension server.
// Default: 15 seconds
KeepAlive time.Duration `yaml:"keepAlive"`
// IdleConnectionTimeout is the maximum amount of time an idle
// (keep-alive) connection between the API server and the extension
// server will remain idle before closing itself.
// Default: 60 seconds
IdleConnectionTimeout time.Duration `yaml:"idleConnectionTimeout"`
// MaxIdleConnections controls the maximum number of idle (keep-alive)
// connections between the API server and the extension server.
// Default: 30
MaxIdleConnections int `yaml:"maxIdleConnections"`
}
// SettingsGetter defines the contract to retrieve Argo CD Settings.
type SettingsGetter interface {
Get() (*settings.ArgoCDSettings, error)
}
// DefaultSettingsGetter is the real settings getter implementation.
type DefaultSettingsGetter struct {
settingsMgr *settings.SettingsManager
}
// NewDefaultSettingsGetter returns a new default settings getter.
func NewDefaultSettingsGetter(mgr *settings.SettingsManager) *DefaultSettingsGetter {
return &DefaultSettingsGetter{
settingsMgr: mgr,
}
}
// Get will retrieve the Argo CD settings.
func (s *DefaultSettingsGetter) Get() (*settings.ArgoCDSettings, error) {
return s.settingsMgr.GetSettings()
}
// ProjectGetter defines the contract to retrieve Argo CD Project.
type ProjectGetter interface {
Get(name string) (*v1alpha1.AppProject, error)
GetClusters(project string) ([]*v1alpha1.Cluster, error)
}
// DefaultProjectGetter is the real ProjectGetter implementation.
type DefaultProjectGetter struct {
projLister applisters.AppProjectNamespaceLister
db db.ArgoDB
}
// NewDefaultProjectGetter returns a new default project getter
func NewDefaultProjectGetter(lister applisters.AppProjectNamespaceLister, db db.ArgoDB) *DefaultProjectGetter {
return &DefaultProjectGetter{
projLister: lister,
db: db,
}
}
// Get will retrieve the live AppProject state.
func (p *DefaultProjectGetter) Get(name string) (*v1alpha1.AppProject, error) {
return p.projLister.Get(name)
}
// GetClusters will retrieve the clusters configured by a project.
func (p *DefaultProjectGetter) GetClusters(project string) ([]*v1alpha1.Cluster, error) {
return p.db.GetProjectClusters(context.TODO(), project)
}
// UserGetter defines the contract to retrieve info from the logged in user.
type UserGetter interface {
GetUserId(ctx context.Context) string
GetUsername(ctx context.Context) string
GetGroups(ctx context.Context) []string
}
// DefaultUserGetter is the main UserGetter implementation.
type DefaultUserGetter struct {
policyEnf *rbacpolicy.RBACPolicyEnforcer
}
// NewDefaultUserGetter return a new default UserGetter
func NewDefaultUserGetter(policyEnf *rbacpolicy.RBACPolicyEnforcer) *DefaultUserGetter {
return &DefaultUserGetter{
policyEnf: policyEnf,
}
}
// GetUsername will return the username of the current logged in user
func (u *DefaultUserGetter) GetUsername(ctx context.Context) string {
return session.Username(ctx)
}
// GetUserId will return the user id of the current logged in user
func (u *DefaultUserGetter) GetUserId(ctx context.Context) string {
return session.GetUserIdentifier(ctx)
}
// GetGroups will return the groups associated with the logged in user.
func (u *DefaultUserGetter) GetGroups(ctx context.Context) []string {
return session.Groups(ctx, u.policyEnf.GetScopes())
}
// ApplicationGetter defines the contract to retrieve the application resource.
type ApplicationGetter interface {
Get(ns, name string) (*v1alpha1.Application, error)
}
// DefaultApplicationGetter is the real application getter implementation.
type DefaultApplicationGetter struct {
appLister applisters.ApplicationLister
}
// NewDefaultApplicationGetter returns the default application getter.
func NewDefaultApplicationGetter(al applisters.ApplicationLister) *DefaultApplicationGetter {
return &DefaultApplicationGetter{
appLister: al,
}
}
// Get will retrieve the application resource for the given namespace and name.
func (a *DefaultApplicationGetter) Get(ns, name string) (*v1alpha1.Application, error) {
return a.appLister.Applications(ns).Get(name)
}
// RbacEnforcer defines the contract to enforce rbac rules
type RbacEnforcer interface {
EnforceErr(rvals ...any) error
}
// Manager is the object that will be responsible for registering
// and handling proxy extensions.
type Manager struct {
log *log.Entry
namespace string
settings SettingsGetter
application ApplicationGetter
project ProjectGetter
cluster argo.ClusterGetter
rbac RbacEnforcer
registry ExtensionRegistry
metricsReg ExtensionMetricsRegistry
userGetter UserGetter
}
// ExtensionMetricsRegistry exposes operations to update http metrics in the Argo CD
// API server.
type ExtensionMetricsRegistry interface {
// IncExtensionRequestCounter will increase the request counter for the given
// extension with the given status.
IncExtensionRequestCounter(extension string, status int)
// ObserveExtensionRequestDuration will register the request roundtrip duration
// between Argo CD API Server and the extension backend service for the given
// extension.
ObserveExtensionRequestDuration(extension string, duration time.Duration)
}
// NewManager will initialize a new manager.
func NewManager(log *log.Entry, namespace string, sg SettingsGetter, ag ApplicationGetter, pg ProjectGetter, cg argo.ClusterGetter, rbac RbacEnforcer, ug UserGetter) *Manager {
return &Manager{
log: log,
namespace: namespace,
settings: sg,
application: ag,
project: pg,
cluster: cg,
rbac: rbac,
userGetter: ug,
}
}
// ExtensionRegistry is an in memory registry that contains contains all
// proxies for all extensions. The key is the extension name defined in
// the Argo CD configmap.
type ExtensionRegistry map[string]ProxyRegistry
// ProxyRegistry is an in memory registry that contains all proxies for a
// given extension. Different extensions will have independent proxy registries.
// This is required to address the use case when one extension is configured with
// multiple backend services in different clusters.
type ProxyRegistry map[ProxyKey]*httputil.ReverseProxy
// NewProxyRegistry will instantiate a new in memory registry for proxies.
func NewProxyRegistry() ProxyRegistry {
r := make(map[ProxyKey]*httputil.ReverseProxy)
return r
}
// ProxyKey defines the struct used as a key in the proxy registry
// map (ProxyRegistry).
type ProxyKey struct {
//nolint:unused // used as part of a map kay
extensionName string
//nolint:unused // used as part of a map kay
clusterName string
//nolint:unused // used as part of a map kay
clusterServer string
}
// proxyKey will build the key to be used in the proxyByCluster
// map.
func proxyKey(extName, cName, cServer string) ProxyKey {
return ProxyKey{
extensionName: extName,
clusterName: cName,
clusterServer: cServer,
}
}
func parseAndValidateConfig(s *settings.ArgoCDSettings) (*ExtensionConfigs, error) {
if len(s.ExtensionConfig) == 0 {
return nil, errors.New("no extensions configurations found")
}
configs := ExtensionConfigs{}
for extName, extConfig := range s.ExtensionConfig {
extConfigMap := map[string]any{}
err := yaml.Unmarshal([]byte(extConfig), &extConfigMap)
if err != nil {
return nil, fmt.Errorf("invalid extension config: %w", err)
}
parsedExtConfig := settings.ReplaceMapSecrets(extConfigMap, s.Secrets)
parsedExtConfigBytes, err := yaml.Marshal(parsedExtConfig)
if err != nil {
return nil, fmt.Errorf("error marshaling parsed extension config: %w", err)
}
// empty extName means that this is the main configuration defined by
// the 'extension.config' configmap key
if extName == "" {
mainConfig := ExtensionConfigs{}
err = yaml.Unmarshal(parsedExtConfigBytes, &mainConfig)
if err != nil {
return nil, fmt.Errorf("invalid parsed extension config: %w", err)
}
configs.Extensions = append(configs.Extensions, mainConfig.Extensions...)
} else {
backendConfig := BackendConfig{}
err = yaml.Unmarshal(parsedExtConfigBytes, &backendConfig)
if err != nil {
return nil, fmt.Errorf("invalid parsed backend extension config for extension %s: %w", extName, err)
}
ext := ExtensionConfig{
Name: extName,
Backend: backendConfig,
}
configs.Extensions = append(configs.Extensions, ext)
}
}
err := validateConfigs(&configs)
if err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
return &configs, nil
}
func validateConfigs(configs *ExtensionConfigs) error {
nameSafeRegex := regexp.MustCompile(`^[A-Za-z0-9-_]+$`)
exts := make(map[string]struct{})
for _, ext := range configs.Extensions {
if ext.Name == "" {
return errors.New("extensions.name must be configured")
}
if !nameSafeRegex.MatchString(ext.Name) {
return errors.New("invalid extensions.name: only alphanumeric characters, hyphens, and underscores are allowed")
}
if _, found := exts[ext.Name]; found {
return fmt.Errorf("duplicated extension found in the configs for %q", ext.Name)
}
exts[ext.Name] = struct{}{}
svcTotal := len(ext.Backend.Services)
if svcTotal == 0 {
return fmt.Errorf("no backend service configured for extension %s", ext.Name)
}
for _, svc := range ext.Backend.Services {
if svc.URL == "" {
return errors.New("extensions.backend.services.url must be configured")
}
if svcTotal > 1 && svc.Cluster == nil {
return errors.New("extensions.backend.services.cluster must be configured when defining more than one service per extension")
}
if svc.Cluster != nil {
if svc.Cluster.Name == "" && svc.Cluster.Server == "" {
return errors.New("cluster.name or cluster.server must be defined when cluster is provided in the configuration")
}
}
if len(svc.Headers) > 0 {
for _, header := range svc.Headers {
if header.Name == "" {
return errors.New("header.name must be defined when providing service headers in the configuration")
}
if header.Value == "" {
return errors.New("header.value must be defined when providing service headers in the configuration")
}
}
}
}
}
return nil
}
// NewProxy will instantiate a new reverse proxy based on the provided
// targetURL and config. It will remove sensitive information from the
// incoming request such as the Authorization and Cookie headers.
func NewProxy(targetURL string, headers []Header, config ProxyConfig) (*httputil.ReverseProxy, error) {
url, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
}
proxy := &httputil.ReverseProxy{
Transport: newTransport(config),
Director: func(req *http.Request) {
req.Host = url.Host
req.URL.Scheme = url.Scheme
req.URL.Host = url.Host
req.Header.Set("Host", url.Host)
req.Header.Del("Authorization")
req.Header.Del("Cookie")
for _, header := range headers {
req.Header.Set(header.Name, header.Value)
}
},
}
return proxy, nil
}
// newTransport will build a new transport to be used in the proxy
// applying default values if not defined in the given config.
func newTransport(config ProxyConfig) *http.Transport {
applyProxyConfigDefaults(&config)
return &http.Transport{
DialContext: (&net.Dialer{
Timeout: config.ConnectionTimeout,
KeepAlive: config.KeepAlive,
}).DialContext,
MaxIdleConns: config.MaxIdleConnections,
IdleConnTimeout: config.IdleConnectionTimeout,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
}
func applyProxyConfigDefaults(c *ProxyConfig) {
if c.ConnectionTimeout == 0 {
c.ConnectionTimeout = DefaultConnectionTimeout
}
if c.KeepAlive == 0 {
c.KeepAlive = DefaultKeepAlive
}
if c.IdleConnectionTimeout == 0 {
c.IdleConnectionTimeout = DefaultIdleConnectionTimeout
}
if c.MaxIdleConnections == 0 {
c.MaxIdleConnections = DefaultMaxIdleConnections
}
}
// RegisterExtensions will retrieve all extensions configurations
// and update the extension registry.
func (m *Manager) RegisterExtensions() error {
settings, err := m.settings.Get()
if err != nil {
return fmt.Errorf("error getting settings: %w", err)
}
if len(settings.ExtensionConfig) == 0 {
m.log.Infof("No extensions configured.")
return nil
}
err = m.UpdateExtensionRegistry(settings)
if err != nil {
return fmt.Errorf("error updating extension registry: %w", err)
}
return nil
}
// UpdateExtensionRegistry will first parse and validate the extensions
// configurations from the given settings. If no errors are found, it will
// iterate over the given configurations building a new extension registry.
// At the end, it will update the manager with the newly created registry.
func (m *Manager) UpdateExtensionRegistry(s *settings.ArgoCDSettings) error {
extConfigs, err := parseAndValidateConfig(s)
if err != nil {
return fmt.Errorf("error parsing extension config: %w", err)
}
extReg := make(map[string]ProxyRegistry)
for _, ext := range extConfigs.Extensions {
proxyReg := NewProxyRegistry()
singleBackend := len(ext.Backend.Services) == 1
for _, service := range ext.Backend.Services {
proxy, err := NewProxy(service.URL, service.Headers, ext.Backend.ProxyConfig)
if err != nil {
return fmt.Errorf("error creating proxy: %w", err)
}
err = appendProxy(proxyReg, ext.Name, service, proxy, singleBackend)
if err != nil {
return fmt.Errorf("error appending proxy: %w", err)
}
}
extReg[ext.Name] = proxyReg
}
m.registry = extReg
return nil
}
// appendProxy will append the given proxy in the given registry. Will use
// the provided extName and service to determine the map key. The key must
// be unique in the map. If the map already has the key and error is returned.
func appendProxy(registry ProxyRegistry,
extName string,
service ServiceConfig,
proxy *httputil.ReverseProxy,
singleBackend bool,
) error {
if singleBackend {
key := proxyKey(extName, "", "")
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
return nil
}
// This is the case where there are more than one backend configured
// for this extension. In this case we need to add the provided cluster
// configurations for proper correlation to find which proxy to use
// while handling requests.
if service.Cluster.Name != "" {
key := proxyKey(extName, service.Cluster.Name, "")
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
}
if service.Cluster.Server != "" {
key := proxyKey(extName, "", service.Cluster.Server)
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
}
if service.Cluster.Name != "" && service.Cluster.Server != "" {
key := proxyKey(extName, service.Cluster.Name, service.Cluster.Server)
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
}
return nil
}
// authorize will enforce rbac rules are satisfied for the given RequestResources.
// The following validations are executed:
// - enforce the subject has permission to read application/project provided
// in HeaderArgoCDApplicationName and HeaderArgoCDProjectName.
// - enforce the subject has permission to invoke the extension identified by
// extName.
// - enforce that the project has permission to access the destination cluster.
//
// If all validations are satisfied it will return the Application resource
func (m *Manager) authorize(ctx context.Context, rr *RequestResources, extName string) (*v1alpha1.Application, error) {
if m.rbac == nil {
return nil, errors.New("rbac enforcer not set in extension manager")
}
appRBACName := security.RBACName(rr.ApplicationNamespace, rr.ProjectName, rr.ApplicationNamespace, rr.ApplicationName)
if err := m.rbac.EnforceErr(ctx.Value("claims"), rbac.ResourceApplications, rbac.ActionGet, appRBACName); err != nil {
return nil, fmt.Errorf("application authorization error: %w", err)
}
if err := m.rbac.EnforceErr(ctx.Value("claims"), rbac.ResourceExtensions, rbac.ActionInvoke, extName); err != nil {
return nil, fmt.Errorf("unauthorized to invoke extension %q: %w", extName, err)
}
// just retrieve the app after checking if subject has access to it
app, err := m.application.Get(rr.ApplicationNamespace, rr.ApplicationName)
if err != nil {
return nil, fmt.Errorf("error getting application: %w", err)
}
if app == nil {
return nil, fmt.Errorf("invalid Application provided in the %q header", HeaderArgoCDApplicationName)
}
if app.Spec.GetProject() != rr.ProjectName {
return nil, fmt.Errorf("project mismatch provided in the %q header", HeaderArgoCDProjectName)
}
proj, err := m.project.Get(app.Spec.GetProject())
if err != nil {
return nil, fmt.Errorf("error getting project: %w", err)
}
if proj == nil {
return nil, fmt.Errorf("invalid project provided in the %q header", HeaderArgoCDProjectName)
}
destCluster, err := argo.GetDestinationCluster(ctx, app.Spec.Destination, m.cluster)
if err != nil {
return nil, fmt.Errorf("error getting destination cluster: %w", err)
}
permitted, err := proj.IsDestinationPermitted(destCluster, app.Spec.Destination.Namespace, m.project.GetClusters)
if err != nil {
return nil, fmt.Errorf("error validating project destinations: %w", err)
}
if !permitted {
return nil, errors.New("the provided project is not allowed to access the cluster configured in the Application destination")
}
return app, nil
}
// findProxy will search the given registry to find the correct proxy to use
// based on the given extName and dest.
func findProxy(registry ProxyRegistry, extName string, dest v1alpha1.ApplicationDestination) (*httputil.ReverseProxy, error) {
// First try to find the proxy in the registry just by the extension name.
// This is the simple case for extensions with only one backend service.
key := proxyKey(extName, "", "")
if proxy, found := registry[key]; found {
return proxy, nil
}
// If extension has multiple backend services configured, the correct proxy
// needs to be searched by the ApplicationDestination.
key = proxyKey(extName, dest.Name, dest.Server)
if proxy, found := registry[key]; found {
return proxy, nil
}
return nil, fmt.Errorf("no proxy found for extension %q", extName)
}
// ProxyRegistry returns the proxy registry associated for the given
// extension name.
func (m *Manager) ProxyRegistry(name string) (ProxyRegistry, bool) {
pReg, found := m.registry[name]
return pReg, found
}
// CallExtension returns a handler func responsible for forwarding requests to the
// extension service. The request will be sanitized by removing sensitive headers.
func (m *Manager) CallExtension() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
if segments[0] != "extensions" {
http.Error(w, "Invalid URL: first segment must be "+URLPrefix, http.StatusBadRequest)
return
}
extName := segments[1]
if extName == "" {
http.Error(w, "Invalid URL: extension name must be provided", http.StatusBadRequest)
return
}
extName = strings.ReplaceAll(extName, "\n", "")
extName = strings.ReplaceAll(extName, "\r", "")
reqResources, err := ValidateHeaders(r)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid headers: %s", err), http.StatusBadRequest)
return
}
app, err := m.authorize(r.Context(), reqResources, extName)
if err != nil {
m.log.Infof("unauthorized extension request: %s", err)
http.Error(w, "Unauthorized extension request", http.StatusUnauthorized)
return
}
proxyRegistry, ok := m.ProxyRegistry(extName)
if !ok {
m.log.Warnf("proxy extension warning: attempt to call unregistered extension: %s", extName)
http.Error(w, "Extension not found", http.StatusNotFound)
return
}
proxy, err := findProxy(proxyRegistry, extName, app.Spec.Destination)
if err != nil {
m.log.Errorf("findProxy error: %s", err)
http.Error(w, "invalid extension", http.StatusBadRequest)
return
}
userId := m.userGetter.GetUserId(r.Context())
username := m.userGetter.GetUsername(r.Context())
groups := m.userGetter.GetGroups(r.Context())
prepareRequest(r, m.namespace, extName, app, userId, username, groups)
m.log.WithFields(log.Fields{
HeaderArgoCDUserId: userId,
HeaderArgoCDUsername: username,
HeaderArgoCDGroups: strings.Join(groups, ","),
HeaderArgoCDNamespace: m.namespace,
HeaderArgoCDApplicationName: fmt.Sprintf("%s:%s", app.GetNamespace(), app.GetName()),
"extension": extName,
"path": r.URL.Path,
}).Info("sending proxy extension request")
// httpsnoop package is used to properly wrap the responseWriter
// and avoid optional intefaces issue:
// https://github.com/felixge/httpsnoop#why-this-package-exists
// CaptureMetrics will call the proxy and return the metrics from it.
metrics := httpsnoop.CaptureMetrics(proxy, w, r)
go registerMetrics(extName, metrics, m.metricsReg)
}
}
func registerMetrics(extName string, metrics httpsnoop.Metrics, extensionMetricsRegistry ExtensionMetricsRegistry) {
if extensionMetricsRegistry != nil {
extensionMetricsRegistry.IncExtensionRequestCounter(extName, metrics.Code)
extensionMetricsRegistry.ObserveExtensionRequestDuration(extName, metrics.Duration)
}
}
// prepareRequest is responsible for cleaning the incoming request URL removing
// the Argo CD extension API section from it. It provides additional information to
// the backend service appending them in the outgoing request headers. The appended
// headers are:
// - Control plane namespace
// - Cluster destination name
// - Cluster destination server
// - Argo CD authenticated username
func prepareRequest(r *http.Request, namespace string, extName string, app *v1alpha1.Application, userId string, username string, groups []string) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%s/%s", URLPrefix, extName))
r.Header.Set(HeaderArgoCDNamespace, namespace)
if app.Spec.Destination.Name != "" {
r.Header.Set(HeaderArgoCDTargetClusterName, app.Spec.Destination.Name)
}
if app.Spec.Destination.Server != "" {
r.Header.Set(HeaderArgoCDTargetClusterURL, app.Spec.Destination.Server)
}
if userId != "" {
r.Header.Set(HeaderArgoCDUserId, userId)
}
if username != "" {
r.Header.Set(HeaderArgoCDUsername, username)
}
if len(groups) > 0 {
r.Header.Set(HeaderArgoCDGroups, strings.Join(groups, ","))
}
}
// AddMetricsRegistry will associate the given metricsReg in the Manager.
func (m *Manager) AddMetricsRegistry(metricsReg ExtensionMetricsRegistry) {
m.metricsReg = metricsReg
}