Browse Source

cmd/tailscale/cli: allow fetching keys from AWS Parameter Store

This allows fetching auth keys, OAuth client secrets, and ID tokens (for
workload identity federation) from AWS Parameter Store by passing an ARN
as the value. This is a relatively low-overhead mechanism for fetching
these values from an external secret store without needing to run a
secret service.

Usage examples:

    # Auth key
    tailscale up \
      --auth-key=arn:aws:ssm:us-east-1:123456789012:parameter/tailscale/auth-key

    # OAuth client secret
    tailscale up \
      --client-secret=arn:aws:ssm:us-east-1:123456789012:parameter/tailscale/oauth-secret \
      --advertise-tags=tag:server

    # ID token (for workload identity federation)
    tailscale up \
      --client-id=my-client \
      --id-token=arn:aws:ssm:us-east-1:123456789012:parameter/tailscale/id-token \
      --advertise-tags=tag:server

Updates tailscale/corp#28792

Signed-off-by: Andrew Dunham <[email protected]>
Andrew Dunham 1 month ago
parent
commit
bcceef3682

+ 76 - 0
cmd/tailscale/cli/cli_test.go

@@ -6,11 +6,14 @@ package cli
 import (
 	"bytes"
 	stdcmp "cmp"
+	"context"
 	"encoding/json"
 	"flag"
 	"fmt"
 	"io"
 	"net/netip"
+	"os"
+	"path/filepath"
 	"reflect"
 	"strings"
 	"testing"
@@ -20,6 +23,7 @@ import (
 	"github.com/peterbourgon/ff/v3/ffcli"
 	"tailscale.com/envknob"
 	"tailscale.com/health/healthmsg"
+	"tailscale.com/internal/client/tailscale"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/tailcfg"
@@ -1696,6 +1700,78 @@ func TestDocs(t *testing.T) {
 	walk(t, root)
 }
 
+func TestUpResolves(t *testing.T) {
+	const testARN = "arn:aws:ssm:us-east-1:123456789012:parameter/my-parameter"
+	undo := tailscale.HookResolveValueFromParameterStore.SetForTest(func(_ context.Context, valueOrARN string) (string, error) {
+		if valueOrARN == testARN {
+			return "resolved-value", nil
+		}
+		return valueOrARN, nil
+	})
+	defer undo()
+
+	const content = "file-content"
+	fpath := filepath.Join(t.TempDir(), "testfile")
+	if err := os.WriteFile(fpath, []byte(content), 0600); err != nil {
+		t.Fatal(err)
+	}
+
+	testCases := []struct {
+		name string
+		arg  string
+		want string
+	}{
+		{"parameter_store", testARN, "resolved-value"},
+		{"file", "file:" + fpath, "file-content"},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name+"_auth_key", func(t *testing.T) {
+			args := upArgsT{authKeyOrFile: tt.arg}
+			got, err := args.getAuthKey(t.Context())
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if got != tt.want {
+				t.Errorf("got %q, want %q", got, tt.want)
+			}
+		})
+
+		t.Run(tt.name+"_client_secret", func(t *testing.T) {
+			args := upArgsT{clientSecretOrFile: tt.arg}
+			got, err := args.getClientSecret(t.Context())
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if got != tt.want {
+				t.Errorf("got %q, want %q", got, tt.want)
+			}
+		})
+
+		t.Run(tt.name+"_id_token", func(t *testing.T) {
+			args := upArgsT{idTokenOrFile: tt.arg}
+			got, err := args.getIDToken(t.Context())
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if got != tt.want {
+				t.Errorf("got %q, want %q", got, tt.want)
+			}
+		})
+	}
+
+	t.Run("passthrough", func(t *testing.T) {
+		args := upArgsT{authKeyOrFile: "tskey-abcd1234"}
+		got, err := args.getAuthKey(t.Context())
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if got != "tskey-abcd1234" {
+			t.Errorf("got %q, want %q", got, "tskey-abcd1234")
+		}
+	})
+}
+
 func TestDeps(t *testing.T) {
 	deptest.DepChecker{
 		GOOS:   "linux",

+ 33 - 9
cmd/tailscale/cli/up.go

@@ -24,6 +24,7 @@ import (
 	shellquote "github.com/kballard/go-shellquote"
 	"github.com/peterbourgon/ff/v3/ffcli"
 	"tailscale.com/feature/buildfeatures"
+	_ "tailscale.com/feature/condregister/awsparamstore"
 	_ "tailscale.com/feature/condregister/identityfederation"
 	_ "tailscale.com/feature/condregister/oauthkey"
 	"tailscale.com/health/healthmsg"
@@ -220,16 +221,39 @@ func resolveValueFromFile(v string) (string, error) {
 	return v, nil
 }
 
-func (a upArgsT) getAuthKey() (string, error) {
-	return resolveValueFromFile(a.authKeyOrFile)
+// resolveValueFromParameterStore resolves a value from AWS Parameter Store if
+// the value looks like an SSM ARN. If the hook is not available or the value
+// is not an SSM ARN, it returns the value unchanged.
+func resolveValueFromParameterStore(ctx context.Context, v string) (string, error) {
+	if f, ok := tailscale.HookResolveValueFromParameterStore.GetOk(); ok {
+		return f(ctx, v)
+	}
+	return v, nil
+}
+
+// resolveValue will take the given value (e.g. as passed to --auth-key), and
+// depending on the prefix, resolve the value from either a file or AWS
+// Parameter Store. Values with an unknown prefix are returned as-is.
+func resolveValue(ctx context.Context, v string) (string, error) {
+	switch {
+	case strings.HasPrefix(v, "file:"):
+		return resolveValueFromFile(v)
+	case strings.HasPrefix(v, tailscale.ResolvePrefixAWSParameterStore):
+		return resolveValueFromParameterStore(ctx, v)
+	}
+	return v, nil
+}
+
+func (a upArgsT) getAuthKey(ctx context.Context) (string, error) {
+	return resolveValue(ctx, a.authKeyOrFile)
 }
 
-func (a upArgsT) getClientSecret() (string, error) {
-	return resolveValueFromFile(a.clientSecretOrFile)
+func (a upArgsT) getClientSecret(ctx context.Context) (string, error) {
+	return resolveValue(ctx, a.clientSecretOrFile)
 }
 
-func (a upArgsT) getIDToken() (string, error) {
-	return resolveValueFromFile(a.idTokenOrFile)
+func (a upArgsT) getIDToken(ctx context.Context) (string, error) {
+	return resolveValue(ctx, a.idTokenOrFile)
 }
 
 var upArgsGlobal upArgsT
@@ -602,7 +626,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
 			return err
 		}
 
-		authKey, err := upArgs.getAuthKey()
+		authKey, err := upArgs.getAuthKey(ctx)
 		if err != nil {
 			return err
 		}
@@ -611,7 +635,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
 		if f, ok := tailscale.HookResolveAuthKey.GetOk(); ok {
 			clientSecret := authKey // the authkey argument accepts client secrets, if both arguments are provided authkey has precedence
 			if clientSecret == "" {
-				clientSecret, err = upArgs.getClientSecret()
+				clientSecret, err = upArgs.getClientSecret(ctx)
 				if err != nil {
 					return err
 				}
@@ -625,7 +649,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
 		// Try to resolve the auth key via workload identity federation if that functionality
 		// is available and no auth key is yet determined.
 		if f, ok := tailscale.HookResolveAuthKeyViaWIF.GetOk(); ok && authKey == "" {
-			idToken, err := upArgs.getIDToken()
+			idToken, err := upArgs.getIDToken(ctx)
 			if err != nil {
 				return err
 			}

+ 11 - 3
cmd/tailscale/depaware.txt

@@ -11,6 +11,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
    W 💣 github.com/alexbrainman/sspi/negotiate                       from tailscale.com/net/tshttpproxy
    L    github.com/atotto/clipboard                                  from tailscale.com/client/systray
         github.com/aws/aws-sdk-go-v2/aws                             from github.com/aws/aws-sdk-go-v2/aws/defaults+
+   L    github.com/aws/aws-sdk-go-v2/aws/arn                         from tailscale.com/feature/awsparamstore
         github.com/aws/aws-sdk-go-v2/aws/defaults                    from github.com/aws/aws-sdk-go-v2/service/sso+
         github.com/aws/aws-sdk-go-v2/aws/middleware                  from github.com/aws/aws-sdk-go-v2/aws/retry+
         github.com/aws/aws-sdk-go-v2/aws/protocol/query              from github.com/aws/aws-sdk-go-v2/service/sts
@@ -21,7 +22,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4          from github.com/aws/aws-sdk-go-v2/aws/signer/v4
         github.com/aws/aws-sdk-go-v2/aws/signer/v4                   from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
         github.com/aws/aws-sdk-go-v2/aws/transport/http              from github.com/aws/aws-sdk-go-v2/config+
-        github.com/aws/aws-sdk-go-v2/config                          from tailscale.com/wif
+        github.com/aws/aws-sdk-go-v2/config                          from tailscale.com/wif+
         github.com/aws/aws-sdk-go-v2/credentials                     from github.com/aws/aws-sdk-go-v2/config
         github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds        from github.com/aws/aws-sdk-go-v2/config
         github.com/aws/aws-sdk-go-v2/credentials/endpointcreds       from github.com/aws/aws-sdk-go-v2/config
@@ -49,6 +50,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         github.com/aws/aws-sdk-go-v2/internal/timeconv               from github.com/aws/aws-sdk-go-v2/aws/retry
         github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
         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/feature/awsparamstore
+   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
         github.com/aws/aws-sdk-go-v2/service/sso                     from github.com/aws/aws-sdk-go-v2/config+
         github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints  from github.com/aws/aws-sdk-go-v2/service/sso
         github.com/aws/aws-sdk-go-v2/service/sso/types               from github.com/aws/aws-sdk-go-v2/service/sso
@@ -65,7 +69,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         github.com/aws/smithy-go/document                            from github.com/aws/aws-sdk-go-v2/service/sso+
         github.com/aws/smithy-go/encoding                            from github.com/aws/smithy-go/encoding/json+
         github.com/aws/smithy-go/encoding/httpbinding                from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
-        github.com/aws/smithy-go/encoding/json                       from github.com/aws/aws-sdk-go-v2/service/ssooidc
+        github.com/aws/smithy-go/encoding/json                       from github.com/aws/aws-sdk-go-v2/service/ssooidc+
         github.com/aws/smithy-go/encoding/xml                        from github.com/aws/aws-sdk-go-v2/service/sts
         github.com/aws/smithy-go/endpoints                           from github.com/aws/aws-sdk-go-v2/service/sso+
         github.com/aws/smithy-go/endpoints/private/rulesfn           from github.com/aws/aws-sdk-go-v2/service/sts
@@ -76,11 +80,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         github.com/aws/smithy-go/middleware                          from github.com/aws/aws-sdk-go-v2/aws+
         github.com/aws/smithy-go/private/requestcompression          from github.com/aws/aws-sdk-go-v2/config
         github.com/aws/smithy-go/ptr                                 from github.com/aws/aws-sdk-go-v2/aws+
-        github.com/aws/smithy-go/rand                                from github.com/aws/aws-sdk-go-v2/aws/middleware
+        github.com/aws/smithy-go/rand                                from github.com/aws/aws-sdk-go-v2/aws/middleware+
         github.com/aws/smithy-go/time                                from github.com/aws/aws-sdk-go-v2/service/sso+
         github.com/aws/smithy-go/tracing                             from github.com/aws/aws-sdk-go-v2/aws/middleware+
         github.com/aws/smithy-go/transport/http                      from github.com/aws/aws-sdk-go-v2/aws+
         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
         github.com/coder/websocket                                   from tailscale.com/util/eventbus
         github.com/coder/websocket/internal/errd                     from github.com/coder/websocket
         github.com/coder/websocket/internal/util                     from github.com/coder/websocket
@@ -112,6 +117,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         github.com/huin/goupnp/scpd                                  from github.com/huin/goupnp
         github.com/huin/goupnp/soap                                  from github.com/huin/goupnp+
         github.com/huin/goupnp/ssdp                                  from github.com/huin/goupnp
+   L    github.com/jmespath/go-jmespath                              from github.com/aws/aws-sdk-go-v2/service/ssm
    L 💣 github.com/jsimonetti/rtnetlink                              from tailscale.com/net/netmon
    L    github.com/jsimonetti/rtnetlink/internal/unix                from github.com/jsimonetti/rtnetlink
         github.com/kballard/go-shellquote                            from tailscale.com/cmd/tailscale/cli
@@ -168,8 +174,10 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         tailscale.com/envknob                                        from tailscale.com/client/local+
         tailscale.com/envknob/featureknob                            from tailscale.com/client/web
         tailscale.com/feature                                        from tailscale.com/tsweb+
+   L    tailscale.com/feature/awsparamstore                          from tailscale.com/feature/condregister/awsparamstore
         tailscale.com/feature/buildfeatures                          from tailscale.com/cmd/tailscale/cli+
         tailscale.com/feature/capture/dissector                      from tailscale.com/cmd/tailscale/cli
+        tailscale.com/feature/condregister/awsparamstore             from tailscale.com/cmd/tailscale/cli
         tailscale.com/feature/condregister/identityfederation        from tailscale.com/cmd/tailscale/cli
         tailscale.com/feature/condregister/oauthkey                  from tailscale.com/cmd/tailscale/cli
         tailscale.com/feature/condregister/portmapper                from tailscale.com/cmd/tailscale/cli

+ 1 - 0
cmd/tailscaled/depaware-minbox.txt

@@ -73,6 +73,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/feature/buildfeatures                          from tailscale.com/ipn/ipnlocal+
         tailscale.com/feature/condlite/expvar                        from tailscale.com/wgengine/magicsock
         tailscale.com/feature/condregister                           from tailscale.com/cmd/tailscaled
+        tailscale.com/feature/condregister/awsparamstore             from tailscale.com/cmd/tailscale/cli
         tailscale.com/feature/condregister/identityfederation        from tailscale.com/cmd/tailscale/cli
         tailscale.com/feature/condregister/oauthkey                  from tailscale.com/cmd/tailscale/cli
         tailscale.com/feature/condregister/portmapper                from tailscale.com/feature/condregister+

+ 88 - 0
feature/awsparamstore/awsparamstore.go

@@ -0,0 +1,88 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_aws
+
+// Package awsparamstore registers support for fetching secret values from AWS
+// Parameter Store.
+package awsparamstore
+
+import (
+	"context"
+	"fmt"
+	"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"
+	"tailscale.com/feature"
+	"tailscale.com/internal/client/tailscale"
+)
+
+func init() {
+	feature.Register("awsparamstore")
+	tailscale.HookResolveValueFromParameterStore.Set(ResolveValue)
+}
+
+// parseARN parses and verifies that the input string is an
+// ARN for AWS Parameter Store, returning the region and parameter name if so.
+//
+// If the input is not a valid Parameter Store ARN, it returns ok==false.
+func parseARN(s string) (region, parameterName string, ok bool) {
+	parsed, err := arn.Parse(s)
+	if err != nil {
+		return "", "", false
+	}
+
+	if parsed.Service != "ssm" {
+		return "", "", false
+	}
+	parameterName, ok = strings.CutPrefix(parsed.Resource, "parameter/")
+	if !ok {
+		return "", "", false
+	}
+
+	// NOTE: parameter names must have a leading slash
+	return parsed.Region, "/" + parameterName, true
+}
+
+// ResolveValue fetches a value from AWS Parameter Store if the input
+// looks like an SSM ARN (e.g., arn:aws:ssm:us-east-1:123456789012:parameter/my-secret).
+//
+// If the input is not a Parameter Store ARN, it returns the value unchanged.
+//
+// If the input is a Parameter Store ARN and fetching the parameter fails, it
+// returns an error.
+func ResolveValue(ctx context.Context, valueOrARN string) (string, error) {
+	// If it doesn't look like an ARN, return as-is
+	region, parameterName, ok := parseARN(valueOrARN)
+	if !ok {
+		return valueOrARN, nil
+	}
+
+	// Load AWS config with the region from the ARN
+	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
+	if err != nil {
+		return "", fmt.Errorf("loading AWS config in region %q: %w", region, err)
+	}
+
+	// Create SSM client and fetch the parameter
+	client := ssm.NewFromConfig(cfg)
+	output, err := client.GetParameter(ctx, &ssm.GetParameterInput{
+		// The parameter to fetch.
+		Name: aws.String(parameterName),
+
+		// If the parameter is a SecureString, decrypt it.
+		WithDecryption: aws.Bool(true),
+	})
+	if err != nil {
+		return "", fmt.Errorf("getting SSM parameter %q: %w", parameterName, err)
+	}
+
+	if output.Parameter == nil || output.Parameter.Value == nil {
+		return "", fmt.Errorf("SSM parameter %q has no value", parameterName)
+	}
+
+	return strings.TrimSpace(*output.Parameter.Value), nil
+}

+ 83 - 0
feature/awsparamstore/awsparamstore_test.go

@@ -0,0 +1,83 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_aws
+
+package awsparamstore
+
+import (
+	"testing"
+)
+
+func TestParseARN(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         string
+		wantOk        bool
+		wantRegion    string
+		wantParamName string
+	}{
+		{
+			name:   "non-arn-passthrough",
+			input:  "tskey-abcd1234",
+			wantOk: false,
+		},
+		{
+			name:   "file-prefix-passthrough",
+			input:  "file:/path/to/key",
+			wantOk: false,
+		},
+		{
+			name:   "empty-passthrough",
+			input:  "",
+			wantOk: false,
+		},
+		{
+			name:   "non-ssm-arn-passthrough",
+			input:  "arn:aws:s3:::my-bucket",
+			wantOk: false,
+		},
+		{
+			name:   "invalid-arn-passthrough",
+			input:  "arn:invalid",
+			wantOk: false,
+		},
+		{
+			name:   "arn-invalid-resource-passthrough",
+			input:  "arn:aws:ssm:us-east-1:123456789012:document/myDoc",
+			wantOk: false,
+		},
+		{
+			name:          "valid-arn",
+			input:         "arn:aws:ssm:us-west-2:123456789012:parameter/my-secret",
+			wantOk:        true,
+			wantRegion:    "us-west-2",
+			wantParamName: "/my-secret",
+		},
+		{
+			name:          "valid-arn-with-path",
+			input:         "arn:aws:ssm:eu-central-1:123456789012:parameter/path/to/secret",
+			wantOk:        true,
+			wantRegion:    "eu-central-1",
+			wantParamName: "/path/to/secret",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotRegion, gotParamName, gotOk := parseARN(tt.input)
+			if gotOk != tt.wantOk {
+				t.Errorf("parseARN(%q) got ok=%v, want %v", tt.input, gotOk, tt.wantOk)
+			}
+			if !tt.wantOk {
+				return
+			}
+			if gotRegion != tt.wantRegion {
+				t.Errorf("parseARN(%q) got region=%q, want %q", tt.input, gotRegion, tt.wantRegion)
+			}
+			if gotParamName != tt.wantParamName {
+				t.Errorf("parseARN(%q) got paramName=%q, want %q", tt.input, gotParamName, tt.wantParamName)
+			}
+		})
+	}
+}

+ 6 - 0
feature/condregister/awsparamstore/doc.go

@@ -0,0 +1,6 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package awsparamstore conditionally registers the awsparamstore feature for
+// resolving secrets from AWS Parameter Store.
+package awsparamstore

+ 8 - 0
feature/condregister/awsparamstore/maybe_awsparamstore.go

@@ -0,0 +1,8 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build (ts_aws || (linux && (arm64 || amd64) && !android)) && !ts_omit_aws
+
+package awsparamstore
+
+import _ "tailscale.com/feature/awsparamstore"

+ 21 - 0
internal/client/tailscale/awsparamstore.go

@@ -0,0 +1,21 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+package tailscale
+
+import (
+	"context"
+
+	"tailscale.com/feature"
+)
+
+// ResolvePrefixAWSParameterStore is the string prefix for values that can be
+// resolved from AWS Parameter Store.
+const ResolvePrefixAWSParameterStore = "arn:aws:ssm:"
+
+// HookResolveValueFromParameterStore resolves to [awsparamstore.ResolveValue] when
+// the corresponding feature tag is enabled in the build process.
+//
+// It fetches a value from AWS Parameter Store given an ARN. If the provided
+// value is not an Parameter Store ARN, it returns the value unchanged.
+var HookResolveValueFromParameterStore feature.Hook[func(ctx context.Context, valueOrARN string) (string, error)]