cmd/tailscale/cli,feature: add support for identity federation (#17529)

Add new arguments to `tailscale up` so authkeys can be generated dynamically via identity federation.

Updates #9192

Signed-off-by: mcoulombe <max@tailscale.com>
bradfitz/test
Max Coulombe 2 months ago committed by GitHub
parent 54cee33bae
commit 6a73c0bdf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -25,6 +25,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
qrcode "github.com/skip2/go-qrcode" qrcode "github.com/skip2/go-qrcode"
"tailscale.com/feature/buildfeatures" "tailscale.com/feature/buildfeatures"
_ "tailscale.com/feature/condregister/identityfederation"
_ "tailscale.com/feature/condregister/oauthkey" _ "tailscale.com/feature/condregister/oauthkey"
"tailscale.com/health/healthmsg" "tailscale.com/health/healthmsg"
"tailscale.com/internal/client/tailscale" "tailscale.com/internal/client/tailscale"
@ -96,6 +97,9 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs") upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs")
upf.StringVar(&upArgs.qrFormat, "qr-format", "small", "QR code formatting (small or large)") upf.StringVar(&upArgs.qrFormat, "qr-format", "small", "QR code formatting (small or large)")
upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`) upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`)
upf.StringVar(&upArgs.clientID, "client-id", "", "Client ID used to generate authkeys via workload identity federation")
upf.StringVar(&upArgs.clientSecretOrFile, "client-secret", "", `Client Secret used to generate authkeys via OAuth; if it begins with "file:", then it's a path to a file containing the secret`)
upf.StringVar(&upArgs.idTokenOrFile, "id-token", "", `ID token from the identity provider to exchange with the control server for workload identity federation; if it begins with "file:", then it's a path to a file containing the token`)
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server") upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes") upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes")
@ -184,6 +188,9 @@ type upArgsT struct {
statefulFiltering bool statefulFiltering bool
netfilterMode string netfilterMode string
authKeyOrFile string // "secret" or "file:/path/to/secret" authKeyOrFile string // "secret" or "file:/path/to/secret"
clientID string
clientSecretOrFile string // "secret" or "file:/path/to/secret"
idTokenOrFile string // "secret" or "file:/path/to/secret"
hostname string hostname string
opUser string opUser string
json bool json bool
@ -193,8 +200,9 @@ type upArgsT struct {
postureChecking bool postureChecking bool
} }
func (a upArgsT) getAuthKey() (string, error) { // resolveValueFromFile returns the value as-is, or if it starts with "file:",
v := a.authKeyOrFile // reads and returns the trimmed contents of the file.
func resolveValueFromFile(v string) (string, error) {
if file, ok := strings.CutPrefix(v, "file:"); ok { if file, ok := strings.CutPrefix(v, "file:"); ok {
b, err := os.ReadFile(file) b, err := os.ReadFile(file)
if err != nil { if err != nil {
@ -205,6 +213,18 @@ func (a upArgsT) getAuthKey() (string, error) {
return v, nil return v, nil
} }
func (a upArgsT) getAuthKey() (string, error) {
return resolveValueFromFile(a.authKeyOrFile)
}
func (a upArgsT) getClientSecret() (string, error) {
return resolveValueFromFile(a.clientSecretOrFile)
}
func (a upArgsT) getIDToken() (string, error) {
return resolveValueFromFile(a.idTokenOrFile)
}
var upArgsGlobal upArgsT var upArgsGlobal upArgsT
// Fields output when `tailscale up --json` is used. Two JSON blocks will be output. // Fields output when `tailscale up --json` is used. Two JSON blocks will be output.
@ -586,11 +606,33 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
// Try to use an OAuth secret to generate an auth key if that functionality // Try to use an OAuth secret to generate an auth key if that functionality
// is available. // is available.
if f, ok := tailscale.HookResolveAuthKey.GetOk(); ok { if f, ok := tailscale.HookResolveAuthKey.GetOk(); ok {
authKey, err = f(ctx, authKey, strings.Split(upArgs.advertiseTags, ",")) clientSecret := authKey // the authkey argument accepts client secrets, if both arguments are provided authkey has precedence
if clientSecret == "" {
clientSecret, err = upArgs.getClientSecret()
if err != nil {
return err
}
}
authKey, err = f(ctx, clientSecret, strings.Split(upArgs.advertiseTags, ","))
if err != nil { if err != nil {
return err return err
} }
} }
// 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()
if err != nil {
return err
}
authKey, err = f(ctx, prefs.ControlURL, upArgs.clientID, idToken, strings.Split(upArgs.advertiseTags, ","))
if err != nil {
return err
}
}
err = localClient.Start(ctx, ipn.Options{ err = localClient.Start(ctx, ipn.Options{
AuthKey: authKey, AuthKey: authKey,
UpdatePrefs: prefs, UpdatePrefs: prefs,
@ -869,7 +911,7 @@ func addPrefFlagMapping(flagName string, prefNames ...string) {
// correspond to an ipn.Pref. // correspond to an ipn.Pref.
func preflessFlag(flagName string) bool { func preflessFlag(flagName string) bool {
switch flagName { switch flagName {
case "auth-key", "force-reauth", "reset", "qr", "qr-format", "json", "timeout", "accept-risk", "host-routes": case "auth-key", "force-reauth", "reset", "qr", "qr-format", "json", "timeout", "accept-risk", "host-routes", "client-id", "client-secret", "id-token":
return true return true
} }
return false return false

@ -43,6 +43,9 @@ var validUpFlags = set.Of(
"stateful-filtering", "stateful-filtering",
"timeout", "timeout",
"unattended", "unattended",
"client-id",
"client-secret",
"id-token",
) )
// TestUpFlagSetIsFrozen complains when new flags are added to tailscale up. // TestUpFlagSetIsFrozen complains when new flags are added to tailscale up.

@ -98,9 +98,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/feature from tailscale.com/tsweb+ tailscale.com/feature from tailscale.com/tsweb+
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/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
tailscale.com/feature/condregister/useproxy from tailscale.com/cmd/tailscale/cli tailscale.com/feature/condregister/useproxy from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/syspolicy from tailscale.com/cmd/tailscale/cli tailscale.com/feature/syspolicy from tailscale.com/cmd/tailscale/cli
@ -245,7 +247,7 @@ 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/ipv6 from golang.org/x/net/icmp+
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from tailscale.com/net/netmon+ D golang.org/x/net/route from tailscale.com/net/netmon+
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+
golang.org/x/oauth2/clientcredentials from tailscale.com/feature/oauthkey golang.org/x/oauth2/clientcredentials from tailscale.com/feature/oauthkey
golang.org/x/oauth2/internal from golang.org/x/oauth2+ golang.org/x/oauth2/internal from golang.org/x/oauth2+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+ golang.org/x/sync/errgroup from github.com/mdlayher/socket+

@ -75,6 +75,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/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+
tailscale.com/feature/condregister/useproxy from tailscale.com/cmd/tailscale/cli+ tailscale.com/feature/condregister/useproxy from tailscale.com/cmd/tailscale/cli+

@ -137,14 +137,14 @@ func TestOmitCaptivePortal(t *testing.T) {
}.Check(t) }.Check(t)
} }
func TestOmitOAuthKey(t *testing.T) { func TestOmitAuth(t *testing.T) {
deptest.DepChecker{ deptest.DepChecker{
GOOS: "linux", GOOS: "linux",
GOARCH: "amd64", GOARCH: "amd64",
Tags: "ts_omit_oauthkey,ts_include_cli", Tags: "ts_omit_oauthkey,ts_omit_identityfederation,ts_include_cli",
OnDep: func(dep string) { OnDep: func(dep string) {
if strings.HasPrefix(dep, "golang.org/x/oauth2") { if strings.HasPrefix(dep, "golang.org/x/oauth2") {
t.Errorf("unexpected dep with ts_omit_oauthkey: %q", dep) t.Errorf("unexpected oauth2 dep: %q", dep)
} }
}, },
}.Check(t) }.Check(t)

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_identity_federation
package buildfeatures
// HasIdentityFederation is whether the binary was built with support for modular feature "Identity token exchange for auth key support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_identity_federation" build tag.
// It's a const so it can be used for dead code elimination.
const HasIdentityFederation = false

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_identity_federation
package buildfeatures
// HasIdentityFederation is whether the binary was built with support for modular feature "Identity token exchange for auth key support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_identity_federation" build tag.
// It's a const so it can be used for dead code elimination.
const HasIdentityFederation = true

@ -0,0 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package identityfederation registers support for authkey resolution
// via identity federation if it's not disabled by the
// ts_omit_identityfederation build tag.
package identityfederation

@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_identityfederation
package identityfederation
import _ "tailscale.com/feature/identityfederation"

@ -157,6 +157,7 @@ var Features = map[FeatureTag]FeatureMeta{
}, },
"health": {Sym: "Health", Desc: "Health checking support"}, "health": {Sym: "Health", Desc: "Health checking support"},
"hujsonconf": {Sym: "HuJSONConf", Desc: "HuJSON config file support"}, "hujsonconf": {Sym: "HuJSONConf", Desc: "HuJSON config file support"},
"identityfederation": {Sym: "IdentityFederation", Desc: "Auth key generation via identity federation support"},
"iptables": {Sym: "IPTables", Desc: "Linux iptables support"}, "iptables": {Sym: "IPTables", Desc: "Linux iptables support"},
"kube": {Sym: "Kube", Desc: "Kubernetes integration"}, "kube": {Sym: "Kube", Desc: "Kubernetes integration"},
"lazywg": {Sym: "LazyWG", Desc: "Lazy WireGuard configuration for memory-constrained devices with large netmaps"}, "lazywg": {Sym: "LazyWG", Desc: "Lazy WireGuard configuration for memory-constrained devices with large netmaps"},

@ -0,0 +1,127 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package identityfederation registers support for using ID tokens to
// automatically request authkeys for logging in.
package identityfederation
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/oauth2"
"tailscale.com/feature"
"tailscale.com/internal/client/tailscale"
"tailscale.com/ipn"
)
func init() {
feature.Register("identityfederation")
tailscale.HookResolveAuthKeyViaWIF.Set(resolveAuthKey)
}
// resolveAuthKey uses OIDC identity federation to exchange the provided ID token and client ID for an authkey.
func resolveAuthKey(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
if clientID == "" {
return "", nil // Short-circuit, no client ID means not using identity federation
}
if idToken == "" {
return "", errors.New("federated identity authkeys require --id-token")
}
if len(tags) == 0 {
return "", errors.New("federated identity authkeys require --advertise-tags")
}
if baseURL == "" {
baseURL = ipn.DefaultControlURL
}
ephemeral, preauth, err := parseOptionalAttributes(clientID)
if err != nil {
return "", fmt.Errorf("failed to parse optional config attributes: %w", err)
}
accessToken, err := exchangeJWTForToken(ctx, baseURL, clientID, idToken)
if err != nil {
return "", fmt.Errorf("failed to exchange JWT for access token: %w", err)
}
if accessToken == "" {
return "", errors.New("received empty access token from Tailscale")
}
tsClient := tailscale.NewClient("-", tailscale.APIKey(accessToken))
tsClient.UserAgent = "tailscale-cli-identity-federation"
tsClient.BaseURL = baseURL
authkey, _, err := tsClient.CreateKey(ctx, tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: false,
Ephemeral: ephemeral,
Preauthorized: preauth,
Tags: tags,
},
},
})
if err != nil {
return "", fmt.Errorf("unexpected error while creating authkey: %w", err)
}
if authkey == "" {
return "", errors.New("received empty authkey from control server")
}
return authkey, nil
}
func parseOptionalAttributes(clientID string) (ephemeral bool, preauthorized bool, err error) {
_, attrs, found := strings.Cut(clientID, "?")
if !found {
return true, false, nil
}
parsed, err := url.ParseQuery(attrs)
if err != nil {
return false, false, fmt.Errorf("failed to parse optional config attributes: %w", err)
}
for k := range parsed {
switch k {
case "ephemeral":
ephemeral, err = strconv.ParseBool(parsed.Get(k))
case "preauthorized":
preauthorized, err = strconv.ParseBool(parsed.Get(k))
default:
return false, false, fmt.Errorf("unknown optional config attribute %q", k)
}
}
return ephemeral, preauthorized, err
}
// exchangeJWTForToken exchanges a JWT for a Tailscale access token.
func exchangeJWTForToken(ctx context.Context, baseURL, clientID, idToken string) (string, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
token, err := (&oauth2.Config{
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token-exchange", baseURL),
},
}).Exchange(ctx, "", oauth2.SetAuthURLParam("client_id", clientID), oauth2.SetAuthURLParam("jwt", idToken))
if err != nil {
// Try to extract more detailed error message
var retrieveErr *oauth2.RetrieveError
if errors.As(err, &retrieveErr) {
return "", fmt.Errorf("token exchange failed with status %d: %s", retrieveErr.Response.StatusCode, string(retrieveErr.Body))
}
return "", fmt.Errorf("unexpected token exchange request error: %w", err)
}
return token.AccessToken, nil
}

@ -0,0 +1,167 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package identityfederation
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestResolveAuthKey(t *testing.T) {
tests := []struct {
name string
clientID string
idToken string
tags []string
wantAuthKey string
wantErr string
}{
{
name: "success",
clientID: "client-123",
idToken: "token",
tags: []string{"tag:test"},
wantAuthKey: "tskey-auth-xyz",
wantErr: "",
},
{
name: "missing client id short-circuits without error",
clientID: "",
idToken: "token",
tags: []string{"tag:test"},
wantAuthKey: "",
wantErr: "",
},
{
name: "missing id token",
clientID: "client-123",
idToken: "",
tags: []string{"tag:test"},
wantErr: "federated identity authkeys require --id-token",
},
{
name: "missing tags",
clientID: "client-123",
idToken: "token",
tags: []string{},
wantErr: "federated identity authkeys require --advertise-tags",
},
{
name: "invalid client id attributes",
clientID: "client-123?invalid=value",
idToken: "token",
tags: []string{"tag:test"},
wantErr: `failed to parse optional config attributes: unknown optional config attribute "invalid"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := mockedControlServer(t)
defer srv.Close()
authKey, err := resolveAuthKey(context.Background(), srv.URL, tt.clientID, tt.idToken, tt.tags)
if tt.wantErr != "" {
if err == nil {
t.Errorf("resolveAuthKey() error = nil, want %q", tt.wantErr)
return
}
if err.Error() != tt.wantErr {
t.Errorf("resolveAuthKey() error = %q, want %q", err.Error(), tt.wantErr)
}
} else if err != nil {
t.Fatalf("resolveAuthKey() unexpected error = %v", err)
}
if authKey != tt.wantAuthKey {
t.Errorf("resolveAuthKey() = %q, want %q", authKey, tt.wantAuthKey)
}
})
}
}
func TestParseOptionalAttributes(t *testing.T) {
tests := []struct {
name string
clientID string
wantEphemeral bool
wantPreauth bool
wantErr string
}{
{
name: "default values",
clientID: "client-123",
wantEphemeral: true,
wantPreauth: false,
wantErr: "",
},
{
name: "custom values",
clientID: "client-123?ephemeral=false&preauthorized=true",
wantEphemeral: false,
wantPreauth: true,
wantErr: "",
},
{
name: "unknown attribute",
clientID: "client-123?unknown=value",
wantEphemeral: false,
wantPreauth: false,
wantErr: `unknown optional config attribute "unknown"`,
},
{
name: "invalid value",
clientID: "client-123?ephemeral=invalid",
wantEphemeral: false,
wantPreauth: false,
wantErr: `strconv.ParseBool: parsing "invalid": invalid syntax`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ephemeral, preauth, err := parseOptionalAttributes(tt.clientID)
if tt.wantErr != "" {
if err == nil {
t.Errorf("parseOptionalAttributes() error = nil, want %q", tt.wantErr)
return
}
if err.Error() != tt.wantErr {
t.Errorf("parseOptionalAttributes() error = %q, want %q", err.Error(), tt.wantErr)
}
} else {
if err != nil {
t.Errorf("parseOptionalAttributes() error = %v, want nil", err)
return
}
}
if ephemeral != tt.wantEphemeral {
t.Errorf("parseOptionalAttributes() ephemeral = %v, want %v", ephemeral, tt.wantEphemeral)
}
if preauth != tt.wantPreauth {
t.Errorf("parseOptionalAttributes() preauth = %v, want %v", preauth, tt.wantPreauth)
}
})
}
}
func mockedControlServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/oauth/token-exchange"):
// OAuth2 library sends the token exchange request
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"access-123","token_type":"Bearer","expires_in":3600}`))
case strings.Contains(r.URL.Path, "/api/v2/tailnet") && strings.Contains(r.URL.Path, "/keys"):
// Tailscale client creates the authkey
w.Write([]byte(`{"key":"tskey-auth-xyz","created":"2024-01-01T00:00:00Z"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}

@ -0,0 +1,19 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"context"
"tailscale.com/feature"
)
// HookResolveAuthKeyViaWIF resolves to [identityfederation.ResolveAuthKey] when the
// corresponding feature tag is enabled in the build process.
//
// baseURL is the URL of the control server used for token exchange and authkey generation.
// clientID is the federated client ID used for token exchange, the format is <tailnet ID>/<oauth client ID>
// idToken is the Identity token from the identity provider
// tags is the list of tags to be associated with the auth key
var HookResolveAuthKeyViaWIF feature.Hook[func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error)]

@ -25,6 +25,9 @@ func init() {
// AuthMethod is an alias to tailscale.com/client/tailscale. // AuthMethod is an alias to tailscale.com/client/tailscale.
type AuthMethod = tsclient.AuthMethod type AuthMethod = tsclient.AuthMethod
// APIKey is an alias to tailscale.com/client/tailscale.
type APIKey = tsclient.APIKey
// Device is an alias to tailscale.com/client/tailscale. // Device is an alias to tailscale.com/client/tailscale.
type Device = tsclient.Device type Device = tsclient.Device

Loading…
Cancel
Save