From 13de36303d9d5d3b872c51199663916e4563eb4b Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 26 Apr 2023 11:34:28 -0700 Subject: [PATCH] cmd/tailscale/cli: [up] add experimental oauth2 authkey support Updates #7982 Signed-off-by: Brad Fitzpatrick --- cmd/get-authkey/main.go | 16 +++--- cmd/tailscale/cli/up.go | 109 +++++++++++++++++++++++++++++++++++++ cmd/tailscale/depaware.txt | 3 + 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/cmd/get-authkey/main.go b/cmd/get-authkey/main.go index fb8f6abe7..5f5e85186 100644 --- a/cmd/get-authkey/main.go +++ b/cmd/get-authkey/main.go @@ -29,9 +29,9 @@ func main() { tags := flag.String("tags", "", "comma-separated list of tags to apply to the authkey") flag.Parse() - clientId := os.Getenv("TS_API_CLIENT_ID") + clientID := os.Getenv("TS_API_CLIENT_ID") clientSecret := os.Getenv("TS_API_CLIENT_SECRET") - if clientId == "" || clientSecret == "" { + if clientID == "" || clientSecret == "" { log.Fatal("TS_API_CLIENT_ID and TS_API_CLIENT_SECRET must be set") } @@ -39,22 +39,22 @@ func main() { log.Fatal("at least one tag must be specified") } - baseUrl := os.Getenv("TS_BASE_URL") - if baseUrl == "" { - baseUrl = "https://api.tailscale.com" + baseURL := os.Getenv("TS_BASE_URL") + if baseURL == "" { + baseURL = "https://api.tailscale.com" } credentials := clientcredentials.Config{ - ClientID: clientId, + ClientID: clientID, ClientSecret: clientSecret, - TokenURL: baseUrl + "/api/v2/oauth/token", + TokenURL: baseURL + "/api/v2/oauth/token", Scopes: []string{"device"}, } ctx := context.Background() tsClient := tailscale.NewClient("-", nil) tsClient.HTTPClient = credentials.Client(ctx) - tsClient.BaseURL = baseUrl + tsClient.BaseURL = baseURL caps := tailscale.KeyCapabilities{ Devices: tailscale.KeyDeviceCapabilities{ diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 92241748b..7a4fb5b6c 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -13,11 +13,13 @@ "fmt" "log" "net/netip" + "net/url" "os" "os/signal" "reflect" "runtime" "sort" + "strconv" "strings" "sync" "syscall" @@ -26,6 +28,9 @@ shellquote "github.com/kballard/go-shellquote" "github.com/peterbourgon/ff/v3/ffcli" qrcode "github.com/skip2/go-qrcode" + "golang.org/x/oauth2/clientcredentials" + "tailscale.com/client/tailscale" + "tailscale.com/envknob" "tailscale.com/health/healthmsg" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" @@ -663,6 +668,10 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE if err != nil { return err } + authKey, err = resolveAuthKey(ctx, authKey) + if err != nil { + return err + } if err := localClient.Start(ctx, ipn.Options{ AuthKey: authKey, UpdatePrefs: prefs, @@ -1102,3 +1111,103 @@ func anyPeerAdvertisingRoutes(st *ipnstate.Status) bool { } return false } + +func init() { + // Required to use our client API. We're fine with the instability since the + // client lives in the same repo as this code. + tailscale.I_Acknowledge_This_API_Is_Unstable = true +} + +// resolveAuthKey either returns v unchanged (in the common case) +// or, if it starts with "oauth:" parses it as one of: +// +// oauth2:CLIENT_ID:CLIENT_SECRET?ephemeral=false&tags=foo,bar&preauthorized=BOOL +// oauth2:CLIENT_ID:CLIENT_SECRET:BASE_URL?... +// +// and does the OAuth2 dance to get and return an authkey. The "ephemeral" property +// defaults to true if unspecified. The "preauthorized" defaults to false. +// +// If the BASE_URL argument is not provided, it defaults to https://api.tailscale.com. +func resolveAuthKey(ctx context.Context, v string) (string, error) { + suff, ok := strings.CutPrefix(v, "oauth:") + if !ok { + return v, nil + } + if !envknob.Bool("TS_EXPERIMENT_OAUTH_AUTHKEY") { + return "", errors.New("oauth authkeys are in experimental status") + } + + pos, named, _ := strings.Cut(suff, "?") + attrs, err := url.ParseQuery(named) + if err != nil { + return "", err + } + for k := range attrs { + switch k { + case "ephemeral", "preauthorized", "tags": + default: + return "", fmt.Errorf("unknown attribute %q", k) + } + } + getBool := func(name string, def bool) (bool, error) { + v := attrs.Get(name) + if v == "" { + return def, nil + } + ret, err := strconv.ParseBool(v) + if err != nil { + return false, fmt.Errorf("invalid attribute boolean attribute %s value %q", name, v) + } + return ret, nil + } + ephemeral, err := getBool("ephemeral", true) + if err != nil { + return "", err + } + preauth, err := getBool("preauthorized", false) + if err != nil { + return "", err + } + var tags []string + if v := attrs.Get("tags"); v != "" { + tags = strings.Split(v, ",") + } + + f := strings.SplitN(pos, ":", 3) + if len(f) < 2 || len(f) > 3 { + return "", errors.New("invalid auth key format; want oauth2:CLIENT_ID:CLIENT_SECRET[:BASE_URL]") + } + clientID, clientSecret := f[0], f[1] + baseURL := "https://api.tailscale.com" + if len(f) == 3 { + baseURL = f[2] + } + + credentials := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: baseURL + "/api/v2/oauth/token", + Scopes: []string{"device"}, + } + + tsClient := tailscale.NewClient("-", nil) + tsClient.HTTPClient = credentials.Client(ctx) + tsClient.BaseURL = baseURL + + caps := tailscale.KeyCapabilities{ + Devices: tailscale.KeyDeviceCapabilities{ + Create: tailscale.KeyDeviceCreateCapabilities{ + Reusable: false, + Ephemeral: ephemeral, + Preauthorized: preauth, + Tags: tags, + }, + }, + } + + authkey, _, err := tsClient.CreateKey(ctx, caps) + if err != nil { + return "", err + } + return authkey, nil +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 5b644992e..da38ff9db 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -155,6 +155,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep golang.org/x/net/ipv6 from golang.org/x/net/icmp golang.org/x/net/proxy from tailscale.com/net/netns D golang.org/x/net/route from net+ + golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials + golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/tailscale/cli + golang.org/x/oauth2/internal from golang.org/x/oauth2+ golang.org/x/sync/errgroup from tailscale.com/derp+ golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ LD golang.org/x/sys/unix from tailscale.com/net/netns+