mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
client/tailscale: move API client for the control admin API
This was work done Nov-Dec 2020 by @c22wen and @chungdaniel. This is just moving it to another repo. Co-Authored-By: Christina Wen <37028905+c22wen@users.noreply.github.com> Co-Authored-By: Christina Wen <christina@tailscale.com> Co-Authored-By: Daniel Chung <chungdaniel@users.noreply.github.com> Co-Authored-By: Daniel Chung <daniel@tailscale.com> Change-Id: I6da3b05b972b54771f796b5be82de5aa463635ca Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
e3619b890c
commit
a54671529b
469
client/tailscale/acl.go
Normal file
469
client/tailscale/acl.go
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build go1.18
|
||||||
|
// +build go1.18
|
||||||
|
|
||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"inet.af/netaddr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ACLRow defines a rule that grants access by a set of users or groups to a set of servers and ports.
|
||||||
|
type ACLRow struct {
|
||||||
|
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||||
|
Users []string `json:"users,omitempty"`
|
||||||
|
Ports []string `json:"ports,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLTest defines a test for your ACLs to prevent accidental exposure or revoking of access to key servers and ports.
|
||||||
|
type ACLTest struct {
|
||||||
|
User string `json:"user,omitempty"` // source
|
||||||
|
Allow []string `json:"allow,omitempty"` // expected destination ip:port that user can access
|
||||||
|
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLDetails contains all the details for an ACL.
|
||||||
|
type ACLDetails struct {
|
||||||
|
Tests []ACLTest `json:"tests,omitempty"`
|
||||||
|
ACLs []ACLRow `json:"acls,omitempty"`
|
||||||
|
Groups map[string][]string `json:"groups,omitempty"`
|
||||||
|
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||||
|
Hosts map[string]string `json:"hosts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACL contains an ACLDetails and metadata.
|
||||||
|
type ACL struct {
|
||||||
|
ACL ACLDetails
|
||||||
|
ETag string // to check with version on server
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLHuJSON contains the HuJSON string of the ACL and metadata.
|
||||||
|
type ACLHuJSON struct {
|
||||||
|
ACL string
|
||||||
|
Warnings []string
|
||||||
|
ETag string // to check with version on server
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACL makes a call to the Tailscale server to get a JSON-parsed version of the ACL.
|
||||||
|
// The JSON-parsed version of the ACL contains no comments as proper JSON does not support
|
||||||
|
// comments.
|
||||||
|
func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.ACL: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.BaseURL, c.Tailnet)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to decode the response.
|
||||||
|
var aclDetails ACLDetails
|
||||||
|
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
acl = &ACL{
|
||||||
|
ACL: aclDetails,
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
}
|
||||||
|
return acl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLHuJSON makes a call to the Tailscale server to get the ACL HuJSON and returns
|
||||||
|
// it as a string.
|
||||||
|
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
|
||||||
|
// changes are allowing comments and trailing comments. See the following links for more info:
|
||||||
|
// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format
|
||||||
|
// https://github.com/tailscale/hujson
|
||||||
|
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.ACLHuJSON: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.BaseURL, c.Tailnet)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/hujson")
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, handleErrorResponse(b, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
ACL []byte `json:"acl"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}{}
|
||||||
|
if err := json.Unmarshal(b, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acl = &ACLHuJSON{
|
||||||
|
ACL: string(data.ACL),
|
||||||
|
Warnings: data.Warnings,
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
}
|
||||||
|
return acl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLTestFailureSummary specifies a user for which ACL tests
|
||||||
|
// failed and the related user-friendly error messages.
|
||||||
|
//
|
||||||
|
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||||
|
// JavaScript client to be rendered in the HTML.
|
||||||
|
type ACLTestFailureSummary struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
|
||||||
|
type ACLTestError struct {
|
||||||
|
ErrResponse
|
||||||
|
Data []ACLTestFailureSummary `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ACLTestError) Error() string {
|
||||||
|
return fmt.Sprintf("%s, Data: %+v", e.ErrResponse.Error(), e.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if avoidCollisions {
|
||||||
|
req.Header.Set("If-Match", etag)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", acceptHeader)
|
||||||
|
req.Header.Set("Content-Type", "application/hujson")
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", 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 {
|
||||||
|
// check if test error
|
||||||
|
var ate ACLTestError
|
||||||
|
if err := json.Unmarshal(b, &ate); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
ate.Status = resp.StatusCode
|
||||||
|
return nil, "", ate
|
||||||
|
}
|
||||||
|
return b, resp.Header.Get("ETag"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetACL sends a POST request to update the ACL according to the provided ACL object. If
|
||||||
|
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||||
|
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||||
|
// were missed.
|
||||||
|
//
|
||||||
|
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||||
|
// Returns error if ACL has tests that fail.
|
||||||
|
// Returns error if there are other errors with the ACL.
|
||||||
|
func (c *Client) SetACL(ctx context.Context, acl ACL, avoidCollisions bool) (res *ACL, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetACL: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
postData, err := json.Marshal(acl.ACL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to decode the response.
|
||||||
|
var aclDetails ACLDetails
|
||||||
|
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res = &ACL{
|
||||||
|
ACL: aclDetails,
|
||||||
|
ETag: etag,
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetACLHuJSON sends a POST request to update the ACL according to the provided ACL object. If
|
||||||
|
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||||
|
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||||
|
// were missed.
|
||||||
|
//
|
||||||
|
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||||
|
// Returns error if the HuJSON is invalid.
|
||||||
|
// Returns error if ACL has tests that fail.
|
||||||
|
// Returns error if there are other errors with the ACL.
|
||||||
|
func (c *Client) SetACLHuJSON(ctx context.Context, acl ACLHuJSON, avoidCollisions bool) (res *ACLHuJSON, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetACLHuJSON: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
postData := []byte(acl.ACL)
|
||||||
|
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/hujson")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res = &ACLHuJSON{
|
||||||
|
ACL: string(b),
|
||||||
|
ETag: etag,
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRuleMatch specifies the source users/groups/hosts that a rule targets
|
||||||
|
// and the destination ports that they can access.
|
||||||
|
// LineNumber is only useful for requests provided in HuJSON form.
|
||||||
|
// While JSON requests will have LineNumber, the value is not useful.
|
||||||
|
type UserRuleMatch struct {
|
||||||
|
Users []string `json:"users"`
|
||||||
|
Ports []string `json:"ports"`
|
||||||
|
LineNumber int `json:"lineNumber"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||||
|
type ACLPreviewResponse struct {
|
||||||
|
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
|
||||||
|
Type string `json:"type"` // The request type: currently only "user" or "ipport".
|
||||||
|
PreviewFor string `json:"previewFor"` // A specific user or ipport.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||||
|
type ACLPreview struct {
|
||||||
|
Matches []UserRuleMatch `json:"matches"`
|
||||||
|
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
|
||||||
|
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("type", previewType)
|
||||||
|
q.Add("previewFor", previewFor)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/hujson")
|
||||||
|
req.SetBasicAuth(c.APIKey, "")
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(b, &res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewACLForUser determines what rules match a given ACL for a user.
|
||||||
|
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||||
|
//
|
||||||
|
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||||
|
// the call is still successful but Matches will be an empty slice.
|
||||||
|
// Returns error if the provided ACL is invalid.
|
||||||
|
func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (res *ACLPreview, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.PreviewACLForUser: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
postData, err := json.Marshal(acl.ACL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ACLPreview{
|
||||||
|
Matches: b.Matches,
|
||||||
|
User: b.PreviewFor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewACLForIPPort determines what rules match a given ACL for a ipport.
|
||||||
|
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||||
|
//
|
||||||
|
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||||
|
// the call is still successful but Matches will be an empty slice.
|
||||||
|
// Returns error if the provided ACL is invalid.
|
||||||
|
func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netaddr.IPPort) (res *ACLPreview, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.PreviewACLForIPPort: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
postData, err := json.Marshal(acl.ACL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ACLPreview{
|
||||||
|
Matches: b.Matches,
|
||||||
|
IPPort: b.PreviewFor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewACLHuJSONForUser determines what rules match a given ACL for a user.
|
||||||
|
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||||
|
//
|
||||||
|
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||||
|
// the call is still successful but Matches will be an empty slice.
|
||||||
|
// Returns error if the provided ACL is invalid.
|
||||||
|
func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, user string) (res *ACLPreview, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.PreviewACLHuJSONForUser: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
postData := []byte(acl.ACL)
|
||||||
|
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ACLPreview{
|
||||||
|
Matches: b.Matches,
|
||||||
|
User: b.PreviewFor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewACLHuJSONForIPPort determines what rules match a given ACL for a ipport.
|
||||||
|
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||||
|
//
|
||||||
|
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||||
|
// the call is still successful but Matches will be an empty slice.
|
||||||
|
// Returns error if the provided ACL is invalid.
|
||||||
|
func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, ipport string) (res *ACLPreview, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.PreviewACLHuJSONForIPPort: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
postData := []byte(acl.ACL)
|
||||||
|
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ACLPreview{
|
||||||
|
Matches: b.Matches,
|
||||||
|
IPPort: b.PreviewFor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateACLJSON takes in the given source and destination (in this situation,
|
||||||
|
// it is assumed that you are checking whether the source can connect to destination)
|
||||||
|
// and creates an ACLTest from that. It then sends the ACLTest to the control api acl
|
||||||
|
// validate endpoint, where the test is run. It returns a nil ACLTestError pointer if
|
||||||
|
// no test errors occur.
|
||||||
|
func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (testErr *ACLTestError, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.ValidateACLJSON: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
|
||||||
|
postData, err := json.Marshal(tests)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.BaseURL, c.Tailnet)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(c.APIKey, "")
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("control api responsed with %d status code", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The test ran without fail
|
||||||
|
if len(b) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var res ACLTestError
|
||||||
|
// The test returned errors.
|
||||||
|
if err = json.Unmarshal(b, &res); err != nil {
|
||||||
|
// failed to unmarshal
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &res, nil
|
||||||
|
}
|
20
client/tailscale/apitype/controltype.go
Normal file
20
client/tailscale/apitype/controltype.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package apitype
|
||||||
|
|
||||||
|
type DNSConfig struct {
|
||||||
|
Resolvers []DNSResolver `json:"resolvers"`
|
||||||
|
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
|
||||||
|
Routes map[string][]DNSResolver `json:"routes"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
Nameservers []string `json:"nameservers"`
|
||||||
|
Proxied bool `json:"proxied"`
|
||||||
|
PerDomain bool `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSResolver struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
|
||||||
|
}
|
262
client/tailscale/devices.go
Normal file
262
client/tailscale/devices.go
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build go1.18
|
||||||
|
// +build go1.18
|
||||||
|
|
||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"tailscale.com/types/opt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetDevicesResponse struct {
|
||||||
|
Devices []*Device `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerpRegion struct {
|
||||||
|
Preferred bool `json:"preferred,omitempty"`
|
||||||
|
LatencyMilliseconds float64 `json:"latencyMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientConnectivity struct {
|
||||||
|
Endpoints []string `json:"endpoints"`
|
||||||
|
DERP string `json:"derp"`
|
||||||
|
MappingVariesByDestIP opt.Bool `json:"mappingVariesByDestIP"`
|
||||||
|
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
|
||||||
|
DERPLatency map[string]DerpRegion `json:"latency"`
|
||||||
|
ClientSupports map[string]opt.Bool `json:"clientSupports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
// Addresses is a list of the devices's Tailscale IP addresses.
|
||||||
|
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
DeviceID string `json:"id"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
|
||||||
|
ClientVersion string `json:"clientVersion"` // Empty for external devices.
|
||||||
|
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
|
||||||
|
OS string `json:"os"`
|
||||||
|
Created string `json:"created"` // Empty for external devices.
|
||||||
|
LastSeen string `json:"lastSeen"`
|
||||||
|
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
|
||||||
|
Expires string `json:"expires"`
|
||||||
|
Authorized bool `json:"authorized"`
|
||||||
|
IsExternal bool `json:"isExternal"`
|
||||||
|
MachineKey string `json:"machineKey"` // Empty for external devices.
|
||||||
|
NodeKey string `json:"nodeKey"`
|
||||||
|
|
||||||
|
// BlocksIncomingConnections is configured via the device's
|
||||||
|
// Tailscale client preferences. This field is only reported
|
||||||
|
// to the API starting with Tailscale 1.3.x clients.
|
||||||
|
BlocksIncomingConnections bool `json:"blocksIncomingConnections"`
|
||||||
|
|
||||||
|
// The following fields are not included by default:
|
||||||
|
|
||||||
|
// EnabledRoutes are the previously-approved subnet routes
|
||||||
|
// (e.g. "192.168.4.16/24", "10.5.2.4/32").
|
||||||
|
EnabledRoutes []string `json:"enabledRoutes"` // Empty for external devices.
|
||||||
|
// AdvertisedRoutes are the subnets (both enabled and not enabled)
|
||||||
|
// being requested from the node.
|
||||||
|
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
|
||||||
|
|
||||||
|
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceFieldsOpts determines which fields should be returned in the response.
|
||||||
|
//
|
||||||
|
// Please only use DeviceAllFields and DeviceDefaultFields.
|
||||||
|
// Other DeviceFieldsOpts are not supported.
|
||||||
|
//
|
||||||
|
// TODO: Support other DeviceFieldsOpts.
|
||||||
|
// In the future, users should be able to create their own DeviceFieldsOpts
|
||||||
|
// as valid arguments by setting the fields they want returned to a "non-nil"
|
||||||
|
// value. For example, DeviceFieldsOpts{NodeID: "true"} should only return NodeIDs.
|
||||||
|
type DeviceFieldsOpts Device
|
||||||
|
|
||||||
|
func (d *DeviceFieldsOpts) addFieldsToQueryParameter() string {
|
||||||
|
if d == DeviceDefaultFields || d == nil {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
if d == DeviceAllFields {
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
DeviceAllFields = &DeviceFieldsOpts{}
|
||||||
|
|
||||||
|
// DeviceDefaultFields specifies that the following fields are returned:
|
||||||
|
// Addresses, NodeID, User, Name, Hostname, ClientVersion, UpdateAvailable,
|
||||||
|
// OS, Created, LastSeen, KeyExpiryDisabled, Expires, Authorized, IsExternal
|
||||||
|
// MachineKey, NodeKey, BlocksIncomingConnections.
|
||||||
|
DeviceDefaultFields = &DeviceFieldsOpts{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Devices retrieves the list of devices for a tailnet.
|
||||||
|
//
|
||||||
|
// See the Device structure for the list of fields hidden for external devices.
|
||||||
|
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||||
|
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||||
|
// Other values are currently undefined.
|
||||||
|
func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceList []*Device, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.Devices: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.BaseURL, c.Tailnet)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Add fields.
|
||||||
|
fieldStr := fields.addFieldsToQueryParameter()
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("fields", fieldStr)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices GetDevicesResponse
|
||||||
|
err = json.Unmarshal(b, &devices)
|
||||||
|
return devices.Devices, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device retrieved the details for a specific device.
|
||||||
|
//
|
||||||
|
// See the Device structure for the list of fields hidden for an external device.
|
||||||
|
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||||
|
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||||
|
// Other values are currently undefined.
|
||||||
|
func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFieldsOpts) (device *Device, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.Device: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
path := fmt.Sprintf("%s/api/v2/device/%s", c.BaseURL, deviceID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add fields.
|
||||||
|
fieldStr := fields.addFieldsToQueryParameter()
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("fields", fieldStr)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(b, &device)
|
||||||
|
return device, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDevice deletes the specified device from the Client's tailnet.
|
||||||
|
// NOTE: Only devices that belong to the Client's tailnet can be deleted.
|
||||||
|
// Deleting external devices is not supported.
|
||||||
|
func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.DeleteDevice: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/device/%s", c.BaseURL, url.PathEscape(deviceID))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeDevice marks a device as authorized.
|
||||||
|
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
|
||||||
|
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.BaseURL, url.PathEscape(deviceID))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTags updates the ACL tags on a device.
|
||||||
|
func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) error {
|
||||||
|
params := &struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}{Tags: tags}
|
||||||
|
data, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("%s/api/v2/device/%s/tags", c.BaseURL, url.PathEscape(deviceID))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return 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
|
||||||
|
}
|
235
client/tailscale/dns.go
Normal file
235
client/tailscale/dns.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build go1.18
|
||||||
|
// +build go1.18
|
||||||
|
|
||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tailscale.com/client/tailscale/apitype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNSNameServers is returned when retrieving the list of nameservers.
|
||||||
|
// It is also the structure provided when setting nameservers.
|
||||||
|
type DNSNameServers struct {
|
||||||
|
DNS []string `json:"dns"` // DNS name servers
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
|
||||||
|
//
|
||||||
|
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
|
||||||
|
type DNSNameServersPostResponse struct {
|
||||||
|
DNS []string `json:"dns"` // DNS name servers
|
||||||
|
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSSearchpaths is the list of search paths for a given domain.
|
||||||
|
type DNSSearchPaths struct {
|
||||||
|
SearchPaths []string `json:"searchPaths"` // DNS search paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSPreferences is the preferences set for a given tailnet.
|
||||||
|
//
|
||||||
|
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
|
||||||
|
// there must be at least one nameserver. When all nameservers are removed,
|
||||||
|
// MagicDNS is disabled.
|
||||||
|
type DNSPreferences struct {
|
||||||
|
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]byte, error) {
|
||||||
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.BaseURL, c.Tailnet, endpoint)
|
||||||
|
data, err := json.Marshal(&postData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSConfig retrieves the DNSConfig settings for a domain.
|
||||||
|
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
b, err := c.dnsGETRequest(ctx, "config")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dnsResp apitype.DNSConfig
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return &dnsResp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var dnsResp apitype.DNSConfig
|
||||||
|
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return &dnsResp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NameServers retrieves the list of nameservers set for a domain.
|
||||||
|
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.NameServers: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
b, err := c.dnsGETRequest(ctx, "nameservers")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dnsResp DNSNameServers
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return dnsResp.DNS, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNameServers sets the list of nameservers for a tailnet to the list provided
|
||||||
|
// by the user.
|
||||||
|
//
|
||||||
|
// It returns the new list of nameservers and the MagicDNS status in case it was
|
||||||
|
// affected by the change. For example, removing all nameservers will turn off
|
||||||
|
// MagicDNS.
|
||||||
|
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
dnsReq := DNSNameServers{DNS: nameservers}
|
||||||
|
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return dnsResp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSPreferences retrieves the DNS preferences set for a tailnet.
|
||||||
|
//
|
||||||
|
// It returns the status of MagicDNS.
|
||||||
|
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
|
||||||
|
// Format return errors to be descriptive.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
b, err := c.dnsGETRequest(ctx, "preferences")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return dnsResp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDNSPreferences sets the DNS preferences for a tailnet.
|
||||||
|
//
|
||||||
|
// MagicDNS can only be enabled when there is at least one nameserver provided.
|
||||||
|
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
|
||||||
|
// unless explicitly enabled by a user again.
|
||||||
|
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
dnsReq := DNSPreferences{MagicDNS: magicDNS}
|
||||||
|
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return dnsResp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchPaths retrieves the list of searchpaths set for a tailnet.
|
||||||
|
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
b, err := c.dnsGETRequest(ctx, "searchpaths")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dnsResp *DNSSearchPaths
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return dnsResp.SearchPaths, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSearchPaths sets the list of searchpaths for a tailnet.
|
||||||
|
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
|
||||||
|
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dnsResp DNSSearchPaths
|
||||||
|
err = json.Unmarshal(b, &dnsResp)
|
||||||
|
return dnsResp.SearchPaths, err
|
||||||
|
}
|
@ -5,7 +5,6 @@
|
|||||||
//go:build go1.18
|
//go:build go1.18
|
||||||
// +build go1.18
|
// +build go1.18
|
||||||
|
|
||||||
// Package tailscale contains Tailscale client code.
|
|
||||||
package tailscale
|
package tailscale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
98
client/tailscale/routes.go
Normal file
98
client/tailscale/routes.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build go1.18
|
||||||
|
// +build go1.18
|
||||||
|
|
||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"inet.af/netaddr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes contains the lists of subnet routes that are currently advertised by a device,
|
||||||
|
// as well as the subnets that are enabled to be routed by the device.
|
||||||
|
type Routes struct {
|
||||||
|
AdvertisedRoutes []netaddr.IPPrefix `json:"advertisedRoutes"`
|
||||||
|
EnabledRoutes []netaddr.IPPrefix `json:"enabledRoutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes retrieves the list of subnet routes that have been enabled for a device.
|
||||||
|
// The routes that are returned are not necessarily advertised by the device,
|
||||||
|
// they have only been preapproved.
|
||||||
|
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.Routes: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.BaseURL, deviceID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sr Routes
|
||||||
|
err = json.Unmarshal(b, &sr)
|
||||||
|
return &sr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type postRoutesParams struct {
|
||||||
|
Routes []netaddr.IPPrefix `json:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRoutes updates the list of subnets that are enabled for a device.
|
||||||
|
// Subnets must be parsable by inet.af/netaddr.ParseIPPrefix.
|
||||||
|
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
|
||||||
|
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
|
||||||
|
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netaddr.IPPrefix) (routes *Routes, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
params := &postRoutesParams{Routes: subnets}
|
||||||
|
data, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.BaseURL, deviceID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var srr *Routes
|
||||||
|
if err := json.Unmarshal(b, &srr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return srr, err
|
||||||
|
}
|
42
client/tailscale/tailnet.go
Normal file
42
client/tailscale/tailnet.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build go1.18
|
||||||
|
// +build go1.18
|
||||||
|
|
||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
|
||||||
|
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.BaseURL, url.PathEscape(string(tailnetID)))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.SetBasicAuth(c.APIKey, "")
|
||||||
|
b, resp, err := c.sendRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return handleErrorResponse(b, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
112
client/tailscale/tailscale.go
Normal file
112
client/tailscale/tailscale.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build go1.18
|
||||||
|
// +build go1.18
|
||||||
|
|
||||||
|
// Package tailscale contains Go clients for the Tailscale Local API and
|
||||||
|
// Tailscale control plane API.
|
||||||
|
//
|
||||||
|
// Warning: this package is in development and makes no API compatibility
|
||||||
|
// promises as of 2022-04-29. It is subject to change at any time.
|
||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||||
|
// for now. It was added 2022-04-29 when it was moved to this git repo
|
||||||
|
// and will be removed when the public API has settled.
|
||||||
|
//
|
||||||
|
// TODO(bradfitz): remove this after the we're happy with the public API.
|
||||||
|
var I_Acknowledge_This_API_Is_Unstable = false
|
||||||
|
|
||||||
|
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
|
||||||
|
|
||||||
|
// DefaultURL is the default base URL used for API calls.
|
||||||
|
const DefaultURL = "https://api.tailscale.com"
|
||||||
|
|
||||||
|
// maxSize is the maximum read size (10MB) of responses from the server.
|
||||||
|
const maxReadSize int64 = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// Client is needed to make different API calls to the Tailscale server.
|
||||||
|
// It holds all the necessary information so that it can be reused to make
|
||||||
|
// multiple requests for the same user.
|
||||||
|
// Unless overridden, "api.tailscale.com" is the default BaseURL.
|
||||||
|
type Client struct {
|
||||||
|
// Tailnet is the globally unique identifier for a Tailscale network, such
|
||||||
|
// as "example.com" or "user@gmail.com".
|
||||||
|
Tailnet string
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New is a convenience method for instantiating a new Client.
|
||||||
|
//
|
||||||
|
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||||
|
// as "example.com" or "user@gmail.com".
|
||||||
|
// If httpClient is nil, then http.DefaultClient is used.
|
||||||
|
// "api.tailscale.com" is set as the BaseURL for the returned client
|
||||||
|
// and can be changed manually by the user.
|
||||||
|
func New(tailnet string, key string, httpClient *http.Client) *Client {
|
||||||
|
c := &Client{
|
||||||
|
Tailnet: tailnet,
|
||||||
|
APIKey: key,
|
||||||
|
BaseURL: DefaultURL,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpClient == nil {
|
||||||
|
c.HTTPClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRequest add the authenication key to the request and sends it. It
|
||||||
|
// receives the response and reads up to 10MB of it.
|
||||||
|
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||||
|
if !I_Acknowledge_This_API_Is_Unstable {
|
||||||
|
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(c.APIKey, "")
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response. Limit the response to 10MB.
|
||||||
|
body := io.LimitReader(resp.Body, maxReadSize)
|
||||||
|
b, err := ioutil.ReadAll(body)
|
||||||
|
return b, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrResponse is the HTTP error returned by the Tailscale server.
|
||||||
|
type ErrResponse struct {
|
||||||
|
Status int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// an ErrResponse from it.
|
||||||
|
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||||
|
var errResp ErrResponse
|
||||||
|
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
errResp.Status = resp.StatusCode
|
||||||
|
return errResp
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user