2025-01-21 05:21:03 +00:00
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
//go:build !plan9
|
|
|
|
|
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
2025-10-05 02:10:50 +01:00
|
|
|
"net/http"
|
2025-01-21 05:21:03 +00:00
|
|
|
"os"
|
2025-10-05 02:10:50 +01:00
|
|
|
"sync"
|
|
|
|
|
"time"
|
2025-01-21 05:21:03 +00:00
|
|
|
|
2025-10-05 02:10:50 +01:00
|
|
|
"go.uber.org/zap"
|
|
|
|
|
"golang.org/x/oauth2"
|
2025-01-21 05:21:03 +00:00
|
|
|
"golang.org/x/oauth2/clientcredentials"
|
2025-02-07 09:09:36 -06:00
|
|
|
"tailscale.com/internal/client/tailscale"
|
2025-07-02 21:42:31 +01:00
|
|
|
"tailscale.com/ipn"
|
2025-02-04 15:09:43 +02:00
|
|
|
"tailscale.com/tailcfg"
|
2025-01-21 05:21:03 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 = "-"
|
2025-10-05 02:10:50 +01:00
|
|
|
oidcJWTPath = "/var/run/secrets/tailscale/serviceaccount/token"
|
2025-01-21 05:21:03 +00:00
|
|
|
)
|
|
|
|
|
|
2025-10-05 02:10:50 +01:00
|
|
|
func newTSClient(logger *zap.SugaredLogger, clientID, clientIDPath, clientSecretPath, loginServer string) (*tailscale.Client, error) {
|
|
|
|
|
baseURL := ipn.DefaultControlURL
|
2025-07-02 21:42:31 +01:00
|
|
|
if loginServer != "" {
|
2025-10-05 02:10:50 +01:00
|
|
|
baseURL = loginServer
|
2025-07-02 21:42:31 +01:00
|
|
|
}
|
2025-10-05 02:10:50 +01:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
}
|
2025-01-21 05:21:03 +00:00
|
|
|
}
|
2025-10-05 02:10:50 +01:00
|
|
|
|
2025-01-21 05:21:03 +00:00
|
|
|
c := tailscale.NewClient(defaultTailnet, nil)
|
|
|
|
|
c.UserAgent = "tailscale-k8s-operator"
|
2025-10-05 02:10:50 +01:00
|
|
|
c.HTTPClient = httpClient
|
2025-07-02 21:42:31 +01:00
|
|
|
if loginServer != "" {
|
|
|
|
|
c.BaseURL = loginServer
|
|
|
|
|
}
|
2025-02-12 10:34:28 -06:00
|
|
|
return c, nil
|
2025-01-21 05:21:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2025-05-14 18:25:08 +01:00
|
|
|
// GetVIPService is a method for getting a Tailscale Service. VIPService is the original name for Tailscale Service.
|
2025-02-12 10:34:28 -06:00
|
|
|
GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error)
|
2025-07-21 11:03:21 +01:00
|
|
|
// ListVIPServices is a method for listing all Tailscale Services. VIPService is the original name for Tailscale Service.
|
|
|
|
|
ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error)
|
2025-05-14 18:25:08 +01:00
|
|
|
// CreateOrUpdateVIPService is a method for creating or updating a Tailscale Service.
|
2025-02-12 10:34:28 -06:00
|
|
|
CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error
|
2025-05-14 18:25:08 +01:00
|
|
|
// DeleteVIPService is a method for deleting a Tailscale Service.
|
2025-02-12 10:34:28 -06:00
|
|
|
DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
2025-01-21 05:21:03 +00:00
|
|
|
}
|
2025-10-05 02:10:50 +01:00
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
}
|