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:
committed by
Andrew Dunham
parent
65d6793204
commit
bcceef3682
@@ -6,11 +6,14 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
stdcmp "cmp"
|
stdcmp "cmp"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,6 +23,7 @@ import (
|
|||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
"tailscale.com/health/healthmsg"
|
"tailscale.com/health/healthmsg"
|
||||||
|
"tailscale.com/internal/client/tailscale"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -1696,6 +1700,78 @@ func TestDocs(t *testing.T) {
|
|||||||
walk(t, root)
|
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) {
|
func TestDeps(t *testing.T) {
|
||||||
deptest.DepChecker{
|
deptest.DepChecker{
|
||||||
GOOS: "linux",
|
GOOS: "linux",
|
||||||
|
|||||||
+33
-9
@@ -24,6 +24,7 @@ import (
|
|||||||
shellquote "github.com/kballard/go-shellquote"
|
shellquote "github.com/kballard/go-shellquote"
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/feature/buildfeatures"
|
"tailscale.com/feature/buildfeatures"
|
||||||
|
_ "tailscale.com/feature/condregister/awsparamstore"
|
||||||
_ "tailscale.com/feature/condregister/identityfederation"
|
_ "tailscale.com/feature/condregister/identityfederation"
|
||||||
_ "tailscale.com/feature/condregister/oauthkey"
|
_ "tailscale.com/feature/condregister/oauthkey"
|
||||||
"tailscale.com/health/healthmsg"
|
"tailscale.com/health/healthmsg"
|
||||||
@@ -220,16 +221,39 @@ func resolveValueFromFile(v string) (string, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a upArgsT) getAuthKey() (string, error) {
|
// resolveValueFromParameterStore resolves a value from AWS Parameter Store if
|
||||||
return resolveValueFromFile(a.authKeyOrFile)
|
// 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) {
|
// resolveValue will take the given value (e.g. as passed to --auth-key), and
|
||||||
return resolveValueFromFile(a.clientSecretOrFile)
|
// 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) {
|
func (a upArgsT) getAuthKey(ctx context.Context) (string, error) {
|
||||||
return resolveValueFromFile(a.idTokenOrFile)
|
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
|
var upArgsGlobal upArgsT
|
||||||
@@ -602,7 +626,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
authKey, err := upArgs.getAuthKey()
|
authKey, err := upArgs.getAuthKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if f, ok := tailscale.HookResolveAuthKey.GetOk(); ok {
|
||||||
clientSecret := authKey // the authkey argument accepts client secrets, if both arguments are provided authkey has precedence
|
clientSecret := authKey // the authkey argument accepts client secrets, if both arguments are provided authkey has precedence
|
||||||
if clientSecret == "" {
|
if clientSecret == "" {
|
||||||
clientSecret, err = upArgs.getClientSecret()
|
clientSecret, err = upArgs.getClientSecret(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Try to resolve the auth key via workload identity federation if that functionality
|
||||||
// is available and no auth key is yet determined.
|
// is available and no auth key is yet determined.
|
||||||
if f, ok := tailscale.HookResolveAuthKeyViaWIF.GetOk(); ok && authKey == "" {
|
if f, ok := tailscale.HookResolveAuthKeyViaWIF.GetOk(); ok && authKey == "" {
|
||||||
idToken, err := upArgs.getIDToken()
|
idToken, err := upArgs.getIDToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||||
L github.com/atotto/clipboard from tailscale.com/client/systray
|
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+
|
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/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/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
|
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/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/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/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 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/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
|
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/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/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
|
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 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/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
|
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/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 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/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/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 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
|
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/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/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/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/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/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 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
|
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 from tailscale.com/util/eventbus
|
||||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||||
github.com/coder/websocket/internal/util 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/scpd from github.com/huin/goupnp
|
||||||
github.com/huin/goupnp/soap 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
|
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 from tailscale.com/net/netmon
|
||||||
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
|
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
|
||||||
github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
|
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 from tailscale.com/client/local+
|
||||||
tailscale.com/envknob/featureknob from tailscale.com/client/web
|
tailscale.com/envknob/featureknob from tailscale.com/client/web
|
||||||
tailscale.com/feature from tailscale.com/tsweb+
|
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/buildfeatures from tailscale.com/cmd/tailscale/cli+
|
||||||
tailscale.com/feature/capture/dissector 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/identityfederation from tailscale.com/cmd/tailscale/cli
|
||||||
tailscale.com/feature/condregister/oauthkey 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
|
tailscale.com/feature/condregister/portmapper from tailscale.com/cmd/tailscale/cli
|
||||||
|
|||||||
@@ -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/buildfeatures from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||||
tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled
|
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/identityfederation from tailscale.com/cmd/tailscale/cli
|
||||||
tailscale.com/feature/condregister/oauthkey 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+
|
tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister+
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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)]
|
||||||
Reference in New Issue
Block a user