mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-27 19:20:59 +00:00

Implements a KMS input for AWS parameter to support encrypting Tailscale state Fixes #14765 Change-Id: I39c0fae4bfd60a9aec17c5ea6a61d0b57143d4ba Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com> Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
256 lines
7.2 KiB
Go
256 lines
7.2 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build linux && !ts_omit_aws
|
|
|
|
// Package awsstore contains an ipn.StateStore implementation using AWS SSM.
|
|
package awsstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"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"
|
|
"tailscale.com/ipn/store/mem"
|
|
"tailscale.com/types/logger"
|
|
)
|
|
|
|
const (
|
|
parameterNameRxStr = `^parameter(/.*)`
|
|
)
|
|
|
|
var parameterNameRx = regexp.MustCompile(parameterNameRxStr)
|
|
|
|
// Option defines a functional option type for configuring awsStore.
|
|
type Option func(*storeOptions)
|
|
|
|
// storeOptions holds optional settings for creating a new awsStore.
|
|
type storeOptions struct {
|
|
kmsKey string
|
|
}
|
|
|
|
// 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
|
|
|
|
// kmsKey is optional. If empty, the parameter is stored in plaintext.
|
|
// If non-empty, the parameter is encrypted with this KMS key.
|
|
kmsKey string
|
|
|
|
memory mem.Store
|
|
}
|
|
|
|
// New returns a new ipn.StateStore using the AWS SSM storage
|
|
// location given by ssmARN.
|
|
//
|
|
// Note that we store the entire store in a single parameter
|
|
// key, therefore if the state is above 8kb, it can cause
|
|
// Tailscaled to only only store new state in-memory and
|
|
// restarting Tailscaled can fail until you delete your state
|
|
// from the AWS Parameter Store.
|
|
//
|
|
// If you want to specify an optional KMS key,
|
|
// pass one or more Option objects, e.g. awsstore.WithKeyID("alias/my-key").
|
|
func New(_ logger.Logf, ssmARN string, opts ...Option) (ipn.StateStore, error) {
|
|
// Apply all options to an empty storeOptions
|
|
var so storeOptions
|
|
for _, opt := range opts {
|
|
opt(&so)
|
|
}
|
|
|
|
return newStore(ssmARN, so, nil)
|
|
}
|
|
|
|
// WithKeyID sets the KMS key to be used for encryption. It can be
|
|
// a KeyID, an alias ("alias/my-key"), or a full ARN.
|
|
//
|
|
// If kmsKey is empty, the Option is a no-op.
|
|
func WithKeyID(kmsKey string) Option {
|
|
return func(o *storeOptions) {
|
|
o.kmsKey = kmsKey
|
|
}
|
|
}
|
|
|
|
// ParseARNAndOpts parses an ARN and optional URL-encoded parameters
|
|
// from arg.
|
|
func ParseARNAndOpts(arg string) (ssmARN string, opts []Option, err error) {
|
|
ssmARN = arg
|
|
|
|
// Support optional ?url-encoded-parameters.
|
|
if s, q, ok := strings.Cut(arg, "?"); ok {
|
|
ssmARN = s
|
|
q, err := url.ParseQuery(q)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
for k := range q {
|
|
switch k {
|
|
default:
|
|
return "", nil, fmt.Errorf("unknown arn option parameter %q", k)
|
|
case "kmsKey":
|
|
// We allow an ARN, a key ID, or an alias name for kmsKeyID.
|
|
// If it doesn't look like an ARN and doesn't have a '/',
|
|
// prepend "alias/" for KMS alias references.
|
|
kmsKey := q.Get(k)
|
|
if kmsKey != "" &&
|
|
!strings.Contains(kmsKey, "/") &&
|
|
!strings.HasPrefix(kmsKey, "arn:") {
|
|
kmsKey = "alias/" + kmsKey
|
|
}
|
|
if kmsKey != "" {
|
|
opts = append(opts, WithKeyID(kmsKey))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ssmARN, opts, nil
|
|
}
|
|
|
|
// newStore is NewStore, but for tests. If client is non-nil, it's
|
|
// used instead of making one.
|
|
func newStore(ssmARN string, so storeOptions, client awsSSMClient) (ipn.StateStore, error) {
|
|
s := &awsStore{
|
|
ssmClient: client,
|
|
kmsKey: so.kmsKey,
|
|
}
|
|
|
|
var err error
|
|
if s.ssmARN, err = arn.Parse(ssmARN); err != nil {
|
|
return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err)
|
|
}
|
|
if s.ssmARN.Service != "ssm" {
|
|
return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Preload existing state, if any
|
|
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: aws.Bool(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.
|
|
//
|
|
// We use intelligent tiering so that when the state is below 4kb, it uses Standard tiering
|
|
// which is free. However, if it exceeds 4kb it switches the parameter to advanced tiering
|
|
// doubling the capacity to 8kb per the following docs:
|
|
// https://aws.amazon.com/about-aws/whats-new/2019/08/aws-systems-manager-parameter-store-announces-intelligent-tiering-to-enable-automatic-parameter-tier-selection/
|
|
in := &ssm.PutParameterInput{
|
|
Name: aws.String(s.ParameterName()),
|
|
Value: aws.String(string(bs)),
|
|
Overwrite: aws.Bool(true),
|
|
Tier: ssmTypes.ParameterTierIntelligentTiering,
|
|
Type: ssmTypes.ParameterTypeSecureString,
|
|
}
|
|
|
|
// If kmsKey is specified, encrypt with that key
|
|
// NOTE: this input allows any alias, keyID or ARN
|
|
// If this isn't specified, AWS will use the default KMS key
|
|
if s.kmsKey != "" {
|
|
in.KeyId = aws.String(s.kmsKey)
|
|
}
|
|
|
|
_, err = s.ssmClient.PutParameter(context.TODO(), in)
|
|
return err
|
|
}
|