mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-02 16:01:27 +01:00
The feature is currently in private alpha, so requires a tailnet feature
flag. Initially focuses on supporting the operator's own auth, because the
operator is the only device we maintain that uses static long-lived
credentials. All other operator-created devices use single-use auth keys.
Testing steps:
* Create a cluster with an API server accessible over public internet
* kubectl get --raw /.well-known/openid-configuration | jq '.issuer'
* Create a federated OAuth client in the Tailscale admin console with:
* The issuer from the previous step
* Subject claim `system:serviceaccount:tailscale:operator`
* Write scopes services, devices:core, auth_keys
* Tag tag:k8s-operator
* Allow the Tailscale control plane to get the public portion of
the ServiceAccount token signing key without authentication:
* kubectl create clusterrolebinding oidc-discovery \
--clusterrole=system:service-account-issuer-discovery \
--group=system:unauthenticated
* helm install --set oauth.clientId=... --set oauth.audience=...
Updates #17457
Change-Id: Ib29c85ba97b093c70b002f4f41793ffc02e6c6e9
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
134 lines
4.4 KiB
Go
134 lines
4.4 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/clientcredentials"
|
|
"tailscale.com/internal/client/tailscale"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// defaultTailnet is a value that can be used in Tailscale API calls instead of tailnet name to indicate that the API
|
|
// call should be performed on the default tailnet for the provided credentials.
|
|
const (
|
|
defaultTailnet = "-"
|
|
oidcJWTPath = "/var/run/secrets/tailscale/serviceaccount/token"
|
|
)
|
|
|
|
func newTSClient(logger *zap.SugaredLogger, clientID, clientIDPath, clientSecretPath, loginServer string) (*tailscale.Client, error) {
|
|
baseURL := ipn.DefaultControlURL
|
|
if loginServer != "" {
|
|
baseURL = loginServer
|
|
}
|
|
|
|
var httpClient *http.Client
|
|
if clientID == "" {
|
|
// Use static client credentials mounted to disk.
|
|
id, err := os.ReadFile(clientIDPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
|
}
|
|
secret, err := os.ReadFile(clientSecretPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
|
}
|
|
credentials := clientcredentials.Config{
|
|
ClientID: string(id),
|
|
ClientSecret: string(secret),
|
|
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token"),
|
|
}
|
|
tokenSrc := credentials.TokenSource(context.Background())
|
|
httpClient = oauth2.NewClient(context.Background(), tokenSrc)
|
|
} else {
|
|
// Use workload identity federation.
|
|
tokenSrc := &jwtTokenSource{
|
|
logger: logger,
|
|
jwtPath: oidcJWTPath,
|
|
baseCfg: clientcredentials.Config{
|
|
ClientID: clientID,
|
|
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token-exchange"),
|
|
},
|
|
}
|
|
httpClient = &http.Client{
|
|
Transport: &oauth2.Transport{
|
|
Source: tokenSrc,
|
|
},
|
|
}
|
|
}
|
|
|
|
c := tailscale.NewClient(defaultTailnet, nil)
|
|
c.UserAgent = "tailscale-k8s-operator"
|
|
c.HTTPClient = httpClient
|
|
if loginServer != "" {
|
|
c.BaseURL = loginServer
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
type tsClient interface {
|
|
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
|
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
|
|
DeleteDevice(ctx context.Context, nodeStableID string) error
|
|
// GetVIPService is a method for getting a Tailscale Service. VIPService is the original name for Tailscale Service.
|
|
GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error)
|
|
// ListVIPServices is a method for listing all Tailscale Services. VIPService is the original name for Tailscale Service.
|
|
ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error)
|
|
// CreateOrUpdateVIPService is a method for creating or updating a Tailscale Service.
|
|
CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error
|
|
// DeleteVIPService is a method for deleting a Tailscale Service.
|
|
DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
|
}
|
|
|
|
// jwtTokenSource implements the [oauth2.TokenSource] interface, but with the
|
|
// ability to regenerate a fresh underlying token source each time a new value
|
|
// of the JWT parameter is needed due to expiration.
|
|
type jwtTokenSource struct {
|
|
logger *zap.SugaredLogger
|
|
jwtPath string // Path to the file containing an automatically refreshed JWT.
|
|
baseCfg clientcredentials.Config // Holds config that doesn't change for the lifetime of the process.
|
|
|
|
mu sync.Mutex // Guards underlying.
|
|
underlying oauth2.TokenSource // The oauth2 client implementation. Does its own separate caching of the access token.
|
|
}
|
|
|
|
func (s *jwtTokenSource) Token() (*oauth2.Token, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.underlying != nil {
|
|
t, err := s.underlying.Token()
|
|
if err == nil && t != nil && t.Valid() {
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
s.logger.Debugf("Refreshing JWT from %s", s.jwtPath)
|
|
tk, err := os.ReadFile(s.jwtPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading JWT from %q: %w", s.jwtPath, err)
|
|
}
|
|
|
|
// Shallow copy of the base config.
|
|
credentials := s.baseCfg
|
|
credentials.EndpointParams = map[string][]string{
|
|
"jwt": {string(tk)},
|
|
}
|
|
|
|
src := credentials.TokenSource(context.Background())
|
|
s.underlying = oauth2.ReuseTokenSourceWithExpiry(nil, src, time.Minute)
|
|
return s.underlying.Token()
|
|
}
|