From 4528f448d61d7d6cd162e8ecfa452aaf79e50a7a Mon Sep 17 00:00:00 2001 From: Maxime VISONNEAU Date: Tue, 12 Oct 2021 09:51:52 -0700 Subject: [PATCH] ipn/store/aws, cmd/tailscaled: add AWS SSM ipn.StateStore implementation 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 Signed-off-by: Brad Fitzpatrick Signed-off-by: Maxime VISONNEAU --- cmd/tailscaled/depaware.txt | 57 +++++++++++ cmd/tailscaled/tailscaled.go | 2 +- go.mod | 10 ++ go.sum | 20 ++++ ipn/ipnserver/server.go | 7 ++ ipn/store.go | 20 ++++ ipn/store/aws/store_aws.go | 175 ++++++++++++++++++++++++++++++++ ipn/store/aws/store_aws_stub.go | 19 ++++ ipn/store/aws/store_aws_test.go | 166 ++++++++++++++++++++++++++++++ 9 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 ipn/store/aws/store_aws.go create mode 100644 ipn/store/aws/store_aws_stub.go create mode 100644 ipn/store/aws/store_aws_test.go diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 1f8172320..bd196108e 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -3,6 +3,61 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+ W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy + L github.com/aws/aws-sdk-go-v2 from github.com/aws/aws-sdk-go-v2/internal/ini + L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/middleware+ + L github.com/aws/aws-sdk-go-v2/aws/arn from tailscale.com/ipn/store/aws + L github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+ + L github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts + L github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/ssm+ + L github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts + L github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry + L github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+ + L github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4 + L github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/service/internal/presigned-url+ + L github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+ + L github.com/aws/aws-sdk-go-v2/config from tailscale.com/ipn/store/aws + L github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds + L github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+ + L github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds + L github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm/internal/endpoints+ + L github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config + L github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+ + L github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+ + L github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds + L github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 + L github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws + L github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry + L github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts + L github.com/aws/aws-sdk-go-v2/service/ssm from tailscale.com/ipn/store/aws + L github.com/aws/aws-sdk-go-v2/service/ssm/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm + L github.com/aws/aws-sdk-go-v2/service/ssm/types from github.com/aws/aws-sdk-go-v2/service/ssm+ + L github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+ + L github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso + L github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso + L github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+ + L github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts + L github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+ + L github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+ + L github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/ssm+ + L github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+ + L github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+ + L github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssm + L github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts + L github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+ + L github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+ + L github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+ + L github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+ + L github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware+ + L github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/ssm+ + L github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws/middleware+ + L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http + L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/coreos/go-iptables/iptables from tailscale.com/wgengine/router github.com/go-multierror/multierror from tailscale.com/wgengine/router+ W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+ @@ -13,6 +68,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4 L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4 L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4 + L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/josharian/native from github.com/mdlayher/netlink+ L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/wgengine/monitor L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink @@ -106,6 +162,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/ipn/ipnstate from tailscale.com/ipn+ tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal + tailscale.com/ipn/store/aws from tailscale.com/ipn/ipnserver tailscale.com/kube from tailscale.com/ipn tailscale.com/log/filelogger from tailscale.com/ipn/ipnserver tailscale.com/log/logheap from tailscale.com/control/controlclient diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index f320b6926..868145ae0 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -114,7 +114,7 @@ func main() { flag.StringVar(&args.httpProxyAddr, "outbound-http-proxy-listen", "", `optional [ip]:port to run an outbound HTTP proxy (e.g. "localhost:8080")`) flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`) flag.Var(flagtype.PortValue(&args.port, 0), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select") - flag.StringVar(&args.statepath, "state", paths.DefaultTailscaledStateFile(), "path of state file; use 'kube:' to use Kubernetes secrets") + flag.StringVar(&args.statepath, "state", paths.DefaultTailscaledStateFile(), "path of state file; use 'kube:' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM") flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket") flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket") flag.BoolVar(&printVersion, "version", false, "print version information and exit") diff --git a/go.mod b/go.mod index 3fb84fce8..e051aa35e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aws/aws-sdk-go v1.38.52 + github.com/aws/aws-sdk-go-v2 v1.9.2 + github.com/aws/aws-sdk-go-v2/config v1.8.3 + github.com/aws/aws-sdk-go-v2/service/ssm v1.12.0 github.com/coreos/go-iptables v0.6.0 github.com/creack/pty v1.1.16 github.com/dave/jennifer v1.4.1 @@ -66,6 +69,13 @@ require ( github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/Microsoft/go-winio v0.4.16 // indirect github.com/OpenPeeDeeP/depguard v1.0.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.4.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.7.2 // indirect + github.com/aws/smithy-go v1.8.0 // indirect github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/bombsimon/wsl/v3 v3.1.0 // indirect github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e // indirect diff --git a/go.sum b/go.sum index 959133551..fc016301c 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,26 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go v1.38.52 h1:7NKcUyTG/CyDX835kq04DDNe8vXaJhbGW8ThemHb18A= github.com/aws/aws-sdk-go v1.38.52/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go-v2 v1.9.2 h1:dUFQcMNZMLON4BOe273pl0filK9RqyQMhCK/6xssL6s= +github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= +github.com/aws/aws-sdk-go-v2/config v1.8.3 h1:o5583X4qUfuRrOGOgmOcDgvr5gJVSu57NK08cWAhIDk= +github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw= +github.com/aws/aws-sdk-go-v2/credentials v1.4.3 h1:LTdD5QhK073MpElh9umLLP97wxphkgVC/OjQaEbBwZA= +github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0 h1:9tfxW/icbSu98C2pcNynm5jmDwU3/741F11688B6QnU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4 h1:leSJ6vCqtPpTmBIgE7044B1wql1E4n//McF+mEgNrYg= +github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2 h1:r7jel2aa4d9Duys7wEmWqDd5ebpC9w6Kxu6wIjjp18E= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8= +github.com/aws/aws-sdk-go-v2/service/ssm v1.12.0 h1:zJfVytawApwhwjDq3tbuzuLjNKQvrhdPM+II2MQDRTI= +github.com/aws/aws-sdk-go-v2/service/ssm v1.12.0/go.mod h1:m3cb1hedrft0oYmueH0CkBgRdiwczuKRXPr0tilSpz4= +github.com/aws/aws-sdk-go-v2/service/sso v1.4.2 h1:pZwkxZbspdqRGzddDB92bkZBoB7lg85sMRE7OqdB3V0= +github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk= +github.com/aws/aws-sdk-go-v2/service/sts v1.7.2 h1:ol2Y5DWqnJeKqNd8th7JWzBtqu63xpOfs1Is+n1t8/4= +github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g= +github.com/aws/smithy-go v1.8.0 h1:AEwwwXQZtUwP5Mz506FeXXrKBe0jA8gVM+1gEcSRooc= +github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 45a4702f0..10d152116 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -35,6 +35,7 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/localapi" + "tailscale.com/ipn/store/aws" "tailscale.com/log/filelogger" "tailscale.com/logtail/backoff" "tailscale.com/net/netstat" @@ -638,6 +639,7 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( var store ipn.StateStore if opts.StatePath != "" { const kubePrefix = "kube:" + const arnPrefix = "arn:" path := opts.StatePath switch { case strings.HasPrefix(path, kubePrefix): @@ -646,6 +648,11 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( if err != nil { return fmt.Errorf("ipn.NewKubeStore(%q): %v", secretName, err) } + case strings.HasPrefix(path, arnPrefix): + store, err = aws.NewStore(path) + if err != nil { + return fmt.Errorf("aws.NewStore(%q): %v", path, err) + } default: if runtime.GOOS == "windows" { path = tryWindowsAppDataMigration(logf, path) diff --git a/ipn/store.go b/ipn/store.go index b491ca972..0e6f4f0ee 100644 --- a/ipn/store.go +++ b/ipn/store.go @@ -158,6 +158,26 @@ func (s *MemoryStore) WriteState(id StateKey, bs []byte) error { return nil } +// LoadFromJSON attempts to unmarshal json content into the +// in-memory cache. +func (s *MemoryStore) LoadFromJSON(data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + return json.Unmarshal(data, &s.cache) +} + +// ExportToJSON exports the content of the cache to +// JSON formatted []byte. +func (s *MemoryStore) ExportToJSON() ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.cache) == 0 { + // Avoid "null" serialization. + return []byte("{}"), nil + } + return json.MarshalIndent(s.cache, "", " ") +} + // FileStore is a StateStore that uses a JSON file for persistence. type FileStore struct { path string diff --git a/ipn/store/aws/store_aws.go b/ipn/store/aws/store_aws.go new file mode 100644 index 000000000..57b4402ae --- /dev/null +++ b/ipn/store/aws/store_aws.go @@ -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 +} diff --git a/ipn/store/aws/store_aws_stub.go b/ipn/store/aws/store_aws_stub.go new file mode 100644 index 000000000..2ad60ac85 --- /dev/null +++ b/ipn/store/aws/store_aws_stub.go @@ -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) +} diff --git a/ipn/store/aws/store_aws_test.go b/ipn/store/aws/store_aws_test.go new file mode 100644 index 000000000..8f0da2845 --- /dev/null +++ b/ipn/store/aws/store_aws_test.go @@ -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) + } + } + } +}