diff --git a/client/tailscale/acl.go b/client/tailscale/acl.go index 8d8bdfc86..bef80d241 100644 --- a/client/tailscale/acl.go +++ b/client/tailscale/acl.go @@ -83,7 +83,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) { } }() - path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("acl") req, err := http.NewRequestWithContext(ctx, "GET", path, nil) if err != nil { return nil, err @@ -97,7 +97,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) { // 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) + return nil, HandleErrorResponse(b, resp) } // Otherwise, try to decode the response. @@ -126,7 +126,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) { } }() - path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("acl?details=1") req, err := http.NewRequestWithContext(ctx, "GET", path, nil) if err != nil { return nil, err @@ -138,7 +138,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) { } if resp.StatusCode != http.StatusOK { - return nil, handleErrorResponse(b, resp) + return nil, HandleErrorResponse(b, resp) } data := struct { @@ -184,7 +184,7 @@ func (e ACLTestError) Error() string { } func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("acl") req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body)) if err != nil { return nil, "", err @@ -328,7 +328,7 @@ type ACLPreview struct { } func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("acl/preview") req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body)) if err != nil { return nil, err @@ -350,7 +350,7 @@ func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, preview // 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) + return nil, HandleErrorResponse(b, resp) } if err = json.Unmarshal(b, &res); err != nil { return nil, err @@ -488,7 +488,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test return nil, err } - path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("acl/validate") req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData)) if err != nil { return nil, err diff --git a/client/tailscale/devices.go b/client/tailscale/devices.go index 9008d4d0d..b79191d53 100644 --- a/client/tailscale/devices.go +++ b/client/tailscale/devices.go @@ -131,7 +131,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL } }() - path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("devices") req, err := http.NewRequestWithContext(ctx, "GET", path, nil) if err != nil { return nil, err @@ -149,7 +149,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL // 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) + return nil, HandleErrorResponse(b, resp) } var devices GetDevicesResponse @@ -188,7 +188,7 @@ func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFiel // 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) + return nil, HandleErrorResponse(b, resp) } err = json.Unmarshal(b, &device) @@ -221,7 +221,7 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) // 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 HandleErrorResponse(b, resp) } return nil } @@ -253,7 +253,7 @@ func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized // 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 HandleErrorResponse(b, resp) } return nil @@ -281,7 +281,7 @@ func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) er // 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 HandleErrorResponse(b, resp) } return nil diff --git a/client/tailscale/dns.go b/client/tailscale/dns.go index f198742b3..bbdc7c56c 100644 --- a/client/tailscale/dns.go +++ b/client/tailscale/dns.go @@ -44,7 +44,7 @@ type DNSPreferences struct { } func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint) + path := c.BuildTailnetURL("dns", endpoint) req, err := http.NewRequestWithContext(ctx, "GET", path, nil) if err != nil { return nil, err @@ -57,14 +57,14 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er // 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) + return nil, HandleErrorResponse(b, resp) } return b, nil } func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint) + path := c.BuildTailnetURL("dns", endpoint) data, err := json.Marshal(&postData) if err != nil { return nil, err @@ -84,7 +84,7 @@ func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData a // 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) + return nil, HandleErrorResponse(b, resp) } return b, nil diff --git a/client/tailscale/keys.go b/client/tailscale/keys.go index 84bcdfae6..79e19e998 100644 --- a/client/tailscale/keys.go +++ b/client/tailscale/keys.go @@ -40,7 +40,7 @@ type KeyDeviceCreateCapabilities struct { // Keys returns the list of keys for the current user. func (c *Client) Keys(ctx context.Context) ([]string, error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("keys") req, err := http.NewRequestWithContext(ctx, "GET", path, nil) if err != nil { return nil, err @@ -51,7 +51,7 @@ func (c *Client) Keys(ctx context.Context) ([]string, error) { return nil, err } if resp.StatusCode != http.StatusOK { - return nil, handleErrorResponse(b, resp) + return nil, HandleErrorResponse(b, resp) } var keys struct { @@ -99,7 +99,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, return "", nil, err } - path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet) + path := c.BuildTailnetURL("keys") req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs)) if err != nil { return "", nil, err @@ -110,7 +110,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, return "", nil, err } if resp.StatusCode != http.StatusOK { - return "", nil, handleErrorResponse(b, resp) + return "", nil, HandleErrorResponse(b, resp) } var key struct { @@ -126,7 +126,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, // Key returns the metadata for the given key ID. Currently, capabilities are // only returned for auth keys, API keys only return general metadata. func (c *Client) Key(ctx context.Context, id string) (*Key, error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id) + path := c.BuildTailnetURL("keys", id) req, err := http.NewRequestWithContext(ctx, "GET", path, nil) if err != nil { return nil, err @@ -137,7 +137,7 @@ func (c *Client) Key(ctx context.Context, id string) (*Key, error) { return nil, err } if resp.StatusCode != http.StatusOK { - return nil, handleErrorResponse(b, resp) + return nil, HandleErrorResponse(b, resp) } var key Key @@ -149,7 +149,7 @@ func (c *Client) Key(ctx context.Context, id string) (*Key, error) { // DeleteKey deletes the key with the given ID. func (c *Client) DeleteKey(ctx context.Context, id string) error { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id) + path := c.BuildTailnetURL("keys", id) req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil) if err != nil { return err @@ -160,7 +160,7 @@ func (c *Client) DeleteKey(ctx context.Context, id string) error { return err } if resp.StatusCode != http.StatusOK { - return handleErrorResponse(b, resp) + return HandleErrorResponse(b, resp) } return nil } diff --git a/client/tailscale/routes.go b/client/tailscale/routes.go index 5912fc46c..b72f2743f 100644 --- a/client/tailscale/routes.go +++ b/client/tailscale/routes.go @@ -44,7 +44,7 @@ func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, e // 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) + return nil, HandleErrorResponse(b, resp) } var sr Routes @@ -84,7 +84,7 @@ func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip // 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) + return nil, HandleErrorResponse(b, resp) } var srr *Routes diff --git a/client/tailscale/tailnet.go b/client/tailscale/tailnet.go index 2539e7f23..9453962c9 100644 --- a/client/tailscale/tailnet.go +++ b/client/tailscale/tailnet.go @@ -9,7 +9,6 @@ "context" "fmt" "net/http" - "net/url" "tailscale.com/util/httpm" ) @@ -22,7 +21,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er } }() - path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID))) + path := c.BuildTailnetURL("tailnet") req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil) if err != nil { return err @@ -35,7 +34,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er } if resp.StatusCode != http.StatusOK { - return handleErrorResponse(b, resp) + return HandleErrorResponse(b, resp) } return nil diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index b81a7ee63..f273023eb 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -17,6 +17,8 @@ "fmt" "io" "net/http" + "net/url" + "path" ) // I_Acknowledge_This_API_Is_Unstable must be set true to use this package @@ -63,6 +65,36 @@ func (c *Client) httpClient() *http.Client { return http.DefaultClient } +// BuildURL builds a url to http(s):///api/v2/ +// using the given pathElements. It url escapes each path element, so the caller +// doesn't need to worry about that. +// +// For example, BuildURL(devices, 5) with the default server URL would result in +// https://api.tailscale.com/api/v2/devices/5. +func (c *Client) BuildURL(pathElements ...any) string { + elem := make([]string, 2, len(pathElements)+1) + elem[0] = c.baseURL() + elem[1] = "/api/v2" + for _, pathElement := range pathElements { + elem = append(elem, url.PathEscape(fmt.Sprint(pathElement))) + } + return path.Join(elem...) +} + +// BuildTailnetURL builds a url to http(s):///api/v2/tailnet// +// using the given pathElements. It url escapes each path element, so the +// caller doesn't need to worry about that. +// +// For example, BuildTailnetURL(policy, validate) with the default server URL and a tailnet of "example.com" +// would result in https://api.tailscale.com/api/v2/tailnet/example.com/policy/validate. +func (c *Client) BuildTailnetURL(pathElements ...any) string { + allElements := make([]any, 3, len(pathElements)+2) + allElements[0] = "tailnet" + allElements[1] = c.Tailnet + allElements = append(allElements, pathElements...) + return c.BuildURL(allElements...) +} + func (c *Client) baseURL() string { if c.BaseURL != "" { return c.BaseURL @@ -150,9 +182,11 @@ func (e ErrResponse) Error() string { return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message) } -// handleErrorResponse decodes the error message from the server and returns +// HandleErrorResponse decodes the error message from the server and returns // an ErrResponse from it. -func handleErrorResponse(b []byte, resp *http.Response) error { +// +// Deprecated: use tailscale.com/client/tailscale/v2 instead. +func HandleErrorResponse(b []byte, resp *http.Response) error { var errResp ErrResponse if err := json.Unmarshal(b, &errResp); err != nil { return err diff --git a/cmd/k8s-operator/ingress-for-pg.go b/cmd/k8s-operator/ingress-for-pg.go index b07882deb..4fa0af2a2 100644 --- a/cmd/k8s-operator/ingress-for-pg.go +++ b/cmd/k8s-operator/ingress-for-pg.go @@ -26,7 +26,7 @@ "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/client/tailscale" + "tailscale.com/internal/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" tsoperator "tailscale.com/k8s-operator" @@ -186,7 +186,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin } dnsName := hostname + "." + tcd serviceName := tailcfg.ServiceName("svc:" + hostname) - existingVIPSvc, err := a.tsClient.getVIPService(ctx, serviceName) + existingVIPSvc, err := a.tsClient.GetVIPService(ctx, serviceName) // TODO(irbekrm): here and when creating the VIPService, verify if the error is not terminal (and therefore // should not be reconciled). For example, if the hostname is already a hostname of a Tailscale node, the GET // here will fail. @@ -269,7 +269,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin vipPorts = append(vipPorts, "80") } - vipSvc := &VIPService{ + vipSvc := &tailscale.VIPService{ Name: serviceName, Tags: tags, Ports: vipPorts, @@ -282,7 +282,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin !reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) || !reflect.DeepEqual(vipSvc.Ports, existingVIPSvc.Ports) { logger.Infof("Ensuring VIPService %q exists and is up to date", hostname) - if err := a.tsClient.createOrUpdateVIPService(ctx, vipSvc); err != nil { + if err := a.tsClient.CreateOrUpdateVIPService(ctx, vipSvc); err != nil { logger.Infof("error creating VIPService: %v", err) return fmt.Errorf("error creating VIPService: %w", err) } @@ -361,7 +361,7 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG } if isVIPServiceForAnyIngress(svc) { logger.Infof("cleaning up orphaned VIPService %q", vipServiceName) - if err := a.tsClient.deleteVIPService(ctx, vipServiceName); err != nil { + if err := a.tsClient.DeleteVIPService(ctx, vipServiceName); err != nil { errResp := &tailscale.ErrResponse{} if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound { return fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err) @@ -509,8 +509,8 @@ func (a *IngressPGReconciler) shouldExpose(ing *networkingv1.Ingress) bool { return isTSIngress && pgAnnot != "" } -func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (*VIPService, error) { - svc, err := a.tsClient.getVIPService(ctx, name) +func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (*tailscale.VIPService, error) { + svc, err := a.tsClient.GetVIPService(ctx, name) if err != nil { errResp := &tailscale.ErrResponse{} if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound { @@ -521,14 +521,14 @@ func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.Se return svc, nil } -func isVIPServiceForIngress(svc *VIPService, ing *networkingv1.Ingress) bool { +func isVIPServiceForIngress(svc *tailscale.VIPService, ing *networkingv1.Ingress) bool { if svc == nil || ing == nil { return false } return strings.EqualFold(svc.Comment, fmt.Sprintf(VIPSvcOwnerRef, ing.UID)) } -func isVIPServiceForAnyIngress(svc *VIPService) bool { +func isVIPServiceForAnyIngress(svc *tailscale.VIPService) bool { if svc == nil { return false } @@ -593,7 +593,7 @@ func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name } logger.Infof("Deleting VIPService %q", name) - if err = a.tsClient.deleteVIPService(ctx, name); err != nil { + if err = a.tsClient.DeleteVIPService(ctx, name); err != nil { return fmt.Errorf("error deleting VIPService: %w", err) } return nil diff --git a/cmd/k8s-operator/ingress-for-pg_test.go b/cmd/k8s-operator/ingress-for-pg_test.go index ee8a94336..c432eb7e1 100644 --- a/cmd/k8s-operator/ingress-for-pg_test.go +++ b/cmd/k8s-operator/ingress-for-pg_test.go @@ -70,7 +70,7 @@ func TestIngressPGReconciler(t *testing.T) { expectReconciled(t, ingPGR, "default", "test-ingress") // Verify VIPService uses custom tags - vipSvc, err := ft.getVIPService(context.Background(), "svc:my-svc") + vipSvc, err := ft.GetVIPService(context.Background(), "svc:my-svc") if err != nil { t.Fatalf("getting VIPService: %v", err) } @@ -398,7 +398,7 @@ func TestIngressPGReconciler_HTTPEndpoint(t *testing.T) { func verifyVIPService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) { t.Helper() - vipSvc, err := ft.getVIPService(context.Background(), tailcfg.ServiceName(serviceName)) + vipSvc, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(serviceName)) if err != nil { t.Fatalf("getting VIPService %q: %v", serviceName, err) } diff --git a/cmd/k8s-operator/testutils_test.go b/cmd/k8s-operator/testutils_test.go index 386005b1f..6b1a4f85b 100644 --- a/cmd/k8s-operator/testutils_test.go +++ b/cmd/k8s-operator/testutils_test.go @@ -28,7 +28,7 @@ "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/client/tailscale" + "tailscale.com/internal/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" @@ -768,7 +768,7 @@ type fakeTSClient struct { sync.Mutex keyRequests []tailscale.KeyCapabilities deleted []string - vipServices map[tailcfg.ServiceName]*VIPService + vipServices map[tailcfg.ServiceName]*tailscale.VIPService } type fakeTSNetServer struct { certDomains []string @@ -875,7 +875,7 @@ func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) { } } -func (c *fakeTSClient) getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) { +func (c *fakeTSClient) GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error) { c.Lock() defer c.Unlock() if c.vipServices == nil { @@ -888,17 +888,17 @@ func (c *fakeTSClient) getVIPService(ctx context.Context, name tailcfg.ServiceNa return svc, nil } -func (c *fakeTSClient) createOrUpdateVIPService(ctx context.Context, svc *VIPService) error { +func (c *fakeTSClient) CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error { c.Lock() defer c.Unlock() if c.vipServices == nil { - c.vipServices = make(map[tailcfg.ServiceName]*VIPService) + c.vipServices = make(map[tailcfg.ServiceName]*tailscale.VIPService) } c.vipServices[svc.Name] = svc return nil } -func (c *fakeTSClient) deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error { +func (c *fakeTSClient) DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error { c.Lock() defer c.Unlock() if c.vipServices != nil { diff --git a/cmd/k8s-operator/tsclient.go b/cmd/k8s-operator/tsclient.go index acbc96520..3101da75d 100644 --- a/cmd/k8s-operator/tsclient.go +++ b/cmd/k8s-operator/tsclient.go @@ -6,19 +6,13 @@ package main import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" "os" "golang.org/x/oauth2/clientcredentials" "tailscale.com/internal/client/tailscale" "tailscale.com/tailcfg" - "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 @@ -45,141 +39,14 @@ func newTSClient(ctx context.Context, clientIDPath, clientSecretPath string) (ts 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 + return c, 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 - getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) - createOrUpdateVIPService(ctx context.Context, svc *VIPService) error - deleteVIPService(ctx context.Context, name tailcfg.ServiceName) 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 a VIPService name in form svc:. - Name tailcfg.ServiceName `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) getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(name.String())) - 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 -} - -// createOrUpdateVIPService 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) createOrUpdateVIPService(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/%s", c.baseURL, c.tailnet, url.PathEscape(svc.Name.String())) - 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) deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error { - path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(name.String())) - 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 + GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error) + CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error + DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error } diff --git a/internal/client/tailscale/tailscale.go b/internal/client/tailscale/tailscale.go index d29927e3f..cba7228bb 100644 --- a/internal/client/tailscale/tailscale.go +++ b/internal/client/tailscale/tailscale.go @@ -8,9 +8,16 @@ package tailscale import ( + "errors" + "io" + "net/http" + tsclient "tailscale.com/client/tailscale" ) +// maxSize is the maximum read size (10MB) of responses from the server. +const maxReadSize = 10 << 20 + func init() { tsclient.I_Acknowledge_This_API_Is_Unstable = true } @@ -50,3 +57,27 @@ func NewClient(tailnet string, auth AuthMethod) *Client { type Client struct { *tsclient.Client } + +// HandleErrorResponse is an alias to tailscale.com/client/tailscale. +func HandleErrorResponse(b []byte, resp *http.Response) error { + return tsclient.HandleErrorResponse(b, resp) +} + +// SendRequest add the authentication key to the request and sends it. It +// receives the response and reads up to 10MB of it. +func SendRequest(c *Client, req *http.Request) ([]byte, *http.Response, error) { + resp, err := c.Do(req) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + // Read response. Limit the response to 10MB. + // This limit is carried over from client/tailscale/tailscale.go. + body := io.LimitReader(resp.Body, maxReadSize+1) + b, err := io.ReadAll(body) + if len(b) > maxReadSize { + err = errors.New("API response too large") + } + return b, resp, err +} diff --git a/internal/client/tailscale/vip_service.go b/internal/client/tailscale/vip_service.go new file mode 100644 index 000000000..958192c4d --- /dev/null +++ b/internal/client/tailscale/vip_service.go @@ -0,0 +1,103 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package tailscale + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "tailscale.com/tailcfg" + "tailscale.com/util/httpm" +) + +// VIPService is a Tailscale VIPService with Tailscale API JSON representation. +type VIPService struct { + // Name is a VIPService name in form svc:. + Name tailcfg.ServiceName `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"` +} + +// GetVIPService retrieves a VIPService by its name. It returns 404 if the VIPService is not found. +func (client *Client) GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) { + path := client.BuildTailnetURL("vip-services", name.String()) + 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 := SendRequest(client, 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 +} + +// CreateOrUpdateVIPService 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 (client *Client) CreateOrUpdateVIPService(ctx context.Context, svc *VIPService) error { + data, err := json.Marshal(svc) + if err != nil { + return err + } + path := client.BuildTailnetURL("vip-services", svc.Name.String()) + 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 := SendRequest(client, 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 +} + +// DeleteVIPService deletes a VIPService by its name. It returns an error if the VIPService +// does not exist or if the deletion fails. +func (client *Client) DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error { + path := client.BuildTailnetURL("vip-services", name.String()) + 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 := SendRequest(client, 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 +}