diff --git a/README.md b/README.md index 79031ae..2085a7c 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,18 @@ wishlist --tailscale.net=your_tailnet_name --tailscale.key=tskey-api-abc123... You can use the [Hints](#Hints) to change the connection settings. +#### OAuth authentication + +Tailscale API keys expire after 90 days. If you want something that doesn't +require you to intervene every couple of months, use OAuth Clients: + +Create a client [here](https://login.tailscale.com/admin/settings/oauth). +The only scope needed is `devices:read`. + +Instead of using `--tailscale.key` (or `$TAILSCALE_KEY`), set +`--tailscale.client.id` and `--tailscale.client.secret` (or +`$TAILSCALE_CLIENT_ID` and `$TAILSCALE_CLIENT_SECRET`, respectively). + ### Zeroconf/Avahi/mDNS/Bonjour You can enable this using the `--zeroconf.enabled` flag: diff --git a/cmd/wishlist/main.go b/cmd/wishlist/main.go index 36cccfc..3404f3c 100644 --- a/cmd/wishlist/main.go +++ b/cmd/wishlist/main.go @@ -51,12 +51,27 @@ be in either the SSH configuration format or YAML. It's also possible to serve the TUI over SSH using the server command. `, - Version: Version, - SilenceUsage: true, - Args: cobra.MaximumNArgs(1), + Version: Version, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.MaximumNArgs(1), CompletionOptions: cobra.CompletionOptions{ HiddenDefaultCmd: true, }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + for k, v := range map[string]string{ + "TAILSCALE_KEY": "tailscale.key", + "TAILSCALE_CLIENT_ID": "tailscale.client.id", + "TAILSCALE_CLIENT_SECRET": "tailscale.client.secret", + } { + if e := os.Getenv(k); e != "" { + if err := cmd.Flags().Set(v, e); err != nil { + return err + } + } + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { cache, err := os.UserCacheDir() if err != nil { @@ -179,14 +194,16 @@ var serverCmd = &cobra.Command{ } var ( - configFile string - srvDomains []string - refreshInterval time.Duration - zeroconfEnabled bool - zeroconfDomain string - zeroconfTimeout time.Duration - tailscaleNet string - tailscaleKey string + configFile string + srvDomains []string + refreshInterval time.Duration + zeroconfEnabled bool + zeroconfDomain string + zeroconfTimeout time.Duration + tailscaleNet string + tailscaleKey string + tailscaleClientID string + tailscaleClientSecret string ) func init() { @@ -198,7 +215,11 @@ func init() { rootCmd.PersistentFlags().DurationVar(&zeroconfTimeout, "zeroconf.timeout", time.Second, "How long should zeroconf keep searching for hosts") rootCmd.PersistentFlags().StringSliceVar(&srvDomains, "srv.domain", nil, "SRV domains to discover endpoints") rootCmd.PersistentFlags().StringVar(&tailscaleNet, "tailscale.net", "", "Tailscale tailnet name") - rootCmd.PersistentFlags().StringVar(&tailscaleKey, "tailscale.key", os.Getenv("TAILSCALE_KEY"), "Tailscale tailnet name [$TAILSCALE_KEY]") + rootCmd.PersistentFlags().StringVar(&tailscaleKey, "tailscale.key", "", "Tailscale API key [$TAILSCALE_KEY]") + rootCmd.PersistentFlags().StringVar(&tailscaleClientID, "tailscale.client.id", "", "Tailscale client ID [$TAILSCALE_CLIENT_ID]") + rootCmd.PersistentFlags().StringVar(&tailscaleClientSecret, "tailscale.client.secret", "", "Tailscale client Secret [$TAILSCALE_CLIENT_SECRET]") + rootCmd.MarkFlagsMutuallyExclusive("tailscale.key", "tailscale.client.id") + rootCmd.MarkFlagsRequiredTogether("tailscale.client.id", "tailscale.client.secret") rootCmd.AddCommand(serverCmd, manCmd) } @@ -318,10 +339,7 @@ func getConfigFile(path string, seed []*wishlist.Endpoint) (wishlist.Config, err func getSeedEndpoints(ctx context.Context) ([]*wishlist.Endpoint, error) { var seed []*wishlist.Endpoint if tailscaleNet != "" { - if tailscaleKey == "" { - return nil, fmt.Errorf("missing tailscale.key") - } - endpoints, err := tailscale.Endpoints(ctx, tailscaleNet, tailscaleKey) + endpoints, err := tailscale.Endpoints(ctx, tailscaleNet, tailscaleKey, tailscaleClientID, tailscaleClientSecret) if err != nil { return nil, err //nolint: wrapcheck } diff --git a/go.mod b/go.mod index b87473e..b263c0c 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/teivah/broadcast v0.1.0 golang.org/x/crypto v0.11.0 + golang.org/x/oauth2 v0.5.0 golang.org/x/term v0.10.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -65,5 +66,6 @@ require ( golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.10.0 // indirect golang.org/x/text v0.11.0 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.30.0 // indirect ) diff --git a/go.sum b/go.sum index 3427ba0..45c6461 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,7 @@ github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KE github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -123,12 +124,15 @@ golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= +golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= @@ -149,6 +153,7 @@ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -156,6 +161,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= diff --git a/jump.go b/jump.go index 7e9335e..59e9633 100644 --- a/jump.go +++ b/jump.go @@ -47,7 +47,7 @@ func splitJump(jump string) (string, string) { switch len(parts) { case 1: return "", ensureJumpPort(parts[0]) // jump with no username - case 2: + case 2: //nolint: gomnd return parts[0], ensureJumpPort(parts[1]) // jump with user and host default: return strings.Join(parts[0:len(parts)-1], "@"), ensureJumpPort(parts[len(parts)-1]) diff --git a/tailscale/tailscale.go b/tailscale/tailscale.go index 7927896..63f30ae 100644 --- a/tailscale/tailscale.go +++ b/tailscale/tailscale.go @@ -10,11 +10,13 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/wishlist" + "golang.org/x/oauth2/clientcredentials" ) // Endpoints returns the found endpoints from tailscale. -func Endpoints(ctx context.Context, tailnet, key string) ([]*wishlist.Endpoint, error) { +func Endpoints(ctx context.Context, tailnet, key, clientID, clientSecret string) ([]*wishlist.Endpoint, error) { log.Debug("discovering from tailscale", "tailnet", tailnet) + req, err := http.NewRequestWithContext( ctx, http.MethodGet, @@ -24,8 +26,12 @@ func Endpoints(ctx context.Context, tailnet, key string) ([]*wishlist.Endpoint, if err != nil { return nil, fmt.Errorf("tailscale: %w", err) } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", key)) - resp, err := http.DefaultClient.Do(req) + + cli, err := getClient(key, clientID, clientSecret) + if err != nil { + return nil, err + } + resp, err := cli.Do(req) if err != nil { return nil, fmt.Errorf("tailscale: %w", err) } @@ -36,9 +42,15 @@ func Endpoints(ctx context.Context, tailnet, key string) ([]*wishlist.Endpoint, return nil, fmt.Errorf("tailscale: %w", err) } - var devices struct { - Devices []device `json:"devices"` + if resp.StatusCode != http.StatusOK { + var out nonOK + if err := json.Unmarshal(bts, &out); err != nil { + return nil, fmt.Errorf("tailscale: %w", err) + } + return nil, fmt.Errorf("tailscale: %s", out.Message) } + + var devices devices if err := json.Unmarshal(bts, &devices); err != nil { return nil, fmt.Errorf("tailscale: %w", err) } @@ -55,6 +67,44 @@ func Endpoints(ctx context.Context, tailnet, key string) ([]*wishlist.Endpoint, return endpoints, nil } +func getClient(key, clientID, clientSecret string) (*http.Client, error) { + if clientID != "" && clientSecret != "" { + log.Info("using oauth") + oauthConfig := &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: "https://api.tailscale.com/api/v2/oauth/token", + } + return oauthConfig.Client(context.Background()), nil + } + + if key != "" { + log.Info("using api key auth") + return &http.Client{ + Transport: apiKeyRoundTripper{key}, + }, nil + } + + return nil, fmt.Errorf("tailscale: missing key or client configuration") +} + +type apiKeyRoundTripper struct { + key string +} + +func (r apiKeyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", r.key)) + return http.DefaultTransport.RoundTrip(req) //nolint: wrapcheck +} + +type nonOK struct { + Message string `json:"message"` +} + +type devices struct { + Devices []device `json:"devices"` +} + type device struct { ID string `json:"id"` Addresses []string `json:"addresses"`