Skip to content
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

feat(security): implement tls block for grafana external #1628

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/v1beta1/grafana_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ type External struct {
AdminUser *v1.SecretKeySelector `json:"adminUser,omitempty"`
// AdminPassword key to talk to the external grafana instance.
AdminPassword *v1.SecretKeySelector `json:"adminPassword,omitempty"`
// TLS Configuration used to talk with the external grafana instance.
// +optional
TLS *ExternalTLSConfig `json:"tls,omitempty"`
}

// TLS Configuration to an external Grafana endpoint
// +kubebuilder:validation:XValidation:rule="(has(self.insecureSkipVerify) && !(has(self.certSecretRef))) || (has(self.certSecretRef) && !(has(self.insecureSkipVerify)))", message="insecureSkipVerify and certSecretRef cannot be set at the same time"
type ExternalTLSConfig struct {
// Disable the CA check of the server
// +optional
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
// Use a secret as a reference to give TLS Certificate information
// +optional
CertSecretRef *v1.SecretReference `json:"certSecretRef,omitempty"`
}

type JsonnetConfig struct {
Expand Down
25 changes: 25 additions & 0 deletions api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions config/crd/bases/grafana.integreatly.org_grafanas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8255,6 +8255,33 @@ spec:
- key
type: object
x-kubernetes-map-type: atomic
tls:
description: TLS Configuration used to talk with the external
grafana instance.
properties:
certSecretRef:
description: Use a secret as a reference to give TLS Certificate
information
properties:
name:
description: name is unique within a namespace to reference
a secret resource.
type: string
namespace:
description: namespace defines the space within which
the secret name must be unique.
type: string
type: object
x-kubernetes-map-type: atomic
insecureSkipVerify:
description: Disable the CA check of the server
type: boolean
type: object
x-kubernetes-validations:
- message: insecureSkipVerify and certSecretRef cannot be set
at the same time
rule: (has(self.insecureSkipVerify) && !(has(self.certSecretRef)))
|| (has(self.certSecretRef) && !(has(self.insecureSkipVerify)))
url:
description: URL of the external grafana instance you want to
manage.
Expand Down
25 changes: 7 additions & 18 deletions controllers/client/grafana_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,23 +124,6 @@ func getAdminCredentials(ctx context.Context, c client.Client, grafana *v1beta1.
return credentials, nil
}

func NewHTTPClient(grafana *v1beta1.Grafana) *http.Client {
var timeout time.Duration
if grafana.Spec.Client != nil && grafana.Spec.Client.TimeoutSeconds != nil {
timeout = time.Duration(*grafana.Spec.Client.TimeoutSeconds)
if timeout < 0 {
timeout = 0
}
} else {
timeout = 10
}

return &http.Client{
Transport: NewInstrumentedRoundTripper(grafana.Name, metrics.GrafanaApiRequests, grafana.IsExternal()),
Timeout: time.Second * timeout,
}
}

func InjectAuthHeaders(ctx context.Context, c client.Client, grafana *v1beta1.Grafana, req *http.Request) error {
creds, err := getAdminCredentials(ctx, c, grafana)
if err != nil {
Expand Down Expand Up @@ -170,12 +153,17 @@ func NewGeneratedGrafanaClient(ctx context.Context, c client.Client, grafana *v1
return nil, err
}

tlsConfig, err := buildTLSConfiguration(ctx, c, grafana)
if err != nil {
return nil, err
}

gURL, err := url.Parse(grafana.Status.AdminUrl)
if err != nil {
return nil, fmt.Errorf("parsing url for client: %w", err)
}

transport := NewInstrumentedRoundTripper(grafana.Name, metrics.GrafanaApiRequests, grafana.IsExternal())
transport := NewInstrumentedRoundTripper(grafana.Name, metrics.GrafanaApiRequests, grafana.IsExternal(), tlsConfig)

client := &http.Client{
Transport: transport,
Expand All @@ -191,6 +179,7 @@ func NewGeneratedGrafanaClient(ctx context.Context, c client.Client, grafana *v1
// NumRetries contains the optional number of attempted retries
NumRetries: 0,
Client: client,
TLSConfig: tlsConfig,
}
if credentials.username != "" {
cfg.BasicAuth = url.UserPassword(credentials.username, credentials.password)
Expand Down
33 changes: 33 additions & 0 deletions controllers/client/http_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package client

import (
"context"
"net/http"
"time"

"github.com/grafana/grafana-operator/v5/api/v1beta1"
"github.com/grafana/grafana-operator/v5/controllers/metrics"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func NewHTTPClient(ctx context.Context, c client.Client, grafana *v1beta1.Grafana) (*http.Client, error) {
var timeout time.Duration
if grafana.Spec.Client != nil && grafana.Spec.Client.TimeoutSeconds != nil {
timeout = time.Duration(*grafana.Spec.Client.TimeoutSeconds)
if timeout < 0 {
timeout = 0
}
} else {
timeout = 10
}

tlsConfig, err := buildTLSConfiguration(ctx, c, grafana)
if err != nil {
return nil, err
}

return &http.Client{
Transport: NewInstrumentedRoundTripper(grafana.Name, metrics.GrafanaApiRequests, grafana.IsExternal(), tlsConfig),
Timeout: time.Second * timeout,
}, nil
}
11 changes: 4 additions & 7 deletions controllers/client/round_tripper.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,14 @@ type instrumentedRoundTripper struct {
metric *prometheus.CounterVec
}

func NewInstrumentedRoundTripper(relatedResource string, metric *prometheus.CounterVec, useProxy bool) http.RoundTripper {
func NewInstrumentedRoundTripper(relatedResource string, metric *prometheus.CounterVec, useProxy bool, tlsConfig *tls.Config) http.RoundTripper {
transport := http.DefaultTransport.(*http.Transport).Clone()

transport.DisableKeepAlives = true
transport.MaxIdleConnsPerHost = -1
if transport.TLSClientConfig != nil {
transport.TLSClientConfig.InsecureSkipVerify = true //nolint
} else {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true, //nolint
}

if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
}

if !useProxy {
Expand Down
68 changes: 68 additions & 0 deletions controllers/client/tls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package client

import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"

"github.com/grafana/grafana-operator/v5/api/v1beta1"
v1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

var (
DefaultTLSConfiguration = &tls.Config{MinVersion: tls.VersionTLS12}
InsecureTLSConfiguration = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} // #nosec G402 - Linter disabled because InsecureSkipVerify is the wanted behavior for this variable
)

// build the tls.Config object based on the content of the Grafana CR object
func buildTLSConfiguration(ctx context.Context, c client.Client, grafana *v1beta1.Grafana) (*tls.Config, error) {
if grafana.IsInternal() || grafana.Spec.External.TLS == nil {
return nil, nil
}
tlsConfigBlock := grafana.Spec.External.TLS

if tlsConfigBlock.InsecureSkipVerify {
return InsecureTLSConfiguration, nil
}

tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}

secret := &v1.Secret{}
selector := client.ObjectKey{
Name: tlsConfigBlock.CertSecretRef.Name,
Namespace: grafana.Namespace,
}
err := c.Get(ctx, selector, secret)
if err != nil {
return nil, err
}

if secret.Data == nil {
return nil, fmt.Errorf("empty credential secret: %v/%v", grafana.Namespace, tlsConfigBlock.CertSecretRef.Name)
}

crt, crtPresent := secret.Data["tls.crt"]
key, keyPresent := secret.Data["tls.key"]

if (crtPresent && !keyPresent) || (keyPresent && !crtPresent) {
return nil, fmt.Errorf("invalid secret %v/%v. tls.crt and tls.key needs to be present together when one of them is declared", tlsConfigBlock.CertSecretRef.Namespace, tlsConfigBlock.CertSecretRef.Name)
} else if crtPresent && keyPresent {
loadedCrt, err := tls.X509KeyPair(crt, key)
if err != nil {
return nil, fmt.Errorf("certificate from secret %v/%v cannot be parsed : %w", grafana.Namespace, tlsConfigBlock.CertSecretRef.Name, err)
}
tlsConfig.Certificates = []tls.Certificate{loadedCrt}
}

if ca, ok := secret.Data["ca.crt"]; ok {
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("failed to add ca.crt from the secret %s/%s", tlsConfigBlock.CertSecretRef.Namespace, tlsConfigBlock.CertSecretRef.Name)
}
tlsConfig.RootCAs = caCertPool
}

return tlsConfig, nil
}
2 changes: 1 addition & 1 deletion controllers/dashboard_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ func (r *GrafanaDashboardReconciler) fetchDashboardJson(ctx context.Context, das
case v1beta1.DashboardSourceTypeGzipJson:
return v1beta1.Gunzip([]byte(dashboard.Spec.GzipJson))
case v1beta1.DashboardSourceTypeUrl:
return fetchers.FetchDashboardFromUrl(dashboard)
return fetchers.FetchDashboardFromUrl(dashboard, client2.InsecureTLSConfiguration)
case v1beta1.DashboardSourceTypeJsonnet:
envs, err := r.getDashboardEnvs(ctx, dashboard)
if err != nil {
Expand Down
11 changes: 7 additions & 4 deletions controllers/fetchers/grafana_com_fetcher.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fetchers

import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -20,8 +21,10 @@ func FetchDashboardFromGrafanaCom(dashboard *v1beta1.GrafanaDashboard) ([]byte,

source := dashboard.Spec.GrafanaCom

tlsConfig := client2.DefaultTLSConfiguration

if source.Revision == nil {
rev, err := getLatestGrafanaComRevision(dashboard)
rev, err := getLatestGrafanaComRevision(dashboard, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to get latest revision for dashboard id %d: %w", source.Id, err)
}
Expand All @@ -30,10 +33,10 @@ func FetchDashboardFromGrafanaCom(dashboard *v1beta1.GrafanaDashboard) ([]byte,

dashboard.Spec.Url = fmt.Sprintf("%s/%d/revisions/%d/download", grafanaComDashboardApiUrlRoot, source.Id, *source.Revision)

return FetchDashboardFromUrl(dashboard)
return FetchDashboardFromUrl(dashboard, tlsConfig)
}

func getLatestGrafanaComRevision(dashboard *v1beta1.GrafanaDashboard) (int, error) {
func getLatestGrafanaComRevision(dashboard *v1beta1.GrafanaDashboard, tlsConfig *tls.Config) (int, error) {
source := dashboard.Spec.GrafanaCom
url := fmt.Sprintf("%s/%d/revisions", grafanaComDashboardApiUrlRoot, source.Id)

Expand All @@ -42,7 +45,7 @@ func getLatestGrafanaComRevision(dashboard *v1beta1.GrafanaDashboard) (int, erro
return -1, err
}

client := client2.NewInstrumentedRoundTripper(fmt.Sprintf("%v/%v", dashboard.Namespace, dashboard.Name), metrics.GrafanaComApiRevisionRequests, true)
client := client2.NewInstrumentedRoundTripper(fmt.Sprintf("%v/%v", dashboard.Namespace, dashboard.Name), metrics.GrafanaComApiRevisionRequests, true, tlsConfig)
response, err := client.RoundTrip(request)
if err != nil {
return -1, err
Expand Down
5 changes: 3 additions & 2 deletions controllers/fetchers/url_fetcher.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fetchers

import (
"crypto/tls"
"fmt"
"io"
"net/http"
Expand All @@ -14,7 +15,7 @@ import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func FetchDashboardFromUrl(dashboard *v1beta1.GrafanaDashboard) ([]byte, error) {
func FetchDashboardFromUrl(dashboard *v1beta1.GrafanaDashboard, tlsConfig *tls.Config) ([]byte, error) {
url, err := url.Parse(dashboard.Spec.Url)
if err != nil {
return nil, err
Expand All @@ -30,7 +31,7 @@ func FetchDashboardFromUrl(dashboard *v1beta1.GrafanaDashboard) ([]byte, error)
return nil, err
}

client := client2.NewInstrumentedRoundTripper(fmt.Sprintf("%v/%v", dashboard.Namespace, dashboard.Name), metrics.DashboardUrlRequests, true)
client := client2.NewInstrumentedRoundTripper(fmt.Sprintf("%v/%v", dashboard.Namespace, dashboard.Name), metrics.DashboardUrlRequests, true, tlsConfig)
response, err := client.RoundTrip(request)
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion controllers/fetchers/url_fetcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestFetchDashboardFromUrl(t *testing.T) {
Status: v1beta1.GrafanaDashboardStatus{},
}

fetchedDashboard, err := FetchDashboardFromUrl(dashboard)
fetchedDashboard, err := FetchDashboardFromUrl(dashboard, nil)
assert.Nil(t, err)
assert.Equal(t, dashboardJSON, fetchedDashboard, "Fetched dashboard doesn't match the original")

Expand Down
11 changes: 7 additions & 4 deletions controllers/grafana_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (r *GrafanaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
nextStatus.Stage = grafanav1beta1.OperatorStageComplete
nextStatus.StageStatus = grafanav1beta1.OperatorStageResultSuccess
nextStatus.AdminUrl = grafana.Spec.External.URL
v, err := r.getVersion(grafana)
v, err := r.getVersion(ctx, grafana)
if err != nil {
controllerLog.Error(err, "failed to get version from external instance")
}
Expand Down Expand Up @@ -137,7 +137,7 @@ func (r *GrafanaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
}

if finished {
v, err := r.getVersion(grafana)
v, err := r.getVersion(ctx, grafana)
if err != nil {
controllerLog.Error(err, "failed to get version from instance")
}
Expand All @@ -148,8 +148,11 @@ func (r *GrafanaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
return r.updateStatus(grafana, nextStatus)
}

func (r *GrafanaReconciler) getVersion(cr *grafanav1beta1.Grafana) (string, error) {
cl := client2.NewHTTPClient(cr)
func (r *GrafanaReconciler) getVersion(ctx context.Context, cr *grafanav1beta1.Grafana) (string, error) {
cl, err := client2.NewHTTPClient(ctx, r.Client, cr)
if err != nil {
return "", fmt.Errorf("setup of the http client: %w", err)
}
instanceUrl := cr.Status.AdminUrl
if instanceUrl == "" && cr.Spec.External != nil {
instanceUrl = cr.Spec.External.URL
Expand Down
Loading
Loading