client/tailscale: add logic to get/put/delete VIPServices

Updates tailscale/corp#24795

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
This commit is contained in:
Irbe Krumina 2025-01-06 11:25:12 +00:00
parent 2fb361a3cf
commit fb6a987760
3 changed files with 285 additions and 0 deletions

View File

@ -39,6 +39,7 @@ const maxReadSize = 10 << 20
type Client struct {
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
// Set to "-" to indicate that the API call should be performed on the default tailnet for the provided credentials.
tailnet string
// auth is the authentication method to use for this client.
// nil means none, which generally won't work, but won't crash.

View File

@ -0,0 +1,160 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"
"tailscale.com/tailcfg"
"tailscale.com/util/dnsname"
"tailscale.com/net/tsaddr"
"tailscale.com/util/httpm"
)
// 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 *Client) 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 *Client) CreateOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error {
if err := svc.validateVIPService(); err != nil {
return fmt.Errorf("invalid VIP service: %w", err)
}
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 *Client) 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
}
// validateVIPService checks if the VIPService is a valid Tailscale VIPService.
func (svc *VIPService) validateVIPService() error {
if svc.Name == "" {
return fmt.Errorf("VIPService name is required")
}
if err := dnsname.ValidLabel(svc.Name); err != nil {
return fmt.Errorf("invalid VIPService name: name must be a valid DNS label: %w", err)
}
for _, tag := range svc.Tags {
if err := tailcfg.CheckTag(tag); err != nil {
return fmt.Errorf("invalid tag %q: %w", tag, err)
}
}
// At most 2 addresses are allowed.
// The first address must be a valid Tailscale IPv4 address and the second address must be a valid IPv6 address.
if len(svc.Addrs) > 0 {
// Validate first address (must be IPv4)
addr, err := netip.ParseAddr(svc.Addrs[0])
if err != nil {
return fmt.Errorf("invalid IP address at index 0: %q", svc.Addrs[0])
}
if !addr.Is4() {
return fmt.Errorf("first IP address must be IPv4")
}
if !tsaddr.IsTailscaleIP(addr) {
return fmt.Errorf("IP address %q is not a valid Tailscale IP", svc.Addrs[0])
}
if len(svc.Addrs) > 2 {
return fmt.Errorf("VIP services can have at most 2 IP addresses, got %d", len(svc.Addrs))
}
if len(svc.Addrs) == 2 {
addr, err := netip.ParseAddr(svc.Addrs[1])
if err != nil {
return fmt.Errorf("invalid IP address at index 1: %q", svc.Addrs[1])
}
if !addr.Is6() {
return fmt.Errorf("second IP address must be IPv6, got %q", svc.Addrs[1])
}
}
}
return nil
}

View File

@ -0,0 +1,124 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"strings"
"testing"
)
func TestValidateVIPService(t *testing.T) {
tests := []struct {
name string
svc VIPService
wantErr string // empty string means no error
}{
{
name: "empty_name",
svc: VIPService{},
wantErr: "VIPService name is required",
},
{
name: "invalid_name_with_dot",
svc: VIPService{
Name: "invalid.name",
},
wantErr: "invalid VIPService name: name must be a valid DNS label",
},
{
name: "invalid_tag",
svc: VIPService{
Name: "valid-name",
Tags: []string{"invalid-tag"},
},
wantErr: "invalid tag",
},
{
name: "valid_service_with_no_ips",
svc: VIPService{
Name: "valid-name",
Tags: []string{"tag:value"},
},
},
{
name: "invalid_first_ip",
svc: VIPService{
Name: "valid-name",
Addrs: []string{"256.256.256.256"},
},
wantErr: "invalid IP address",
},
{
name: "non_ipv4_as_first_address",
svc: VIPService{
Name: "valid-name",
Addrs: []string{"2001:db8::1"},
},
wantErr: "first IP address must be IPv4",
},
{
name: "non_tailscale_ipv4",
svc: VIPService{
Name: "valid-name",
Addrs: []string{"192.168.1.1"},
},
wantErr: "is not a valid Tailscale IP",
},
{
name: "too_many_addresses",
svc: VIPService{
Name: "valid-name",
Addrs: []string{"100.64.0.1", "2001:db8::1", "100.64.0.2"},
},
wantErr: "can have at most 2 IP addresses",
},
{
name: "non_ipv6_as_second_address",
svc: VIPService{
Name: "valid-name",
Addrs: []string{"100.64.0.1", "192.168.1.1"},
},
wantErr: "second IP address must be IPv6",
},
{
name: "invalid_second_ip",
svc: VIPService{
Name: "valid-name",
Addrs: []string{"100.64.0.1", "not-an-ip"},
},
wantErr: "invalid IP address at index 1",
},
{
name: "valid_service_with_both_addresses",
svc: VIPService{
Name: "valid-name",
Tags: []string{"tag:value"},
Addrs: []string{"100.64.0.1", "2001:db8::1"},
},
},
{
name: "valid_service_with_only_ipv4",
svc: VIPService{
Name: "valid-name",
Tags: []string{"tag:value"},
Addrs: []string{"100.64.0.1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.svc.validateVIPService()
if tt.wantErr == "" {
if err != nil {
t.Errorf("validateVIPService() error = %v, wanted no error", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("validateVIPService() error = %v, want error containing %q", err, tt.wantErr)
}
})
}
}