From https://github.com/tailscale/tailscale/pull/1919 with edits by bradfitz@. This change introduces a new storage provider for the state file. It allows users to leverage AWS SSM parameter store natively within tailscaled, like: $ tailscaled --state=arn:aws:ssm:eu-west-1:123456789:parameter/foo Known limitations: - it is not currently possible to specific a custom KMS key ID RELNOTE=tailscaled on Linux supports using AWS SSM for state Edits-By: Brad Fitzpatrick <bradfitz@tailscale.com> Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Signed-off-by: Maxime VISONNEAU <maxime.visonneau@gmail.com>main
parent
1b20d1ce54
commit
4528f448d6
@ -0,0 +1,175 @@ |
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
// Package aws contains an AWS SSM StateStore implementation.
|
||||
package aws |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"regexp" |
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws" |
||||
"github.com/aws/aws-sdk-go-v2/aws/arn" |
||||
"github.com/aws/aws-sdk-go-v2/config" |
||||
"github.com/aws/aws-sdk-go-v2/service/ssm" |
||||
ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" |
||||
"tailscale.com/ipn" |
||||
) |
||||
|
||||
const ( |
||||
parameterNameRxStr = `^parameter/(.*)` |
||||
) |
||||
|
||||
var parameterNameRx = regexp.MustCompile(parameterNameRxStr) |
||||
|
||||
// awsSSMClient is an interface allowing us to mock the couple of
|
||||
// API calls we are leveraging with the AWSStore provider
|
||||
type awsSSMClient interface { |
||||
GetParameter(ctx context.Context, |
||||
params *ssm.GetParameterInput, |
||||
optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) |
||||
|
||||
PutParameter(ctx context.Context, |
||||
params *ssm.PutParameterInput, |
||||
optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) |
||||
} |
||||
|
||||
// store is a store which leverages AWS SSM parameter store
|
||||
// to persist the state
|
||||
type awsStore struct { |
||||
ssmClient awsSSMClient |
||||
ssmARN arn.ARN |
||||
|
||||
memory ipn.MemoryStore |
||||
} |
||||
|
||||
// NewStore returns a new ipn.StateStore using the AWS SSM storage
|
||||
// location given by ssmARN.
|
||||
func NewStore(ssmARN string) (ipn.StateStore, error) { |
||||
return newStore(ssmARN, nil) |
||||
} |
||||
|
||||
// newStore is NewStore, but for tests. If client is non-nil, it's
|
||||
// used instead of making one.
|
||||
func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) { |
||||
s := &awsStore{ |
||||
ssmClient: client, |
||||
} |
||||
|
||||
var err error |
||||
|
||||
// Parse the ARN
|
||||
if s.ssmARN, err = arn.Parse(ssmARN); err != nil { |
||||
return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err) |
||||
} |
||||
|
||||
// Validate the ARN corresponds to the SSM service
|
||||
if s.ssmARN.Service != "ssm" { |
||||
return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service) |
||||
} |
||||
|
||||
// Validate the ARN corresponds to a parameter store resource
|
||||
if !parameterNameRx.MatchString(s.ssmARN.Resource) { |
||||
return nil, fmt.Errorf("invalid resource %q, expected to match %v", s.ssmARN.Resource, parameterNameRxStr) |
||||
} |
||||
|
||||
if s.ssmClient == nil { |
||||
var cfg aws.Config |
||||
if cfg, err = config.LoadDefaultConfig( |
||||
context.TODO(), |
||||
config.WithRegion(s.ssmARN.Region), |
||||
); err != nil { |
||||
return nil, err |
||||
} |
||||
s.ssmClient = ssm.NewFromConfig(cfg) |
||||
} |
||||
|
||||
// Hydrate cache with the potentially current state
|
||||
if err := s.LoadState(); err != nil { |
||||
return nil, err |
||||
} |
||||
return s, nil |
||||
|
||||
} |
||||
|
||||
// LoadState attempts to read the state from AWS SSM parameter store key.
|
||||
func (s *awsStore) LoadState() error { |
||||
param, err := s.ssmClient.GetParameter( |
||||
context.TODO(), |
||||
&ssm.GetParameterInput{ |
||||
Name: aws.String(s.ParameterName()), |
||||
WithDecryption: true, |
||||
}, |
||||
) |
||||
|
||||
if err != nil { |
||||
var pnf *ssmTypes.ParameterNotFound |
||||
if errors.As(err, &pnf) { |
||||
// Create the parameter as it does not exist yet
|
||||
// and return directly as it is defacto empty
|
||||
return s.persistState() |
||||
} |
||||
return err |
||||
} |
||||
|
||||
// Load the content in-memory
|
||||
return s.memory.LoadFromJSON([]byte(*param.Parameter.Value)) |
||||
} |
||||
|
||||
// ParameterName returns the parameter name extracted from
|
||||
// the provided ARN
|
||||
func (s *awsStore) ParameterName() (name string) { |
||||
values := parameterNameRx.FindStringSubmatch(s.ssmARN.Resource) |
||||
if len(values) == 2 { |
||||
name = values[1] |
||||
} |
||||
return |
||||
} |
||||
|
||||
// String returns the awsStore and the ARN of the SSM parameter store
|
||||
// configured to store the state
|
||||
func (s *awsStore) String() string { return fmt.Sprintf("awsStore(%q)", s.ssmARN.String()) } |
||||
|
||||
// ReadState implements the Store interface.
|
||||
func (s *awsStore) ReadState(id ipn.StateKey) (bs []byte, err error) { |
||||
return s.memory.ReadState(id) |
||||
} |
||||
|
||||
// WriteState implements the Store interface.
|
||||
func (s *awsStore) WriteState(id ipn.StateKey, bs []byte) (err error) { |
||||
// Write the state in-memory
|
||||
if err = s.memory.WriteState(id, bs); err != nil { |
||||
return |
||||
} |
||||
|
||||
// Persist the state in AWS SSM parameter store
|
||||
return s.persistState() |
||||
} |
||||
|
||||
// PersistState saves the states into the AWS SSM parameter store
|
||||
func (s *awsStore) persistState() error { |
||||
// Generate JSON from in-memory cache
|
||||
bs, err := s.memory.ExportToJSON() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Store in AWS SSM parameter store
|
||||
_, err = s.ssmClient.PutParameter( |
||||
context.TODO(), |
||||
&ssm.PutParameterInput{ |
||||
Name: aws.String(s.ParameterName()), |
||||
Value: aws.String(string(bs)), |
||||
Overwrite: true, |
||||
Tier: ssmTypes.ParameterTierStandard, |
||||
Type: ssmTypes.ParameterTypeSecureString, |
||||
}, |
||||
) |
||||
return err |
||||
} |
||||
@ -0,0 +1,19 @@ |
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package aws |
||||
|
||||
import ( |
||||
"fmt" |
||||
"runtime" |
||||
|
||||
"tailscale.com/ipn" |
||||
) |
||||
|
||||
func NewStore(string) (ipn.StateStore, error) { |
||||
return nil, fmt.Errorf("AWS store is not supported on %v", runtime.GOOS) |
||||
} |
||||
@ -0,0 +1,166 @@ |
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package aws |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws" |
||||
"github.com/aws/aws-sdk-go-v2/aws/arn" |
||||
"github.com/aws/aws-sdk-go-v2/service/ssm" |
||||
ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" |
||||
"tailscale.com/ipn" |
||||
"tailscale.com/tstest" |
||||
) |
||||
|
||||
type mockedAWSSSMClient struct { |
||||
value string |
||||
} |
||||
|
||||
func (sp *mockedAWSSSMClient) GetParameter(_ context.Context, input *ssm.GetParameterInput, _ ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { |
||||
output := new(ssm.GetParameterOutput) |
||||
if sp.value == "" { |
||||
return output, &ssmTypes.ParameterNotFound{} |
||||
} |
||||
|
||||
output.Parameter = &ssmTypes.Parameter{ |
||||
Value: aws.String(sp.value), |
||||
} |
||||
|
||||
return output, nil |
||||
} |
||||
|
||||
func (sp *mockedAWSSSMClient) PutParameter(_ context.Context, input *ssm.PutParameterInput, _ ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) { |
||||
sp.value = *input.Value |
||||
return new(ssm.PutParameterOutput), nil |
||||
} |
||||
|
||||
func TestAWSStoreString(t *testing.T) { |
||||
store := &awsStore{ |
||||
ssmARN: arn.ARN{ |
||||
Service: "ssm", |
||||
Region: "eu-west-1", |
||||
AccountID: "123456789", |
||||
Resource: "parameter/foo", |
||||
}, |
||||
} |
||||
want := "awsStore(\"arn::ssm:eu-west-1:123456789:parameter/foo\")" |
||||
if got := store.String(); got != want { |
||||
t.Errorf("AWSStore.String = %q; want %q", got, want) |
||||
} |
||||
} |
||||
|
||||
func TestNewAWSStore(t *testing.T) { |
||||
tstest.PanicOnLog() |
||||
|
||||
mc := &mockedAWSSSMClient{} |
||||
storeParameterARN := arn.ARN{ |
||||
Service: "ssm", |
||||
Region: "eu-west-1", |
||||
AccountID: "123456789", |
||||
Resource: "parameter/foo", |
||||
} |
||||
|
||||
s, err := newStore(storeParameterARN.String(), mc) |
||||
if err != nil { |
||||
t.Fatalf("creating aws store failed: %v", err) |
||||
} |
||||
testStoreSemantics(t, s) |
||||
|
||||
// Build a brand new file store and check that both IDs written
|
||||
// above are still there.
|
||||
s2, err := newStore(storeParameterARN.String(), mc) |
||||
if err != nil { |
||||
t.Fatalf("creating second aws store failed: %v", err) |
||||
} |
||||
store2 := s.(*awsStore) |
||||
|
||||
// This is specific to the test, with the non-mocked API, LoadState() should
|
||||
// have been already called and sucessful as no err is returned from NewAWSStore()
|
||||
s2.(*awsStore).LoadState() |
||||
|
||||
expected := map[ipn.StateKey]string{ |
||||
"foo": "bar", |
||||
"baz": "quux", |
||||
} |
||||
for id, want := range expected { |
||||
bs, err := store2.ReadState(id) |
||||
if err != nil { |
||||
t.Errorf("reading %q (2nd store): %v", id, err) |
||||
} |
||||
if string(bs) != want { |
||||
t.Errorf("reading %q (2nd store): got %q, want %q", id, string(bs), want) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func testStoreSemantics(t *testing.T, store ipn.StateStore) { |
||||
t.Helper() |
||||
|
||||
tests := []struct { |
||||
// if true, data is data to write. If false, data is expected
|
||||
// output of read.
|
||||
write bool |
||||
id ipn.StateKey |
||||
data string |
||||
// If write=false, true if we expect a not-exist error.
|
||||
notExists bool |
||||
}{ |
||||
{ |
||||
id: "foo", |
||||
notExists: true, |
||||
}, |
||||
{ |
||||
write: true, |
||||
id: "foo", |
||||
data: "bar", |
||||
}, |
||||
{ |
||||
id: "foo", |
||||
data: "bar", |
||||
}, |
||||
{ |
||||
id: "baz", |
||||
notExists: true, |
||||
}, |
||||
{ |
||||
write: true, |
||||
id: "baz", |
||||
data: "quux", |
||||
}, |
||||
{ |
||||
id: "foo", |
||||
data: "bar", |
||||
}, |
||||
{ |
||||
id: "baz", |
||||
data: "quux", |
||||
}, |
||||
} |
||||
|
||||
for _, test := range tests { |
||||
if test.write { |
||||
if err := store.WriteState(test.id, []byte(test.data)); err != nil { |
||||
t.Errorf("writing %q to %q: %v", test.data, test.id, err) |
||||
} |
||||
} else { |
||||
bs, err := store.ReadState(test.id) |
||||
if err != nil { |
||||
if test.notExists && err == ipn.ErrStateNotExist { |
||||
continue |
||||
} |
||||
t.Errorf("reading %q: %v", test.id, err) |
||||
continue |
||||
} |
||||
if string(bs) != test.data { |
||||
t.Errorf("reading %q: got %q, want %q", test.id, string(bs), test.data) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue