SupportBundles: Add bundle encryption based on age (#62501)

* add bundle encryption based on age

* undo changes to grafana-data

* sort deps

* test bundle creation and encryption

* use whitespace separator

* add support bundle config documentation

* Update docs/sources/troubleshooting/support-bundles/index.md

* Apply suggestions from code review

Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>

* touch up docs

* extract encrypt

* Update docs/sources/troubleshooting/support-bundles/index.md

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>

* Update docs/sources/troubleshooting/support-bundles/index.md

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>

---------

Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
This commit is contained in:
Jo 2023-02-24 15:24:44 +00:00 committed by GitHub
parent 966bcd3545
commit af987ae636
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 398 additions and 28 deletions

View File

@ -1377,6 +1377,14 @@ max_crawl_duration =
# This setting should be expressed as a duration. Examples: 10s (seconds), 1m (minutes).
scheduler_interval =
#################################### Support Bundles #####################################
[support_bundles]
# Enable support bundle creation (default: true)
enabled = true
# Only server admins can generate and view support bundles (default: true)
server_admin_only = true
# If set, bundles will be encrypted with the provided public keys separated by whitespace
public_keys = ""
#################################### Storage ################################################

View File

@ -1263,6 +1263,14 @@
;grpc_host =
;grpc_port =
[support_bundles]
# Enable support bundle creation (default: true)
#enabled = true
# Only server admins can generate and view support bundles (default: true)
#server_admin_only = true
# If set, bundles will be encrypted with the provided public keys separated by whitespace
#public_keys = ""
[enterprise]
# Path to a valid Grafana Enterprise license.jwt file
;license_path =

View File

@ -53,3 +53,71 @@ To generate a support bundle and send the support bundle to Grafana Labs via a s
Grafana downloads the support bundle to an archive (tar.gz) file.
1. Attach the archive (tar.gz) file to a support ticket that you send to Grafana Labs Technical Support.
## Support bundle configuration
You can configure the following settings for support bundles:
```ini
# Enable support bundle creation (default: true)
enabled = true
# Only server admins can generate and view support bundles. When set to false, organization admins can generate and view support bundles (default: true)
server_admin_only = true
# If set, bundles will be encrypted with the provided public keys separated by whitespace
public_keys = ""
```
## Encrypting a support bundle
Support bundles can be encrypted with [age](age-encryption.org) before they are sent to
recipients. This is useful when you want to send a support bundle to Grafana through a
channel that is not private.
### Generate a key pair
Ensure [age](https://github.com/FiloSottile/age#installation) is installed on your system.
```bash
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
```
### Support bundle encryption
Ensure [age](https://github.com/FiloSottile/age#installation) is installed on your system.
Add the public key to the `public_keys` setting in the `support_bundle` section of the Grafana configuration file.
```ini
[support_bundle]
public_keys = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
```
> Multiple public keys can be defined by separating them with whitespace.
> All included public keys will be able to decrypt the support bundle.
Example:
```ini
[support_bundle]
public_keys = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p age1yu8vzu554pv3klw46yhdv4raz36k5w3vy30lpxn46923lqngudyqvxacer"
```
When you restart Grafana, new support bundles will be encrypted with the provided
public keys. The support bundle file extension is `tar.gz.age`.
#### Decrypt a support bundle
Ensure [age](https://github.com/FiloSottile/age#installation) is installed on your system.
Execute the following command to decrypt the support bundle:
```bash
age --decrypt -i keyfile -o output.tar.gz downloaded.tar.gz.age
```
Example:
```bash
age --decrypt -i key.txt -o data.tar.gz af6684b4-d613-4b31-9fc3-7cb579199bea.tar.gz.age
```

3
go.mod
View File

@ -131,7 +131,7 @@ require (
gopkg.in/square/go-jose.v2 v2.5.1
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1
xorm.io/builder v0.3.6 // indirect
xorm.io/builder v0.3.6
xorm.io/core v0.7.3
xorm.io/xorm v0.8.2
)
@ -348,6 +348,7 @@ require (
require (
cloud.google.com/go/compute v1.13.0 // indirect
cloud.google.com/go/iam v0.8.0 // indirect
filippo.io/age v1.1.1
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v0.7.0 // indirect

4
go.sum
View File

@ -101,6 +101,8 @@ contrib.go.opencensus.io/exporter/prometheus v0.3.0/go.mod h1:rpCPVQKhiyH8oomWgm
contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8=
contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE=
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e/go.mod h1:Xa6lInWHNQnuWoF0YPSsx+INFA9qk7/7pTjwb3PInkY=
@ -1264,8 +1266,6 @@ github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 h1:2
github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE=
github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M=
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.148.0 h1:M8v6L9agAFMlZMnak1yInII+aVF5FjZ1Qv4Q+GANyk4=
github.com/grafana/grafana-plugin-sdk-go v0.148.0/go.mod h1:NMgO3t2gR5wyLx8bWZ9CTmpDk5Txp4wYFccFLHdYn3Q=
github.com/grafana/grafana-plugin-sdk-go v0.149.1 h1:n4Mx8oUE+exa1DGdWSUmp2DuZUDhURQEzPG05HUGfnc=
github.com/grafana/grafana-plugin-sdk-go v0.149.1/go.mod h1:NMgO3t2gR5wyLx8bWZ9CTmpDk5Txp4wYFccFLHdYn3Q=
github.com/grafana/phlare/api v0.1.2 h1:1jrwd3KnsXMzj/tJih9likx5EvbY3pbvLbDqAAYem30=

View File

@ -0,0 +1,77 @@
package kvstore
import (
"context"
"errors"
"strings"
)
// In memory kv store used for testing
type FakeKVStore struct {
store map[Key]string
delError bool
}
func NewFakeKVStore() *FakeKVStore {
return &FakeKVStore{store: make(map[Key]string)}
}
func (f *FakeKVStore) DeletionError(shouldErr bool) {
f.delError = shouldErr
}
func (f *FakeKVStore) Get(ctx context.Context, orgId int64, namespace string, key string) (string, bool, error) {
value := f.store[buildKey(orgId, namespace, key)]
found := value != ""
return value, found, nil
}
func (f *FakeKVStore) Set(ctx context.Context, orgId int64, namespace string, key string, value string) error {
f.store[buildKey(orgId, namespace, key)] = value
return nil
}
func (f *FakeKVStore) Del(ctx context.Context, orgId int64, namespace string, key string) error {
if f.delError {
return errors.New("mocked del error")
}
delete(f.store, buildKey(orgId, namespace, key))
return nil
}
// List all keys with an optional filter. If default values are provided, filter is not applied.
func (f *FakeKVStore) Keys(ctx context.Context, orgId int64, namespace string, keyPrefix string) ([]Key, error) {
res := make([]Key, 0)
for k := range f.store {
if orgId == AllOrganizations && namespace == "" && keyPrefix == "" {
res = append(res, k)
} else if k.OrgId == orgId && k.Namespace == namespace && strings.HasPrefix(k.Key, keyPrefix) {
res = append(res, k)
}
}
return res, nil
}
func (f *FakeKVStore) GetAll(ctx context.Context, orgId int64, namespace string) (map[int64]map[string]string, error) {
items := make(map[int64]map[string]string)
for k := range f.store {
orgId := k.OrgId
namespace := k.Namespace
if _, ok := items[orgId]; !ok {
items[orgId] = make(map[string]string)
}
items[orgId][namespace] = f.store[k]
}
return items, nil
}
func buildKey(orgId int64, namespace string, key string) Key {
return Key{
OrgId: orgId,
Namespace: namespace,
Key: key,
}
}

View File

@ -99,6 +99,10 @@ func (s *Service) handleDownload(ctx *contextmodel.ReqContext) response.Response
ctx.Resp.Header().Set("Content-Type", "application/tar+gzip")
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.tar.gz", uid))
if len(s.encryptionPublicKeys) > 0 {
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.tar.gz.age", uid))
}
return response.CreateNormalResponse(ctx.Resp.Header(), bundle.TarBytes, http.StatusOK)
}

View File

@ -27,45 +27,48 @@ const (
)
type Service struct {
cfg *setting.Cfg
store bundleStore
pluginStore plugins.Store
pluginSettings pluginsettings.Service
accessControl ac.AccessControl
features *featuremgmt.FeatureManager
bundleRegistry *bundleregistry.Service
cfg *setting.Cfg
features *featuremgmt.FeatureManager
pluginSettings pluginsettings.Service
pluginStore plugins.Store
store bundleStore
log log.Logger
log log.Logger
encryptionPublicKeys []string
enabled bool
serverAdminOnly bool
}
func ProvideService(cfg *setting.Cfg,
bundleRegistry *bundleregistry.Service,
sql db.DB,
kvStore kvstore.KVStore,
func ProvideService(
accessControl ac.AccessControl,
accesscontrolService ac.Service,
routeRegister routing.RouteRegister,
settings setting.Provider,
pluginStore plugins.Store,
pluginSettings pluginsettings.Service,
bundleRegistry *bundleregistry.Service,
cfg *setting.Cfg,
features *featuremgmt.FeatureManager,
httpServer *grafanaApi.HTTPServer,
kvStore kvstore.KVStore,
pluginSettings pluginsettings.Service,
pluginStore plugins.Store,
routeRegister routing.RouteRegister,
settings setting.Provider,
sql db.DB,
usageStats usagestats.Service) (*Service, error) {
section := cfg.SectionWithEnvOverrides("support_bundles")
s := &Service{
cfg: cfg,
store: newStore(kvStore),
pluginStore: pluginStore,
pluginSettings: pluginSettings,
accessControl: accessControl,
features: features,
bundleRegistry: bundleRegistry,
log: log.New("supportbundle.service"),
enabled: section.Key("enabled").MustBool(true),
serverAdminOnly: section.Key("server_admin_only").MustBool(true),
accessControl: accessControl,
bundleRegistry: bundleRegistry,
cfg: cfg,
enabled: section.Key("enabled").MustBool(true),
encryptionPublicKeys: section.Key("public_keys").Strings(" "),
features: features,
log: log.New("supportbundle.service"),
pluginSettings: pluginSettings,
pluginStore: pluginStore,
serverAdminOnly: section.Key("server_admin_only").MustBool(true),
store: newStore(kvStore),
}
usageStats.RegisterMetricsFunc(s.getUsageStats)

View File

@ -6,11 +6,14 @@ import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"path/filepath"
"runtime/debug"
"time"
"filippo.io/age"
"github.com/grafana/grafana/pkg/services/supportbundles"
)
@ -92,7 +95,43 @@ func (s *Service) bundle(ctx context.Context, collectors []string, uid string) (
return nil, errCompress
}
return buf.Bytes(), nil
final := buf
if len(s.encryptionPublicKeys) > 0 {
var err error
final, err = encrypt(buf, s.encryptionPublicKeys...)
if err != nil {
return nil, err
}
}
return final.Bytes(), nil
}
func encrypt(buf bytes.Buffer, publicKeys ...string) (bytes.Buffer, error) {
final := bytes.Buffer{}
recipients := make([]age.Recipient, 0, len(publicKeys))
for _, key := range publicKeys {
recipient, err := age.ParseX25519Recipient(key)
if err != nil {
return final, fmt.Errorf("unable to parse support bundle recipient public key: %w", err)
}
recipients = append(recipients, recipient)
}
w, err := age.Encrypt(&final, recipients...)
if err != nil {
return final, fmt.Errorf("unable to open support bundle encryption header: %w", err)
}
if _, err = w.Write(buf.Bytes()); err != nil {
return final, fmt.Errorf("unable to write support bundle encryption: %w", err)
}
if err := w.Close(); err != nil {
return final, fmt.Errorf("unable to close support bundle encryption: %w", err)
}
return final, nil
}
func compress(files map[string][]byte, buf io.Writer) error {

View File

@ -0,0 +1,162 @@
package supportbundlesimpl
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"testing"
"filippo.io/age"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/supportbundles"
"github.com/grafana/grafana/pkg/services/supportbundles/bundleregistry"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
const (
testAgePublicKey = "age15xgfm9gg89nz92pqjat2hy9h7lpwwfwwgvchg67lqtzzhjtmg5vq9utnd4"
testAgePrivateKey = "AGE-SECRET-KEY-1HGMLT8VSC95UXN2R5LUZECXT42WW7TSEYQKCWLX7PKH3YHS6HGCQ0XVEFD"
testAgePublicKey2 = "age1q02sf508xetfa5ztzhuw0hxweyd50n27qndufaffvdth25knueds8w99c5"
testAgePrivateKey2 = "AGE-SECRET-KEY-1DSW2P60F2ZRY4D4M57PEKTFVYCXXDYYZ0VZWG5RTUZCWHR3EJ9TQP92JXQ"
)
func TestService_bundleCreate(t *testing.T) {
s := &Service{
log: log.New("test"),
bundleRegistry: bundleregistry.ProvideService(),
store: newStore(kvstore.NewFakeKVStore()),
}
cfg := setting.NewCfg()
collector := basicCollector(cfg)
s.bundleRegistry.RegisterSupportItemCollector(collector)
createdBundle, err := s.store.Create(context.Background(), &user.SignedInUser{UserID: 1, Login: "bob"})
require.NoError(t, err)
s.startBundleWork(context.Background(), []string{collector.UID}, createdBundle.UID)
bundle, err := s.get(context.Background(), createdBundle.UID)
require.NoError(t, err)
assert.Equal(t, createdBundle.UID, bundle.UID)
assert.Equal(t, supportbundles.StateComplete, bundle.State)
assert.Equal(t, "bob", bundle.Creator)
assert.NotZero(t, len(bundle.TarBytes))
confirmFilesInTar(t, bundle.TarBytes)
}
func TestService_bundleEncryptDecrypt(t *testing.T) {
s := &Service{
log: log.New("test"),
bundleRegistry: bundleregistry.ProvideService(),
store: newStore(kvstore.NewFakeKVStore()),
encryptionPublicKeys: []string{testAgePublicKey},
}
cfg := setting.NewCfg()
collector := basicCollector(cfg)
s.bundleRegistry.RegisterSupportItemCollector(collector)
createdBundle, err := s.store.Create(context.Background(), &user.SignedInUser{UserID: 1, Login: "bob"})
require.NoError(t, err)
s.startBundleWork(context.Background(), []string{collector.UID}, createdBundle.UID)
bundle, err := s.get(context.Background(), createdBundle.UID)
require.NoError(t, err)
assert.Equal(t, createdBundle.UID, bundle.UID)
assert.Equal(t, supportbundles.StateComplete, bundle.State)
assert.Equal(t, "bob", bundle.Creator)
assert.NotZero(t, len(bundle.TarBytes))
tarBytes := decryptTar(t, bundle.TarBytes, testAgePrivateKey)
assert.NotZero(t, len(tarBytes))
confirmFilesInTar(t, tarBytes)
}
func TestService_bundleEncryptDecryptMultipleRecipients(t *testing.T) {
s := &Service{
log: log.New("test"),
bundleRegistry: bundleregistry.ProvideService(),
store: newStore(kvstore.NewFakeKVStore()),
encryptionPublicKeys: []string{testAgePublicKey, testAgePublicKey2},
}
cfg := setting.NewCfg()
collector := basicCollector(cfg)
s.bundleRegistry.RegisterSupportItemCollector(collector)
createdBundle, err := s.store.Create(context.Background(), &user.SignedInUser{UserID: 1, Login: "bob"})
require.NoError(t, err)
s.startBundleWork(context.Background(), []string{collector.UID}, createdBundle.UID)
bundle, err := s.get(context.Background(), createdBundle.UID)
require.NoError(t, err)
assert.Equal(t, createdBundle.UID, bundle.UID)
assert.Equal(t, supportbundles.StateComplete, bundle.State)
assert.Equal(t, "bob", bundle.Creator)
assert.NotZero(t, len(bundle.TarBytes))
tarBytes := decryptTar(t, bundle.TarBytes, testAgePrivateKey)
assert.NotZero(t, len(tarBytes))
confirmFilesInTar(t, tarBytes)
tarBytes2 := decryptTar(t, bundle.TarBytes, testAgePrivateKey2)
assert.NotZero(t, len(tarBytes2))
confirmFilesInTar(t, tarBytes2)
}
func decryptTar(t *testing.T, tarBytes []byte, privateKey string) []byte {
reader := bytes.NewReader(tarBytes)
t.Helper()
recipientPK, err := age.ParseX25519Identity(privateKey)
require.NoError(t, err)
tarBytesReader, err := age.Decrypt(reader, recipientPK)
require.NoError(t, err)
newTarBytes, err := io.ReadAll(tarBytesReader)
require.NoError(t, err)
return newTarBytes
}
// Check that the tarball contains the expected files
func confirmFilesInTar(t *testing.T, tarBytes []byte) {
t.Helper()
r := bytes.NewReader(tarBytes)
gzipReader, err := gzip.NewReader(r)
require.NoError(t, err)
tr := tar.NewReader(gzipReader)
files := []string{}
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
files = append(files, hdr.Name)
}
assert.ElementsMatch(t, []string{"/bundle/basic.json"}, files)
}