summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorIrbe Krumina <irbe@tailscale.com>2025-01-06 11:25:12 +0000
committerIrbe Krumina <irbe@tailscale.com>2025-01-06 11:39:34 +0000
commitfb6a98776022a877f50fb90f9912be73b029bb3c (patch)
tree33a5b2c98415d073c88619d80aceb9b9b1ad1b16
parent2fb361a3cf1146112b80b56ebcaf33be9474c21b (diff)
downloadtailscale-irbekrm/vip_svcs_api.tar.xz
tailscale-irbekrm/vip_svcs_api.zip
client/tailscale: add logic to get/put/delete VIPServicesirbekrm/vip_svcs_api
Updates tailscale/corp#24795 Signed-off-by: Irbe Krumina <irbe@tailscale.com>
-rw-r--r--client/tailscale/tailscale.go1
-rw-r--r--client/tailscale/vipservices.go160
-rw-r--r--client/tailscale/vipservices_test.go124
3 files changed, 285 insertions, 0 deletions
diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go
index 8533b4712..6f5cb7049 100644
--- a/client/tailscale/tailscale.go
+++ b/client/tailscale/tailscale.go
@@ -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.
diff --git a/client/tailscale/vipservices.go b/client/tailscale/vipservices.go
new file mode 100644
index 000000000..5d9651ebf
--- /dev/null
+++ b/client/tailscale/vipservices.go
@@ -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
+}
diff --git a/client/tailscale/vipservices_test.go b/client/tailscale/vipservices_test.go
new file mode 100644
index 000000000..dcd9490fc
--- /dev/null
+++ b/client/tailscale/vipservices_test.go
@@ -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)
+ }
+ })
+ }
+}