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 <andrew@tailscale.com>
This commit is contained in:
Andrew Dunham
2026-01-14 02:29:06 -05:00
committed by Andrew Dunham
parent 65d6793204
commit bcceef3682
9 changed files with 327 additions and 12 deletions
+76
View File
@@ -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
View File
@@ -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
}
func (a upArgsT) getClientSecret() (string, error) {
return resolveValueFromFile(a.clientSecretOrFile)
// 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) getIDToken() (string, error) {
return resolveValueFromFile(a.idTokenOrFile)
func (a upArgsT) getAuthKey(ctx context.Context) (string, error) {
return resolveValue(ctx, a.authKeyOrFile)
}
func (a upArgsT) getClientSecret(ctx context.Context) (string, error) {
return resolveValue(ctx, a.clientSecretOrFile)
}
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
View File
@@ -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
View File
@@ -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+