mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-22 21:08:38 +00:00

cmd/k8s-operator: add logic to parse L7 Ingresses in HA mode - Wrap the Tailscale API client used by the Kubernetes Operator into a client that knows how to manage VIPServices. - Create/Delete VIPServices and update serve config for L7 Ingresses for ProxyGroup. - Ensure that ingress ProxyGroup proxies mount serve config from a shared ConfigMap. Updates tailscale/corp#24795 Signed-off-by: Irbe Krumina <irbe@tailscale.com>
186 lines
6.8 KiB
Go
186 lines
6.8 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
|
|
"golang.org/x/oauth2/clientcredentials"
|
|
"tailscale.com/client/tailscale"
|
|
"tailscale.com/util/httpm"
|
|
)
|
|
|
|
// 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 = "-"
|
|
defaultBaseURL = "https://api.tailscale.com"
|
|
)
|
|
|
|
func newTSClient(ctx context.Context, clientIDPath, clientSecretPath string) (tsClient, error) {
|
|
clientID, err := os.ReadFile(clientIDPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
|
}
|
|
clientSecret, err := os.ReadFile(clientSecretPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
|
}
|
|
credentials := clientcredentials.Config{
|
|
ClientID: string(clientID),
|
|
ClientSecret: string(clientSecret),
|
|
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
|
}
|
|
c := tailscale.NewClient(defaultTailnet, nil)
|
|
c.UserAgent = "tailscale-k8s-operator"
|
|
c.HTTPClient = credentials.Client(ctx)
|
|
tsc := &tsClientImpl{
|
|
Client: c,
|
|
baseURL: defaultBaseURL,
|
|
tailnet: defaultTailnet,
|
|
}
|
|
return tsc, 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
|
|
getVIPServiceByName(ctx context.Context, name string) (*VIPService, error)
|
|
createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error
|
|
deleteVIPServiceByName(ctx context.Context, name string) error
|
|
}
|
|
|
|
type tsClientImpl struct {
|
|
*tailscale.Client
|
|
baseURL string
|
|
tailnet string
|
|
}
|
|
|
|
// VIPService is a Tailscale VIPService with Tailscale API JSON representation.
|
|
type VIPService struct {
|
|
// Name is the leftmost label of the DNS name of the VIP service.
|
|
// Name is required.
|
|
Name string `json:"name,omitempty"`
|
|
// Addrs are the IP addresses of the VIP Service. There are two addresses:
|
|
// the first is IPv4 and the second is IPv6.
|
|
// When creating a new VIP Service, the IP addresses are optional: if no
|
|
// addresses are specified then they will be selected. If an IPv4 address is
|
|
// specified at index 0, then that address will attempt to be used. An IPv6
|
|
// address can not be specified upon creation.
|
|
Addrs []string `json:"addrs,omitempty"`
|
|
// Comment is an optional text string for display in the admin panel.
|
|
Comment string `json:"comment,omitempty"`
|
|
// Ports are the ports of a VIPService that will be configured via Tailscale serve config.
|
|
// If set, any node wishing to advertise this VIPService must have this port configured via Tailscale serve.
|
|
Ports []string `json:"ports,omitempty"`
|
|
// Tags are optional ACL tags that will be applied to the VIPService.
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
// GetVIPServiceByName retrieves a VIPService by its name. It returns 404 if the VIPService is not found.
|
|
func (c *tsClientImpl) getVIPServiceByName(ctx context.Context, name string) (*VIPService, error) {
|
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(name))
|
|
req, err := http.NewRequestWithContext(ctx, httpm.GET, path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating new HTTP request: %w", err)
|
|
}
|
|
b, resp, err := c.sendRequest(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error making Tailsale API request: %w", err)
|
|
}
|
|
// If status code was not successful, return the error.
|
|
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, handleErrorResponse(b, resp)
|
|
}
|
|
svc := &VIPService{}
|
|
if err := json.Unmarshal(b, svc); err != nil {
|
|
return nil, err
|
|
}
|
|
return svc, nil
|
|
}
|
|
|
|
// CreateOrUpdateVIPServiceByName creates or updates a VIPService by its name. Caller must ensure that, if the
|
|
// VIPService already exists, the VIPService is fetched first to ensure that any auto-allocated IP addresses are not
|
|
// lost during the update. If the VIPService was created without any IP addresses explicitly set (so that they were
|
|
// auto-allocated by Tailscale) any subsequent request to this function that does not set any IP addresses will error.
|
|
func (c *tsClientImpl) createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error {
|
|
data, err := json.Marshal(svc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(svc.Name))
|
|
req, err := http.NewRequestWithContext(ctx, httpm.PUT, path, bytes.NewBuffer(data))
|
|
if err != nil {
|
|
return fmt.Errorf("error creating new HTTP request: %w", err)
|
|
}
|
|
b, resp, err := c.sendRequest(req)
|
|
if err != nil {
|
|
return fmt.Errorf("error making Tailscale API request: %w", err)
|
|
}
|
|
// If status code was not successful, return the error.
|
|
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
|
if resp.StatusCode != http.StatusOK {
|
|
return handleErrorResponse(b, resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteVIPServiceByName deletes a VIPService by its name. It returns an error if the VIPService
|
|
// does not exist or if the deletion fails.
|
|
func (c *tsClientImpl) deleteVIPServiceByName(ctx context.Context, name string) error {
|
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(name))
|
|
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("error creating new HTTP request: %w", err)
|
|
}
|
|
b, resp, err := c.sendRequest(req)
|
|
if err != nil {
|
|
return fmt.Errorf("error making Tailscale API request: %w", err)
|
|
}
|
|
// If status code was not successful, return the error.
|
|
if resp.StatusCode != http.StatusOK {
|
|
return handleErrorResponse(b, resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendRequest add the authentication key to the request and sends it. It
|
|
// receives the response and reads up to 10MB of it.
|
|
func (c *tsClientImpl) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return nil, resp, fmt.Errorf("error actually doing request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read response
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
err = fmt.Errorf("error reading response body: %v", err)
|
|
}
|
|
return b, resp, err
|
|
}
|
|
|
|
// handleErrorResponse decodes the error message from the server and returns
|
|
// an ErrResponse from it.
|
|
func handleErrorResponse(b []byte, resp *http.Response) error {
|
|
var errResp tailscale.ErrResponse
|
|
if err := json.Unmarshal(b, &errResp); err != nil {
|
|
return err
|
|
}
|
|
errResp.Status = resp.StatusCode
|
|
return errResp
|
|
}
|