mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-02 22:45:37 +00:00
c86a610eb3
This option allows logging the raw HTTP requests and responses that the portmapper Client makes when using UPnP. This can be extremely helpful when debugging strange UPnP issues with users' devices, and might allow us to avoid having to instruct users to perform a packet capture. Updates #8992 Signed-off-by: Andrew Dunham <andrew@du.nham.ca> Change-Id: I2c3cf6930b09717028deaff31738484cc9b008e4
1357 lines
45 KiB
Go
1357 lines
45 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
||
// SPDX-License-Identifier: BSD-3-Clause
|
||
|
||
//go:build go1.19
|
||
|
||
package tailscale
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptrace"
|
||
"net/netip"
|
||
"net/url"
|
||
"os/exec"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"go4.org/mem"
|
||
"tailscale.com/client/tailscale/apitype"
|
||
"tailscale.com/envknob"
|
||
"tailscale.com/ipn"
|
||
"tailscale.com/ipn/ipnstate"
|
||
"tailscale.com/net/netutil"
|
||
"tailscale.com/paths"
|
||
"tailscale.com/safesocket"
|
||
"tailscale.com/tailcfg"
|
||
"tailscale.com/tka"
|
||
"tailscale.com/types/key"
|
||
"tailscale.com/types/tkatype"
|
||
"tailscale.com/util/cmpx"
|
||
)
|
||
|
||
// defaultLocalClient is the default LocalClient when using the legacy
|
||
// package-level functions.
|
||
var defaultLocalClient LocalClient
|
||
|
||
// LocalClient is a client to Tailscale's "LocalAPI", communicating with the
|
||
// Tailscale daemon on the local machine. Its API is not necessarily stable and
|
||
// subject to changes between releases. Some API calls have stricter
|
||
// compatibility guarantees, once they've been widely adopted. See method docs
|
||
// for details.
|
||
//
|
||
// Its zero value is valid to use.
|
||
//
|
||
// Any exported fields should be set before using methods on the type
|
||
// and not changed thereafter.
|
||
type LocalClient struct {
|
||
// Dial optionally specifies an alternate func that connects to the local
|
||
// machine's tailscaled or equivalent. If nil, a default is used.
|
||
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
||
|
||
// Socket specifies an alternate path to the local Tailscale socket.
|
||
// If empty, a platform-specific default is used.
|
||
Socket string
|
||
|
||
// UseSocketOnly, if true, tries to only connect to tailscaled via the
|
||
// Unix socket and not via fallback mechanisms as done on macOS when
|
||
// connecting to the GUI client variants.
|
||
UseSocketOnly bool
|
||
|
||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||
// It's lazily initialized on first use.
|
||
tsClient *http.Client
|
||
tsClientOnce sync.Once
|
||
}
|
||
|
||
func (lc *LocalClient) socket() string {
|
||
if lc.Socket != "" {
|
||
return lc.Socket
|
||
}
|
||
return paths.DefaultTailscaledSocket()
|
||
}
|
||
|
||
func (lc *LocalClient) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||
if lc.Dial != nil {
|
||
return lc.Dial
|
||
}
|
||
return lc.defaultDialer
|
||
}
|
||
|
||
func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||
if addr != "local-tailscaled.sock:80" {
|
||
return nil, fmt.Errorf("unexpected URL address %q", addr)
|
||
}
|
||
if !lc.UseSocketOnly {
|
||
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
|
||
// a TCP server on a random port, find the random port. For HTTP connections,
|
||
// we don't send the token. It gets added in an HTTP Basic-Auth header.
|
||
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||
// We use 127.0.0.1 and not "localhost" (issue 7851).
|
||
var d net.Dialer
|
||
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||
}
|
||
}
|
||
s := safesocket.DefaultConnectionStrategy(lc.socket())
|
||
return safesocket.Connect(s)
|
||
}
|
||
|
||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||
//
|
||
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
|
||
//
|
||
// The hostname must be "local-tailscaled.sock", even though it
|
||
// doesn't actually do any DNS lookup. The actual means of connecting to and
|
||
// authenticating to the local Tailscale daemon vary by platform.
|
||
//
|
||
// DoLocalRequest may mutate the request to add Authorization headers.
|
||
func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||
req.Header.Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
|
||
lc.tsClientOnce.Do(func() {
|
||
lc.tsClient = &http.Client{
|
||
Transport: &http.Transport{
|
||
DialContext: lc.dialer(),
|
||
},
|
||
}
|
||
})
|
||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||
req.SetBasicAuth("", token)
|
||
}
|
||
return lc.tsClient.Do(req)
|
||
}
|
||
|
||
func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
|
||
res, err := lc.DoLocalRequest(req)
|
||
if err == nil {
|
||
if server := res.Header.Get("Tailscale-Version"); server != "" && server != envknob.IPCVersion() && onVersionMismatch != nil {
|
||
onVersionMismatch(envknob.IPCVersion(), server)
|
||
}
|
||
if res.StatusCode == 403 {
|
||
all, _ := io.ReadAll(res.Body)
|
||
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
|
||
}
|
||
return res, nil
|
||
}
|
||
if ue, ok := err.(*url.Error); ok {
|
||
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
|
||
path := req.URL.Path
|
||
pathPrefix, _, _ := strings.Cut(path, "?")
|
||
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
|
||
}
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
type errorJSON struct {
|
||
Error string
|
||
}
|
||
|
||
// AccessDeniedError is an error due to permissions.
|
||
type AccessDeniedError struct {
|
||
err error
|
||
}
|
||
|
||
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
|
||
func (e *AccessDeniedError) Unwrap() error { return e.err }
|
||
|
||
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
|
||
func IsAccessDeniedError(err error) bool {
|
||
var ae *AccessDeniedError
|
||
return errors.As(err, &ae)
|
||
}
|
||
|
||
// bestError returns either err, or if body contains a valid JSON
|
||
// object of type errorJSON, its non-empty error body.
|
||
func bestError(err error, body []byte) error {
|
||
var j errorJSON
|
||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||
return errors.New(j.Error)
|
||
}
|
||
return err
|
||
}
|
||
|
||
func errorMessageFromBody(body []byte) string {
|
||
var j errorJSON
|
||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||
return j.Error
|
||
}
|
||
return strings.TrimSpace(string(body))
|
||
}
|
||
|
||
var onVersionMismatch func(clientVer, serverVer string)
|
||
|
||
// SetVersionMismatchHandler sets f as the version mismatch handler
|
||
// to be called when the client (the current process) has a version
|
||
// number that doesn't match the server's declared version.
|
||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||
onVersionMismatch = f
|
||
}
|
||
|
||
func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
|
||
if jr, ok := body.(jsonReader); ok && jr.err != nil {
|
||
return nil, jr.err // fail early if there was a JSON marshaling error
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, "http://"+apitype.LocalAPIHost+path, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer res.Body.Close()
|
||
slurp, err := io.ReadAll(res.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != wantStatus {
|
||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||
return nil, bestError(err, slurp)
|
||
}
|
||
return slurp, nil
|
||
}
|
||
|
||
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
|
||
return lc.send(ctx, "GET", path, 200, nil)
|
||
}
|
||
|
||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||
//
|
||
// Deprecated: use LocalClient.WhoIs.
|
||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||
return defaultLocalClient.WhoIs(ctx, remoteAddr)
|
||
}
|
||
|
||
func decodeJSON[T any](b []byte) (ret T, err error) {
|
||
if err := json.Unmarshal(b, &ret); err != nil {
|
||
var zero T
|
||
return zero, fmt.Errorf("failed to unmarshal JSON into %T: %w", ret, err)
|
||
}
|
||
return ret, nil
|
||
}
|
||
|
||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||
}
|
||
|
||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||
func (lc *LocalClient) Goroutines(ctx context.Context) ([]byte, error) {
|
||
return lc.get200(ctx, "/localapi/v0/goroutines")
|
||
}
|
||
|
||
// DaemonMetrics returns the Tailscale daemon's metrics in
|
||
// the Prometheus text exposition format.
|
||
func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||
}
|
||
|
||
// IncrementCounter increments the value of a Tailscale daemon's counter
|
||
// metric by the given delta. If the metric has yet to exist, a new counter
|
||
// metric is created and initialized to delta.
|
||
//
|
||
// IncrementCounter does not support gauge metrics or negative delta values.
|
||
func (lc *LocalClient) IncrementCounter(ctx context.Context, name string, delta int) error {
|
||
type metricUpdate struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Value int `json:"value"` // amount to increment by
|
||
}
|
||
if delta < 0 {
|
||
return errors.New("negative delta not allowed")
|
||
}
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]metricUpdate{{
|
||
Name: name,
|
||
Type: "counter",
|
||
Value: delta,
|
||
}}))
|
||
return err
|
||
}
|
||
|
||
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
|
||
// Close the context to stop the stream.
|
||
func (lc *LocalClient) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
|
||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/logtap", nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != 200 {
|
||
return nil, errors.New(res.Status)
|
||
}
|
||
return res.Body, nil
|
||
}
|
||
|
||
// Pprof returns a pprof profile of the Tailscale daemon.
|
||
func (lc *LocalClient) Pprof(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||
var secArg string
|
||
if sec < 0 || sec > 300 {
|
||
return nil, errors.New("duration out of range")
|
||
}
|
||
if sec != 0 || pprofType == "profile" {
|
||
secArg = fmt.Sprint(sec)
|
||
}
|
||
return lc.get200(ctx, fmt.Sprintf("/localapi/v0/pprof?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||
}
|
||
|
||
// BugReportOpts contains options to pass to the Tailscale daemon when
|
||
// generating a bug report.
|
||
type BugReportOpts struct {
|
||
// Note contains an optional user-provided note to add to the logs.
|
||
Note string
|
||
|
||
// Diagnose specifies whether to print additional diagnostic information to
|
||
// the logs when generating this bugreport.
|
||
Diagnose bool
|
||
|
||
// Record specifies, if non-nil, whether to perform a bugreport
|
||
// "recording"–generating an initial log marker, then waiting for
|
||
// this channel to be closed before finishing the request, which
|
||
// generates another log marker.
|
||
Record <-chan struct{}
|
||
}
|
||
|
||
// BugReportWithOpts logs and returns a log marker that can be shared by the
|
||
// user with support.
|
||
//
|
||
// The opts type specifies options to pass to the Tailscale daemon when
|
||
// generating this bug report.
|
||
func (lc *LocalClient) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
|
||
qparams := make(url.Values)
|
||
if opts.Note != "" {
|
||
qparams.Set("note", opts.Note)
|
||
}
|
||
if opts.Diagnose {
|
||
qparams.Set("diagnose", "true")
|
||
}
|
||
if opts.Record != nil {
|
||
qparams.Set("record", "true")
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
defer cancel()
|
||
|
||
var requestBody io.Reader
|
||
if opts.Record != nil {
|
||
pr, pw := io.Pipe()
|
||
requestBody = pr
|
||
|
||
// This goroutine waits for the 'Record' channel to be closed,
|
||
// and then closes the write end of our pipe to unblock the
|
||
// reader.
|
||
go func() {
|
||
defer pw.Close()
|
||
select {
|
||
case <-opts.Record:
|
||
case <-ctx.Done():
|
||
}
|
||
}()
|
||
}
|
||
|
||
// lc.send might block if opts.Record != nil; see above.
|
||
uri := fmt.Sprintf("/localapi/v0/bugreport?%s", qparams.Encode())
|
||
body, err := lc.send(ctx, "POST", uri, 200, requestBody)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.TrimSpace(string(body)), nil
|
||
}
|
||
|
||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||
//
|
||
// This is the same as calling BugReportWithOpts and only specifying the Note
|
||
// field.
|
||
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
|
||
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
|
||
}
|
||
|
||
// DebugAction invokes a debug action, such as "rebind" or "restun".
|
||
// These are development tools and subject to change or removal over time.
|
||
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DebugPortmapOpts contains options for the DebugPortmap command.
|
||
type DebugPortmapOpts struct {
|
||
// Duration is how long the mapping should be created for. It defaults
|
||
// to 5 seconds if not set.
|
||
Duration time.Duration
|
||
|
||
// Type is the kind of portmap to debug. The empty string instructs the
|
||
// portmap client to perform all known types. Other valid options are
|
||
// "pmp", "pcp", and "upnp".
|
||
Type string
|
||
|
||
// GatewayAddr specifies the gateway address used during portmapping.
|
||
// If set, SelfAddr must also be set. If unset, it will be
|
||
// autodetected.
|
||
GatewayAddr netip.Addr
|
||
|
||
// SelfAddr specifies the gateway address used during portmapping. If
|
||
// set, GatewayAddr must also be set. If unset, it will be
|
||
// autodetected.
|
||
SelfAddr netip.Addr
|
||
|
||
// LogHTTP instructs the debug-portmap endpoint to print all HTTP
|
||
// requests and responses made to the logs.
|
||
LogHTTP bool
|
||
}
|
||
|
||
// DebugPortmap invokes the debug-portmap endpoint, and returns an
|
||
// io.ReadCloser that can be used to read the logs that are printed during this
|
||
// process.
|
||
//
|
||
// opts can be nil; if so, default values will be used.
|
||
func (lc *LocalClient) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
|
||
vals := make(url.Values)
|
||
if opts == nil {
|
||
opts = &DebugPortmapOpts{}
|
||
}
|
||
|
||
vals.Set("duration", cmpx.Or(opts.Duration, 5*time.Second).String())
|
||
vals.Set("type", opts.Type)
|
||
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
|
||
|
||
if opts.GatewayAddr.IsValid() != opts.SelfAddr.IsValid() {
|
||
return nil, fmt.Errorf("both GatewayAddr and SelfAddr must be provided if one is")
|
||
} else if opts.GatewayAddr.IsValid() {
|
||
vals.Set("gateway_and_self", fmt.Sprintf("%s/%s", opts.GatewayAddr, opts.SelfAddr))
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-portmap?"+vals.Encode(), nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != 200 {
|
||
body, _ := io.ReadAll(res.Body)
|
||
res.Body.Close()
|
||
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||
}
|
||
|
||
return res.Body, nil
|
||
}
|
||
|
||
// SetDevStoreKeyValue set a statestore key/value. It's only meant for development.
|
||
// The schema (including when keys are re-read) is not a stable interface.
|
||
func (lc *LocalClient) SetDevStoreKeyValue(ctx context.Context, key, value string) error {
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/dev-set-state-store?"+(url.Values{
|
||
"key": {key},
|
||
"value": {value},
|
||
}).Encode(), 200, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetComponentDebugLogging sets component's debug logging enabled for
|
||
// the provided duration. If the duration is in the past, the debug logging
|
||
// is disabled.
|
||
func (lc *LocalClient) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
|
||
body, err := lc.send(ctx, "POST",
|
||
fmt.Sprintf("/localapi/v0/component-debug-logging?component=%s&secs=%d",
|
||
url.QueryEscape(component), int64(d.Seconds())), 200, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
var res struct {
|
||
Error string
|
||
}
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
return err
|
||
}
|
||
if res.Error != "" {
|
||
return errors.New(res.Error)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Status returns the Tailscale daemon's status.
|
||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||
return defaultLocalClient.Status(ctx)
|
||
}
|
||
|
||
// Status returns the Tailscale daemon's status.
|
||
func (lc *LocalClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||
return lc.status(ctx, "")
|
||
}
|
||
|
||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||
return defaultLocalClient.StatusWithoutPeers(ctx)
|
||
}
|
||
|
||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||
func (lc *LocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||
return lc.status(ctx, "?peers=false")
|
||
}
|
||
|
||
func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
||
body, err := lc.get200(ctx, "/localapi/v0/status"+queryString)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decodeJSON[*ipnstate.Status](body)
|
||
}
|
||
|
||
// IDToken is a request to get an OIDC ID token for an audience.
|
||
// The token can be presented to any resource provider which offers OIDC
|
||
// Federation.
|
||
func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
|
||
body, err := lc.get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decodeJSON[*tailcfg.TokenResponse](body)
|
||
}
|
||
|
||
// WaitingFiles returns the list of received Taildrop files that have been
|
||
// received by the Tailscale daemon in its staging/cache directory but not yet
|
||
// transferred by the user's CLI or GUI client and written to a user's home
|
||
// directory somewhere.
|
||
func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||
return lc.AwaitWaitingFiles(ctx, 0)
|
||
}
|
||
|
||
// AwaitWaitingFiles is like WaitingFiles but takes a duration to await for an answer.
|
||
// If the duration is 0, it will return immediately. The duration is respected at second
|
||
// granularity only. If no files are available, it returns (nil, nil).
|
||
func (lc *LocalClient) AwaitWaitingFiles(ctx context.Context, d time.Duration) ([]apitype.WaitingFile, error) {
|
||
path := "/localapi/v0/files/?waitsec=" + fmt.Sprint(int(d.Seconds()))
|
||
body, err := lc.get200(ctx, path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decodeJSON[[]apitype.WaitingFile](body)
|
||
}
|
||
|
||
func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||
_, err := lc.send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
|
||
return err
|
||
}
|
||
|
||
func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
|
||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if res.ContentLength == -1 {
|
||
res.Body.Close()
|
||
return nil, 0, fmt.Errorf("unexpected chunking")
|
||
}
|
||
if res.StatusCode != 200 {
|
||
body, _ := io.ReadAll(res.Body)
|
||
res.Body.Close()
|
||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||
}
|
||
return res.Body, res.ContentLength, nil
|
||
}
|
||
|
||
func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
|
||
body, err := lc.get200(ctx, "/localapi/v0/file-targets")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decodeJSON[[]apitype.FileTarget](body)
|
||
}
|
||
|
||
// PushFile sends Taildrop file r to target.
|
||
//
|
||
// A size of -1 means unknown.
|
||
// The name parameter is the original filename, not escaped.
|
||
func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
|
||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://"+apitype.LocalAPIHost+"/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if size != -1 {
|
||
req.ContentLength = size
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if res.StatusCode == 200 {
|
||
io.Copy(io.Discard, res.Body)
|
||
return nil
|
||
}
|
||
all, _ := io.ReadAll(res.Body)
|
||
return bestError(fmt.Errorf("%s: %s", res.Status, all), all)
|
||
}
|
||
|
||
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
|
||
// machine is properly configured to forward IP packets as a subnet router
|
||
// or exit node.
|
||
func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
|
||
body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var jres struct {
|
||
Warning string
|
||
}
|
||
if err := json.Unmarshal(body, &jres); err != nil {
|
||
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
|
||
}
|
||
if jres.Warning != "" {
|
||
return errors.New(jres.Warning)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckPrefs validates the provided preferences, without making any changes.
|
||
//
|
||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
|
||
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
|
||
// EditPrefs is not necessary.
|
||
func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, jsonBody(p))
|
||
return err
|
||
}
|
||
|
||
func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||
body, err := lc.get200(ctx, "/localapi/v0/prefs")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var p ipn.Prefs
|
||
if err := json.Unmarshal(body, &p); err != nil {
|
||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, jsonBody(mp))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return decodeJSON[*ipn.Prefs](body)
|
||
}
|
||
|
||
// StartLoginInteractive starts an interactive login.
|
||
func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error {
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
|
||
return err
|
||
}
|
||
|
||
// Start applies the configuration specified in opts, and starts the
|
||
// state machine.
|
||
func (lc *LocalClient) Start(ctx context.Context, opts ipn.Options) error {
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/start", http.StatusNoContent, jsonBody(opts))
|
||
return err
|
||
}
|
||
|
||
// Logout logs out the current node.
|
||
func (lc *LocalClient) Logout(ctx context.Context) error {
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
|
||
return err
|
||
}
|
||
|
||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||
// the provided TXT value. The intended use case is answering
|
||
// LetsEncrypt/ACME dns-01 challenges.
|
||
//
|
||
// The control plane will only permit SetDNS requests with very
|
||
// specific names and values. The name should be
|
||
// "_acme-challenge." + your node's MagicDNS name. It's expected that
|
||
// clients cache the certs from LetsEncrypt (or whichever CA is
|
||
// providing them) and only request new ones as needed; the control plane
|
||
// rate limits SetDNS requests.
|
||
//
|
||
// This is a low-level interface; it's expected that most Tailscale
|
||
// users use a higher level interface to getting/using TLS
|
||
// certificates.
|
||
func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
|
||
v := url.Values{}
|
||
v.Set("name", name)
|
||
v.Set("value", value)
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
|
||
return err
|
||
}
|
||
|
||
// DialTCP connects to the host's port via Tailscale.
|
||
//
|
||
// The host may be a base DNS name (resolved from the netmap inside
|
||
// tailscaled), a FQDN, or an IP address.
|
||
//
|
||
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
|
||
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||
connCh := make(chan net.Conn, 1)
|
||
trace := httptrace.ClientTrace{
|
||
GotConn: func(info httptrace.GotConnInfo) {
|
||
connCh <- info.Conn
|
||
},
|
||
}
|
||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/dial", nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header = http.Header{
|
||
"Upgrade": []string{"ts-dial"},
|
||
"Connection": []string{"upgrade"},
|
||
"Dial-Host": []string{host},
|
||
"Dial-Port": []string{fmt.Sprint(port)},
|
||
}
|
||
res, err := lc.DoLocalRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||
body, _ := io.ReadAll(res.Body)
|
||
res.Body.Close()
|
||
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
|
||
}
|
||
// From here on, the underlying net.Conn is ours to use, but there
|
||
// is still a read buffer attached to it within resp.Body. So, we
|
||
// must direct I/O through resp.Body, but we can still use the
|
||
// underlying net.Conn for stuff like deadlines.
|
||
var switchedConn net.Conn
|
||
select {
|
||
case switchedConn = <-connCh:
|
||
default:
|
||
}
|
||
if switchedConn == nil {
|
||
res.Body.Close()
|
||
return nil, fmt.Errorf("httptrace didn't provide a connection")
|
||
}
|
||
rwc, ok := res.Body.(io.ReadWriteCloser)
|
||
if !ok {
|
||
res.Body.Close()
|
||
return nil, errors.New("http Transport did not provide a writable body")
|
||
}
|
||
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
|
||
}
|
||
|
||
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
|
||
// It is intended to be used with netcheck to see availability of DERPs.
|
||
func (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||
var derpMap tailcfg.DERPMap
|
||
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err = json.Unmarshal(res, &derpMap); err != nil {
|
||
return nil, fmt.Errorf("invalid derp map json: %w", err)
|
||
}
|
||
return &derpMap, nil
|
||
}
|
||
|
||
// CertPair returns a cert and private key for the provided DNS domain.
|
||
//
|
||
// It returns a cached certificate from disk if it's still valid.
|
||
//
|
||
// Deprecated: use LocalClient.CertPair.
|
||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||
return defaultLocalClient.CertPair(ctx, domain)
|
||
}
|
||
|
||
// CertPair returns a cert and private key for the provided DNS domain.
|
||
//
|
||
// It returns a cached certificate from disk if it's still valid.
|
||
//
|
||
// API maturity: this is considered a stable API.
|
||
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
// with ?type=pair, the response PEM is first the one private
|
||
// key PEM block, then the cert PEM blocks.
|
||
i := mem.Index(mem.B(res), mem.S("--\n--"))
|
||
if i == -1 {
|
||
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
|
||
}
|
||
i += len("--\n")
|
||
keyPEM, certPEM = res[:i], res[i:]
|
||
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
|
||
return nil, nil, fmt.Errorf("unexpected output: key in cert")
|
||
}
|
||
return certPEM, keyPEM, nil
|
||
}
|
||
|
||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||
//
|
||
// It returns a cached certificate from disk if it's still valid.
|
||
//
|
||
// It's the right signature to use as the value of
|
||
// tls.Config.GetCertificate.
|
||
//
|
||
// Deprecated: use LocalClient.GetCertificate.
|
||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||
return defaultLocalClient.GetCertificate(hi)
|
||
}
|
||
|
||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||
//
|
||
// It returns a cached certificate from disk if it's still valid.
|
||
//
|
||
// It's the right signature to use as the value of
|
||
// tls.Config.GetCertificate.
|
||
//
|
||
// API maturity: this is considered a stable API.
|
||
func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||
if hi == nil || hi.ServerName == "" {
|
||
return nil, errors.New("no SNI ServerName")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||
defer cancel()
|
||
|
||
name := hi.ServerName
|
||
if !strings.Contains(name, ".") {
|
||
if v, ok := lc.ExpandSNIName(ctx, name); ok {
|
||
name = v
|
||
}
|
||
}
|
||
certPEM, keyPEM, err := lc.CertPair(ctx, name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &cert, nil
|
||
}
|
||
|
||
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
|
||
//
|
||
// Deprecated: use LocalClient.ExpandSNIName.
|
||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||
return defaultLocalClient.ExpandSNIName(ctx, name)
|
||
}
|
||
|
||
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
|
||
func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||
st, err := lc.StatusWithoutPeers(ctx)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
for _, d := range st.CertDomains {
|
||
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
|
||
return d, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// PingOpts contains options for the ping request.
|
||
//
|
||
// The zero value is valid, which means to use defaults.
|
||
type PingOpts struct {
|
||
// Size is the length of the ping message in bytes. It's ignored if it's
|
||
// smaller than the minimum message size.
|
||
//
|
||
// For disco pings, it specifies the length of the packet's payload. That
|
||
// is, it includes the disco headers and message, but not the IP and UDP
|
||
// headers.
|
||
Size int
|
||
}
|
||
|
||
// Ping sends a ping of the provided type to the provided IP and waits
|
||
// for its response. The opts type specifies additional options.
|
||
func (lc *LocalClient) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType, opts PingOpts) (*ipnstate.PingResult, error) {
|
||
v := url.Values{}
|
||
v.Set("ip", ip.String())
|
||
v.Set("size", strconv.Itoa(opts.Size))
|
||
v.Set("type", string(pingtype))
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
return decodeJSON[*ipnstate.PingResult](body)
|
||
}
|
||
|
||
// Ping sends a ping of the provided type to the provided IP and waits
|
||
// for its response.
|
||
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
|
||
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
|
||
}
|
||
|
||
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
|
||
func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
|
||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error: %w", err)
|
||
}
|
||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||
}
|
||
|
||
// NetworkLockInit initializes the tailnet key authority.
|
||
//
|
||
// TODO(tom): Plumb through disablement secrets.
|
||
func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
|
||
var b bytes.Buffer
|
||
type initRequest struct {
|
||
Keys []tka.Key
|
||
DisablementValues [][]byte
|
||
SupportDisablement []byte
|
||
}
|
||
|
||
if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues, SupportDisablement: supportDisablement}); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/init", 200, &b)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error: %w", err)
|
||
}
|
||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||
}
|
||
|
||
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
|
||
// enable unattended bringup in the locked tailnet.
|
||
func (lc *LocalClient) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||
encodedPrivate, err := tkaKey.MarshalText()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var b bytes.Buffer
|
||
type wrapRequest struct {
|
||
TSKey string
|
||
TKAKey string // key.NLPrivate.MarshalText
|
||
}
|
||
if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b)
|
||
if err != nil {
|
||
return "", fmt.Errorf("error: %w", err)
|
||
}
|
||
return string(body), nil
|
||
}
|
||
|
||
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
|
||
func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
|
||
var b bytes.Buffer
|
||
type modifyRequest struct {
|
||
AddKeys []tka.Key
|
||
RemoveKeys []tka.Key
|
||
}
|
||
|
||
if err := json.NewEncoder(&b).Encode(modifyRequest{AddKeys: addKeys, RemoveKeys: removeKeys}); err != nil {
|
||
return err
|
||
}
|
||
|
||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/modify", 204, &b); err != nil {
|
||
return fmt.Errorf("error: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
|
||
// rotationPublic, if specified, must be an ed25519 public key.
|
||
func (lc *LocalClient) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
|
||
var b bytes.Buffer
|
||
type signRequest struct {
|
||
NodeKey key.NodePublic
|
||
RotationPublic []byte
|
||
}
|
||
|
||
if err := json.NewEncoder(&b).Encode(signRequest{NodeKey: nodeKey, RotationPublic: rotationPublic}); err != nil {
|
||
return err
|
||
}
|
||
|
||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/sign", 200, &b); err != nil {
|
||
return fmt.Errorf("error: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
|
||
func (lc *LocalClient) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error: %w", err)
|
||
}
|
||
return decodeJSON[[]tkatype.MarshaledSignature](body)
|
||
}
|
||
|
||
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
|
||
func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
||
v := url.Values{}
|
||
v.Set("limit", fmt.Sprint(maxEntries))
|
||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
||
}
|
||
|
||
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
|
||
func (lc *LocalClient) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||
// This endpoint expects an empty JSON stanza as the payload.
|
||
var b bytes.Buffer
|
||
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
|
||
return err
|
||
}
|
||
|
||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil {
|
||
return fmt.Errorf("error: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
|
||
// in url and returns information extracted from it.
|
||
func (lc *LocalClient) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||
vr := struct {
|
||
URL string
|
||
}{url}
|
||
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("sending verify-deeplink: %w", err)
|
||
}
|
||
|
||
return decodeJSON[*tka.DeeplinkValidationResult](body)
|
||
}
|
||
|
||
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
|
||
func (lc *LocalClient) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
|
||
vr := struct {
|
||
Keys []tkatype.KeyID
|
||
ForkFrom string
|
||
}{removeKeys, forkFrom.String()}
|
||
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
|
||
}
|
||
|
||
return body, nil
|
||
}
|
||
|
||
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
|
||
func (lc *LocalClient) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
|
||
r := bytes.NewReader(aum.Serialize())
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
|
||
}
|
||
|
||
return body, nil
|
||
}
|
||
|
||
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
|
||
func (lc *LocalClient) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
|
||
r := bytes.NewReader(aum.Serialize())
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
|
||
if err != nil {
|
||
return fmt.Errorf("sending cosign-recovery-aum: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetServeConfig sets or replaces the serving settings.
|
||
// If config is nil, settings are cleared and serving is disabled.
|
||
func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/serve-config", 200, jsonBody(config))
|
||
if err != nil {
|
||
return fmt.Errorf("sending serve config: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||
func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||
return fmt.Errorf("error: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// StreamServe returns an io.ReadCloser that streams serve/Funnel
|
||
// connections made to the provided HostPort.
|
||
//
|
||
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
|
||
// the backend enables it for the duration of the context's lifespan and
|
||
// then turns it back off once the context is closed. If either are already enabled,
|
||
// then they remain that way but logs are still streamed
|
||
func (lc *LocalClient) StreamServe(ctx context.Context, hp ipn.ServeStreamRequest) (io.ReadCloser, error) {
|
||
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/stream-serve", jsonBody(hp))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != 200 {
|
||
res.Body.Close()
|
||
return nil, errors.New(res.Status)
|
||
}
|
||
return res.Body, nil
|
||
}
|
||
|
||
// GetServeConfig return the current serve config.
|
||
//
|
||
// If the serve config is empty, it returns (nil, nil).
|
||
func (lc *LocalClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
|
||
body, err := lc.send(ctx, "GET", "/localapi/v0/serve-config", 200, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("getting serve config: %w", err)
|
||
}
|
||
return getServeConfigFromJSON(body)
|
||
}
|
||
|
||
func getServeConfigFromJSON(body []byte) (sc *ipn.ServeConfig, err error) {
|
||
if err := json.Unmarshal(body, &sc); err != nil {
|
||
return nil, err
|
||
}
|
||
return sc, nil
|
||
}
|
||
|
||
// tailscaledConnectHint gives a little thing about why tailscaled (or
|
||
// platform equivalent) is not answering localapi connections.
|
||
//
|
||
// It ends in a punctuation. See caller.
|
||
func tailscaledConnectHint() string {
|
||
if runtime.GOOS != "linux" {
|
||
// TODO(bradfitz): flesh this out
|
||
return "not running?"
|
||
}
|
||
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
|
||
if err != nil {
|
||
return "not running?"
|
||
}
|
||
// Parse:
|
||
// LoadState=loaded
|
||
// ActiveState=inactive
|
||
// SubState=dead
|
||
st := map[string]string{}
|
||
for _, line := range strings.Split(string(out), "\n") {
|
||
if k, v, ok := strings.Cut(line, "="); ok {
|
||
st[k] = strings.TrimSpace(v)
|
||
}
|
||
}
|
||
if st["LoadState"] == "loaded" &&
|
||
(st["SubState"] != "running" || st["ActiveState"] != "active") {
|
||
return "systemd tailscaled.service not running."
|
||
}
|
||
return "not running?"
|
||
}
|
||
|
||
type jsonReader struct {
|
||
b *bytes.Reader
|
||
err error // sticky JSON marshal error, if any
|
||
}
|
||
|
||
// jsonBody returns an io.Reader that marshals v as JSON and then reads it.
|
||
func jsonBody(v any) jsonReader {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
return jsonReader{err: err}
|
||
}
|
||
return jsonReader{b: bytes.NewReader(b)}
|
||
}
|
||
|
||
func (r jsonReader) Read(p []byte) (n int, err error) {
|
||
if r.err != nil {
|
||
return 0, r.err
|
||
}
|
||
return r.b.Read(p)
|
||
}
|
||
|
||
// ProfileStatus returns the current profile and the list of all profiles.
|
||
func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProfile, all []ipn.LoginProfile, err error) {
|
||
body, err := lc.send(ctx, "GET", "/localapi/v0/profiles/current", 200, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
current, err = decodeJSON[ipn.LoginProfile](body)
|
||
if err != nil {
|
||
return
|
||
}
|
||
body, err = lc.send(ctx, "GET", "/localapi/v0/profiles/", 200, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
all, err = decodeJSON[[]ipn.LoginProfile](body)
|
||
return current, all, err
|
||
}
|
||
|
||
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
|
||
// profile is not assigned an ID until it is persisted after a successful login.
|
||
// In order to login to the new profile, the user must call LoginInteractive.
|
||
func (lc *LocalClient) SwitchToEmptyProfile(ctx context.Context) error {
|
||
_, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil)
|
||
return err
|
||
}
|
||
|
||
// SwitchProfile switches to the given profile.
|
||
func (lc *LocalClient) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil)
|
||
return err
|
||
}
|
||
|
||
// DeleteProfile removes the profile with the given ID.
|
||
// If the profile is the current profile, an empty profile
|
||
// will be selected as if SwitchToEmptyProfile was called.
|
||
func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID) error {
|
||
_, err := lc.send(ctx, "DELETE", "/localapi/v0/profiles"+url.PathEscape(string(profile)), http.StatusNoContent, nil)
|
||
return err
|
||
}
|
||
|
||
// QueryFeature makes a request for instructions on how to enable
|
||
// a feature, such as Funnel, for the node's tailnet. If relevant,
|
||
// this includes a control server URL the user can visit to enable
|
||
// the feature.
|
||
//
|
||
// If you are looking to use QueryFeature, you'll likely want to
|
||
// use cli.enableFeatureInteractive instead, which handles the logic
|
||
// of wraping QueryFeature and translating its response into an
|
||
// interactive flow for the user, including using the IPN notify bus
|
||
// to block until the feature has been enabled.
|
||
//
|
||
// 2023-08-09: Valid feature values are "serve" and "funnel".
|
||
func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
|
||
v := url.Values{"feature": {feature}}
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
|
||
}
|
||
|
||
func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
|
||
v := url.Values{"region": {regionIDOrCode}}
|
||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||
}
|
||
return decodeJSON[*ipnstate.DebugDERPRegionReport](body)
|
||
}
|
||
|
||
// DebugSetExpireIn marks the current node key to expire in d.
|
||
//
|
||
// This is meant primarily for debug and testing.
|
||
func (lc *LocalClient) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
|
||
v := url.Values{"expiry": {fmt.Sprint(time.Now().Add(d).Unix())}}
|
||
_, err := lc.send(ctx, "POST", "/localapi/v0/set-expiry-sooner?"+v.Encode(), 200, nil)
|
||
return err
|
||
}
|
||
|
||
// StreamDebugCapture streams a pcap-formatted packet capture.
|
||
//
|
||
// The provided context does not determine the lifetime of the
|
||
// returned io.ReadCloser.
|
||
func (lc *LocalClient) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
|
||
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-capture", nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != 200 {
|
||
res.Body.Close()
|
||
return nil, errors.New(res.Status)
|
||
}
|
||
return res.Body, nil
|
||
}
|
||
|
||
// WatchIPNBus subscribes to the IPN notification bus. It returns a watcher
|
||
// once the bus is connected successfully.
|
||
//
|
||
// The context is used for the life of the watch, not just the call to
|
||
// WatchIPNBus.
|
||
//
|
||
// The returned IPNBusWatcher's Close method must be called when done to release
|
||
// resources.
|
||
//
|
||
// A default set of ipn.Notify messages are returned but the set can be modified by mask.
|
||
func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) {
|
||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask),
|
||
nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := lc.doLocalRequestNiceError(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.StatusCode != 200 {
|
||
res.Body.Close()
|
||
return nil, errors.New(res.Status)
|
||
}
|
||
dec := json.NewDecoder(res.Body)
|
||
return &IPNBusWatcher{
|
||
ctx: ctx,
|
||
httpRes: res,
|
||
dec: dec,
|
||
}, nil
|
||
}
|
||
|
||
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
|
||
// It's returned by LocalClient.WatchIPNBus.
|
||
//
|
||
// It must be closed when done.
|
||
type IPNBusWatcher struct {
|
||
ctx context.Context // from original WatchIPNBus call
|
||
httpRes *http.Response
|
||
dec *json.Decoder
|
||
|
||
mu sync.Mutex
|
||
closed bool
|
||
}
|
||
|
||
// Close stops the watcher and releases its resources.
|
||
func (w *IPNBusWatcher) Close() error {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
if w.closed {
|
||
return nil
|
||
}
|
||
w.closed = true
|
||
return w.httpRes.Body.Close()
|
||
}
|
||
|
||
// Next returns the next ipn.Notify from the stream.
|
||
// If the context from LocalClient.WatchIPNBus is done, that error is returned.
|
||
func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
|
||
var n ipn.Notify
|
||
if err := w.dec.Decode(&n); err != nil {
|
||
if cerr := w.ctx.Err(); cerr != nil {
|
||
err = cerr
|
||
}
|
||
return ipn.Notify{}, err
|
||
}
|
||
return n, nil
|
||
}
|