mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 13:05:46 +00:00
da8def8e13
The //go:build syntax was introduced in Go 1.17: https://go.dev/doc/go1.17#build-lines gofmt has kept the +build and go:build lines in sync since then, but enough time has passed. Time to remove them. Done with: perl -i -npe 's,^// \+build.*\n,,' $(git grep -l -F '+build') Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
477 lines
15 KiB
Go
477 lines
15 KiB
Go
// 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.19
|
|
|
|
package tailscale
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
)
|
|
|
|
// ACLRow defines a rule that grants access by a set of users or groups to a set
|
|
// of servers and ports.
|
|
// Only one of Src/Dst or Users/Ports may be specified.
|
|
type ACLRow struct {
|
|
Action string `json:"action,omitempty"` // valid values: "accept"
|
|
Users []string `json:"users,omitempty"` // old name for src
|
|
Ports []string `json:"ports,omitempty"` // old name for dst
|
|
Src []string `json:"src,omitempty"`
|
|
Dst []string `json:"dst,omitempty"`
|
|
}
|
|
|
|
// ACLTest defines a test for your ACLs to prevent accidental exposure or
|
|
// revoking of access to key servers and ports. Only one of Src or User may be
|
|
// specified, and only one of Allow/Accept may be specified.
|
|
type ACLTest struct {
|
|
Src string `json:"src,omitempty"` // source
|
|
User string `json:"user,omitempty"` // old name for source
|
|
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
|
|
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
|
|
|
|
Allow []string `json:"allow,omitempty"` // old name for accept
|
|
}
|
|
|
|
// 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")
|
|
c.setAuth(req)
|
|
|
|
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 netip.AddrPort) (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")
|
|
c.setAuth(req)
|
|
|
|
b, resp, err := c.sendRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("control api responded 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
|
|
}
|