From fb6a98776022a877f50fb90f9912be73b029bb3c Mon Sep 17 00:00:00 2001 From: Irbe Krumina Date: Mon, 6 Jan 2025 11:25:12 +0000 Subject: [PATCH] client/tailscale: add logic to get/put/delete VIPServices Updates tailscale/corp#24795 Signed-off-by: Irbe Krumina --- client/tailscale/tailscale.go | 1 + client/tailscale/vipservices.go | 160 +++++++++++++++++++++++++++ client/tailscale/vipservices_test.go | 124 +++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 client/tailscale/vipservices.go create mode 100644 client/tailscale/vipservices_test.go 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) + } + }) + } +}