Chore: Refactor manifest verifier (#67218)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
const publicKeySyncInterval = 10 * 24 * time.Hour // 10 days
|
||||
|
||||
// ManifestKeys is the database representation of public keys
|
||||
// used to verify plugin manifests.
|
||||
type ManifestKeys struct {
|
||||
KeyID string `json:"keyId"`
|
||||
PublicKey string `json:"public"`
|
||||
Since int64 `json:"since"`
|
||||
}
|
||||
|
||||
type KeyRetriever struct {
|
||||
cfg *config.Cfg
|
||||
log log.Logger
|
||||
|
||||
lock sync.Mutex
|
||||
cli http.Client
|
||||
kv plugins.KeyStore
|
||||
hasKeys bool
|
||||
}
|
||||
|
||||
var _ plugins.KeyRetriever = (*KeyRetriever)(nil)
|
||||
|
||||
func ProvideService(cfg *config.Cfg, kv plugins.KeyStore) *KeyRetriever {
|
||||
kr := &KeyRetriever{
|
||||
cfg: cfg,
|
||||
log: log.New("plugin.signature.key_retriever"),
|
||||
cli: makeHttpClient(),
|
||||
kv: kv,
|
||||
}
|
||||
return kr
|
||||
}
|
||||
|
||||
// IsDisabled disables dynamic retrieval of public keys from the API server.
|
||||
func (kr *KeyRetriever) IsDisabled() bool {
|
||||
return !kr.cfg.Features.IsEnabled(featuremgmt.FlagPluginsAPIManifestKey)
|
||||
}
|
||||
|
||||
func (kr *KeyRetriever) Run(ctx context.Context) error {
|
||||
// do an initial update if necessary
|
||||
err := kr.updateKeys(ctx)
|
||||
if err != nil {
|
||||
kr.log.Error("Error downloading plugin manifest keys", "error", err)
|
||||
}
|
||||
|
||||
// calculate initial send delay
|
||||
lastUpdated, err := kr.kv.GetLastUpdated(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextSendInterval := time.Until(lastUpdated.Add(publicKeySyncInterval))
|
||||
if nextSendInterval < time.Minute {
|
||||
nextSendInterval = time.Minute
|
||||
}
|
||||
|
||||
downloadKeysTicker := time.NewTicker(nextSendInterval)
|
||||
defer downloadKeysTicker.Stop()
|
||||
|
||||
select {
|
||||
case <-downloadKeysTicker.C:
|
||||
err = kr.updateKeys(ctx)
|
||||
if err != nil {
|
||||
kr.log.Error("Error downloading plugin manifest keys", "error", err)
|
||||
}
|
||||
|
||||
if nextSendInterval != publicKeySyncInterval {
|
||||
nextSendInterval = publicKeySyncInterval
|
||||
downloadKeysTicker.Reset(nextSendInterval)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (kr *KeyRetriever) updateKeys(ctx context.Context) error {
|
||||
kr.lock.Lock()
|
||||
defer kr.lock.Unlock()
|
||||
|
||||
lastUpdated, err := kr.kv.GetLastUpdated(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if time.Since(*lastUpdated) < publicKeySyncInterval {
|
||||
// Cache is still valid
|
||||
return nil
|
||||
}
|
||||
|
||||
return kr.downloadKeys(ctx)
|
||||
}
|
||||
|
||||
// Retrieve the key from the API and store it in the database
|
||||
func (kr *KeyRetriever) downloadKeys(ctx context.Context) error {
|
||||
var data struct {
|
||||
Items []ManifestKeys
|
||||
}
|
||||
|
||||
url, err := url.JoinPath(kr.cfg.GrafanaComURL, "/api/plugins/ci/keys") // nolint:gosec URL is provided by config
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := kr.cli.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
kr.log.Warn("error closing response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(data.Items) == 0 {
|
||||
return errors.New("missing public key")
|
||||
}
|
||||
|
||||
cachedKeys, err := kr.kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shouldKeep := make(map[string]bool)
|
||||
for _, key := range data.Items {
|
||||
err = kr.kv.Set(ctx, key.KeyID, key.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shouldKeep[key.KeyID] = true
|
||||
}
|
||||
|
||||
// Delete keys that are no longer in the API
|
||||
for _, key := range cachedKeys {
|
||||
if !shouldKeep[key] {
|
||||
err = kr.kv.Del(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last updated timestamp
|
||||
return kr.kv.SetLastUpdated(ctx)
|
||||
}
|
||||
|
||||
func (kr *KeyRetriever) ensureKeys(ctx context.Context) error {
|
||||
if kr.hasKeys {
|
||||
return nil
|
||||
}
|
||||
keys, err := kr.kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
// Populate with the default key
|
||||
err := kr.kv.Set(ctx, statickey.GetDefaultKeyID(), statickey.GetDefaultKey())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
kr.hasKeys = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPublicKey loads public keys from:
|
||||
// - The hard-coded value if the feature flag is not enabled.
|
||||
// - A cached value from kv storage if it has been already retrieved. This cache is populated from the grafana.com API.
|
||||
func (kr *KeyRetriever) GetPublicKey(ctx context.Context, keyID string) (string, error) {
|
||||
kr.lock.Lock()
|
||||
defer kr.lock.Unlock()
|
||||
|
||||
err := kr.ensureKeys(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key, exist, err := kr.kv.Get(ctx, keyID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exist {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("missing public key for %s", keyID)
|
||||
}
|
||||
|
||||
// Same configuration as pkg/plugins/repo/client.go
|
||||
func makeHttpClient() http.Client {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
return http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: tr,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keystore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setFakeAPIServer(t *testing.T, publicKey string, keyID string) (*httptest.Server, chan bool) {
|
||||
done := make(chan bool)
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/plugins/ci/keys" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
data := struct {
|
||||
Items []ManifestKeys `json:"items"`
|
||||
}{
|
||||
Items: []ManifestKeys{{PublicKey: publicKey, KeyID: keyID}},
|
||||
}
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
done <- true
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
done <- true
|
||||
})), done
|
||||
}
|
||||
func Test_PublicKeyUpdate(t *testing.T) {
|
||||
t.Run("it should retrieve an API key", func(t *testing.T) {
|
||||
cfg := &config.Cfg{
|
||||
Features: featuremgmt.WithFeatures([]interface{}{featuremgmt.FlagPluginsAPIManifestKey}...),
|
||||
}
|
||||
expectedKey := "fake"
|
||||
s, done := setFakeAPIServer(t, expectedKey, "7e4d0c6a708866e7")
|
||||
cfg.GrafanaComURL = s.URL
|
||||
v := ProvideService(cfg, keystore.ProvideService(kvstore.NewFakeKVStore()))
|
||||
go func() {
|
||||
err := v.Run(context.Background())
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
<-done
|
||||
|
||||
// wait for the lock to be free
|
||||
v.lock.Lock()
|
||||
defer v.lock.Unlock()
|
||||
res, found, err := v.kv.Get(context.Background(), "7e4d0c6a708866e7")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, found)
|
||||
require.Equal(t, expectedKey, res)
|
||||
})
|
||||
|
||||
t.Run("it should update the latest update date", func(t *testing.T) {
|
||||
cfg := &config.Cfg{
|
||||
Features: featuremgmt.WithFeatures([]interface{}{featuremgmt.FlagPluginsAPIManifestKey}...),
|
||||
}
|
||||
expectedKey := "fake"
|
||||
s, done := setFakeAPIServer(t, expectedKey, "7e4d0c6a708866e7")
|
||||
cfg.GrafanaComURL = s.URL
|
||||
v := ProvideService(cfg, keystore.ProvideService(kvstore.NewFakeKVStore()))
|
||||
go func() {
|
||||
err := v.Run(context.Background())
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
<-done
|
||||
|
||||
// wait for the lock to be free
|
||||
v.lock.Lock()
|
||||
defer v.lock.Unlock()
|
||||
ti, err := v.kv.GetLastUpdated(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Less(t, time.Time{}, *ti)
|
||||
})
|
||||
|
||||
t.Run("it should remove old keys", func(t *testing.T) {
|
||||
cfg := &config.Cfg{
|
||||
Features: featuremgmt.WithFeatures([]interface{}{featuremgmt.FlagPluginsAPIManifestKey}...),
|
||||
}
|
||||
expectedKey := "fake"
|
||||
s, done := setFakeAPIServer(t, expectedKey, "other")
|
||||
cfg.GrafanaComURL = s.URL
|
||||
v := ProvideService(cfg, keystore.ProvideService(kvstore.NewFakeKVStore()))
|
||||
go func() {
|
||||
err := v.Run(context.Background())
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
<-done
|
||||
|
||||
// wait for the lock to be free
|
||||
v.lock.Lock()
|
||||
defer v.lock.Unlock()
|
||||
_, found, err := v.kv.Get(context.Background(), "7e4d0c6a708866e7")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, found)
|
||||
|
||||
res, found, err := v.kv.Get(context.Background(), "other")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, found)
|
||||
require.Equal(t, expectedKey, res)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic"
|
||||
)
|
||||
|
||||
var _ plugins.KeyRetriever = (*Service)(nil)
|
||||
|
||||
type Service struct {
|
||||
kr plugins.KeyRetriever
|
||||
}
|
||||
|
||||
func ProvideService(dkr *dynamic.KeyRetriever) *Service {
|
||||
s := &Service{}
|
||||
if !dkr.IsDisabled() {
|
||||
s.kr = dkr
|
||||
} else {
|
||||
s.kr = statickey.New()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (kr *Service) GetPublicKey(ctx context.Context, keyID string) (string, error) {
|
||||
return kr.kr.GetPublicKey(ctx, keyID)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keystore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_GetPublicKey(t *testing.T) {
|
||||
t.Run("it should return a static key", func(t *testing.T) {
|
||||
cfg := &config.Cfg{
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
}
|
||||
kr := ProvideService(dynamic.ProvideService(cfg, keystore.ProvideService(kvstore.NewFakeKVStore())))
|
||||
key, err := kr.GetPublicKey(context.Background(), statickey.GetDefaultKeyID())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, statickey.GetDefaultKey(), key)
|
||||
})
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/oauthtoken"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/config"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keystore"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/licensing"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
|
||||
@@ -71,6 +73,9 @@ var WireSet = wire.NewSet(
|
||||
signature.ProvideService,
|
||||
wire.Bind(new(plugins.KeyStore), new(*keystore.Service)),
|
||||
keystore.ProvideService,
|
||||
wire.Bind(new(plugins.KeyRetriever), new(*keyretriever.Service)),
|
||||
keyretriever.ProvideService,
|
||||
dynamic.ProvideService,
|
||||
)
|
||||
|
||||
// WireExtensionSet provides a wire.ProviderSet of plugin providers that can be
|
||||
|
||||
Reference in New Issue
Block a user