mirror of https://github.com/tailscale/tailscale/
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>
main
parent
65d6793204
commit
bcceef3682
@ -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)]
|
||||
Loading…
Reference in New Issue