mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-30 07:42:12 +01:00 
			
		
		
		
	Fixes tailscale/corp#33125 Change-Id: I9911f5059d5ebe42ecf7db9becb2326cca240765 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
		
			
				
	
	
		
			313 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			313 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (c) Tailscale Inc & AUTHORS
 | |
| // SPDX-License-Identifier: BSD-3-Clause
 | |
| 
 | |
| package ts2021
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"cmp"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"log"
 | |
| 	"math"
 | |
| 	"net"
 | |
| 	"net/http"
 | |
| 	"net/netip"
 | |
| 	"net/url"
 | |
| 	"sync"
 | |
| 	"time"
 | |
| 
 | |
| 	"tailscale.com/control/controlhttp"
 | |
| 	"tailscale.com/health"
 | |
| 	"tailscale.com/net/dnscache"
 | |
| 	"tailscale.com/net/netmon"
 | |
| 	"tailscale.com/net/tsdial"
 | |
| 	"tailscale.com/tailcfg"
 | |
| 	"tailscale.com/tstime"
 | |
| 	"tailscale.com/types/key"
 | |
| 	"tailscale.com/types/logger"
 | |
| 	"tailscale.com/util/mak"
 | |
| 	"tailscale.com/util/set"
 | |
| )
 | |
| 
 | |
| // Client provides a http.Client to connect to tailcontrol over
 | |
| // the ts2021 protocol.
 | |
| type Client struct {
 | |
| 	// Client is an HTTP client to talk to the coordination server.
 | |
| 	// It automatically makes a new Noise connection as needed.
 | |
| 	*http.Client
 | |
| 
 | |
| 	logf      logger.Logf // non-nil
 | |
| 	opts      ClientOpts
 | |
| 	host      string // the host part of serverURL
 | |
| 	httpPort  string // the default port to dial
 | |
| 	httpsPort string // the fallback Noise-over-https port or empty if none
 | |
| 
 | |
| 	// mu protects the following
 | |
| 	mu       sync.Mutex
 | |
| 	closed   bool
 | |
| 	connPool set.HandleSet[*Conn] // all live connections
 | |
| }
 | |
| 
 | |
| // ClientOpts contains options for the [NewClient] function. All fields are
 | |
| // required unless otherwise specified.
 | |
| type ClientOpts struct {
 | |
| 	// ServerURL is the URL of the server to connect to.
 | |
| 	ServerURL string
 | |
| 
 | |
| 	// PrivKey is this node's private key.
 | |
| 	PrivKey key.MachinePrivate
 | |
| 
 | |
| 	// ServerPubKey is the public key of the server.
 | |
| 	// It is of the form https://<host>:<port> (no trailing slash).
 | |
| 	ServerPubKey key.MachinePublic
 | |
| 
 | |
| 	// Dialer's SystemDial function is used to connect to the server.
 | |
| 	Dialer *tsdial.Dialer
 | |
| 
 | |
| 	// Optional fields follow
 | |
| 
 | |
| 	// Logf is the log function to use.
 | |
| 	// If nil, log.Printf is used.
 | |
| 	Logf logger.Logf
 | |
| 
 | |
| 	// NetMon is the network monitor that will be used to get the
 | |
| 	// network interface state. This field can be nil; if so, the current
 | |
| 	// state will be looked up dynamically.
 | |
| 	NetMon *netmon.Monitor
 | |
| 
 | |
| 	// DNSCache is the caching Resolver to use to connect to the server.
 | |
| 	//
 | |
| 	// This field can be nil.
 | |
| 	DNSCache *dnscache.Resolver
 | |
| 
 | |
| 	// HealthTracker, if non-nil, is the health tracker to use.
 | |
| 	HealthTracker *health.Tracker
 | |
| 
 | |
| 	// DialPlan, if set, is a function that should return an explicit plan
 | |
| 	// on how to connect to the server.
 | |
| 	DialPlan func() *tailcfg.ControlDialPlan
 | |
| 
 | |
| 	// ProtocolVersion, if non-zero, specifies an alternate
 | |
| 	// protocol version to use instead of the default,
 | |
| 	// of [tailcfg.CurrentCapabilityVersion].
 | |
| 	ProtocolVersion uint16
 | |
| }
 | |
| 
 | |
| // NewClient returns a new noiseClient for the provided server and machine key.
 | |
| //
 | |
| // netMon may be nil, if non-nil it's used to do faster interface lookups.
 | |
| // dialPlan may be nil
 | |
| func NewClient(opts ClientOpts) (*Client, error) {
 | |
| 	logf := opts.Logf
 | |
| 	if logf == nil {
 | |
| 		logf = log.Printf
 | |
| 	}
 | |
| 	if opts.ServerURL == "" {
 | |
| 		return nil, errors.New("ServerURL is required")
 | |
| 	}
 | |
| 	if opts.PrivKey.IsZero() {
 | |
| 		return nil, errors.New("PrivKey is required")
 | |
| 	}
 | |
| 	if opts.ServerPubKey.IsZero() {
 | |
| 		return nil, errors.New("ServerPubKey is required")
 | |
| 	}
 | |
| 	if opts.Dialer == nil {
 | |
| 		return nil, errors.New("Dialer is required")
 | |
| 	}
 | |
| 
 | |
| 	u, err := url.Parse(opts.ServerURL)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("invalid ClientOpts.ServerURL: %w", err)
 | |
| 	}
 | |
| 	if u.Scheme != "http" && u.Scheme != "https" {
 | |
| 		return nil, errors.New("invalid ServerURL scheme, must be http or https")
 | |
| 	}
 | |
| 
 | |
| 	httpPort, httpsPort := "80", "443"
 | |
| 	addr, _ := netip.ParseAddr(u.Hostname())
 | |
| 	isPrivateHost := addr.IsPrivate() || addr.IsLoopback() || u.Hostname() == "localhost"
 | |
| 	if port := u.Port(); port != "" {
 | |
| 		// If there is an explicit port specified, entirely rely on the scheme,
 | |
| 		// unless it's http with a private host in which case we never try using HTTPS.
 | |
| 		if u.Scheme == "https" {
 | |
| 			httpPort = ""
 | |
| 			httpsPort = port
 | |
| 		} else if u.Scheme == "http" {
 | |
| 			httpPort = port
 | |
| 			httpsPort = "443"
 | |
| 			if isPrivateHost {
 | |
| 				logf("setting empty HTTPS port with http scheme and private host %s", u.Hostname())
 | |
| 				httpsPort = ""
 | |
| 			}
 | |
| 		}
 | |
| 	} else if u.Scheme == "http" && isPrivateHost {
 | |
| 		// Whenever the scheme is http and the hostname is an IP address, do not set the HTTPS port,
 | |
| 		// as there cannot be a TLS certificate issued for an IP, unless it's a public IP.
 | |
| 		httpPort = "80"
 | |
| 		httpsPort = ""
 | |
| 	}
 | |
| 
 | |
| 	np := &Client{
 | |
| 		opts:      opts,
 | |
| 		host:      u.Hostname(),
 | |
| 		httpPort:  httpPort,
 | |
| 		httpsPort: httpsPort,
 | |
| 		logf:      logf,
 | |
| 	}
 | |
| 
 | |
| 	tr := &http.Transport{
 | |
| 		Protocols:       new(http.Protocols),
 | |
| 		MaxConnsPerHost: 1,
 | |
| 	}
 | |
| 	// We force only HTTP/2 for this transport, which is what the control server
 | |
| 	// speaks inside the ts2021 Noise encryption. But Go doesn't know about that,
 | |
| 	// so we use "SetUnencryptedHTTP2" even though it's actually encrypted.
 | |
| 	tr.Protocols.SetUnencryptedHTTP2(true)
 | |
| 	tr.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
 | |
| 		return np.dial(ctx)
 | |
| 	}
 | |
| 
 | |
| 	np.Client = &http.Client{Transport: tr}
 | |
| 	return np, nil
 | |
| }
 | |
| 
 | |
| // Close closes all the underlying noise connections.
 | |
| // It is a no-op and returns nil if the connection is already closed.
 | |
| func (nc *Client) Close() error {
 | |
| 	nc.mu.Lock()
 | |
| 	live := nc.connPool
 | |
| 	nc.closed = true
 | |
| 	nc.connPool = nil // stop noteConnClosed from mutating it as we loop over it (in live) below
 | |
| 	nc.mu.Unlock()
 | |
| 
 | |
| 	for _, c := range live {
 | |
| 		c.Close()
 | |
| 	}
 | |
| 	nc.Client.CloseIdleConnections()
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // dial opens a new connection to tailcontrol, fetching the server noise key
 | |
| // if not cached.
 | |
| func (nc *Client) dial(ctx context.Context) (*Conn, error) {
 | |
| 	if tailcfg.CurrentCapabilityVersion > math.MaxUint16 {
 | |
| 		// Panic, because a test should have started failing several
 | |
| 		// thousand version numbers before getting to this point.
 | |
| 		panic("capability version is too high to fit in the wire protocol")
 | |
| 	}
 | |
| 
 | |
| 	var dialPlan *tailcfg.ControlDialPlan
 | |
| 	if nc.opts.DialPlan != nil {
 | |
| 		dialPlan = nc.opts.DialPlan()
 | |
| 	}
 | |
| 
 | |
| 	// If we have a dial plan, then set our timeout as slightly longer than
 | |
| 	// the maximum amount of time contained therein; we assume that
 | |
| 	// explicit instructions on timeouts are more useful than a single
 | |
| 	// hard-coded timeout.
 | |
| 	//
 | |
| 	// The default value of 5 is chosen so that, when there's no dial plan,
 | |
| 	// we retain the previous behaviour of 10 seconds end-to-end timeout.
 | |
| 	timeoutSec := 5.0
 | |
| 	if dialPlan != nil {
 | |
| 		for _, c := range dialPlan.Candidates {
 | |
| 			if v := c.DialStartDelaySec + c.DialTimeoutSec; v > timeoutSec {
 | |
| 				timeoutSec = v
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// After we establish a connection, we need some time to actually
 | |
| 	// upgrade it into a Noise connection. With a ballpark worst-case RTT
 | |
| 	// of 1000ms, give ourselves an extra 5 seconds to complete the
 | |
| 	// handshake.
 | |
| 	timeoutSec += 5
 | |
| 
 | |
| 	// Be extremely defensive and ensure that the timeout is in the range
 | |
| 	// [5, 60] seconds (e.g. if we accidentally get a negative number).
 | |
| 	if timeoutSec > 60 {
 | |
| 		timeoutSec = 60
 | |
| 	} else if timeoutSec < 5 {
 | |
| 		timeoutSec = 5
 | |
| 	}
 | |
| 
 | |
| 	timeout := time.Duration(timeoutSec * float64(time.Second))
 | |
| 	ctx, cancel := context.WithTimeout(ctx, timeout)
 | |
| 	defer cancel()
 | |
| 
 | |
| 	chd := &controlhttp.Dialer{
 | |
| 		Hostname:        nc.host,
 | |
| 		HTTPPort:        nc.httpPort,
 | |
| 		HTTPSPort:       cmp.Or(nc.httpsPort, controlhttp.NoPort),
 | |
| 		MachineKey:      nc.opts.PrivKey,
 | |
| 		ControlKey:      nc.opts.ServerPubKey,
 | |
| 		ProtocolVersion: cmp.Or(nc.opts.ProtocolVersion, uint16(tailcfg.CurrentCapabilityVersion)),
 | |
| 		Dialer:          nc.opts.Dialer.SystemDial,
 | |
| 		DNSCache:        nc.opts.DNSCache,
 | |
| 		DialPlan:        dialPlan,
 | |
| 		Logf:            nc.logf,
 | |
| 		NetMon:          nc.opts.NetMon,
 | |
| 		HealthTracker:   nc.opts.HealthTracker,
 | |
| 		Clock:           tstime.StdClock{},
 | |
| 	}
 | |
| 	clientConn, err := chd.Dial(ctx)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	nc.mu.Lock()
 | |
| 
 | |
| 	handle := set.NewHandle()
 | |
| 	ncc := NewConn(clientConn.Conn, func() { nc.noteConnClosed(handle) })
 | |
| 	mak.Set(&nc.connPool, handle, ncc)
 | |
| 
 | |
| 	if nc.closed {
 | |
| 		nc.mu.Unlock()
 | |
| 		ncc.Close() // Needs to be called without holding the lock.
 | |
| 		return nil, errors.New("noise client closed")
 | |
| 	}
 | |
| 
 | |
| 	defer nc.mu.Unlock()
 | |
| 	return ncc, nil
 | |
| }
 | |
| 
 | |
| // noteConnClosed notes that the *Conn with the given handle has closed and
 | |
| // should be removed from the live connPool (which is usually of size 0 or 1,
 | |
| // except perhaps briefly 2 during a network failure and reconnect).
 | |
| func (nc *Client) noteConnClosed(handle set.Handle) {
 | |
| 	nc.mu.Lock()
 | |
| 	defer nc.mu.Unlock()
 | |
| 	nc.connPool.Delete(handle)
 | |
| }
 | |
| 
 | |
| // post does a POST to the control server at the given path, JSON-encoding body.
 | |
| // The provided nodeKey is an optional load balancing hint.
 | |
| func (nc *Client) Post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
 | |
| 	return nc.DoWithBody(ctx, "POST", path, nodeKey, body)
 | |
| }
 | |
| 
 | |
| func (nc *Client) DoWithBody(ctx context.Context, method, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
 | |
| 	jbody, err := json.Marshal(body)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	req, err := http.NewRequestWithContext(ctx, method, "https://"+nc.host+path, bytes.NewReader(jbody))
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	AddLBHeader(req, nodeKey)
 | |
| 	req.Header.Set("Content-Type", "application/json")
 | |
| 	return nc.Do(req)
 | |
| }
 | |
| 
 | |
| // AddLBHeader adds the load balancer header to req if nodeKey is non-zero.
 | |
| func AddLBHeader(req *http.Request, nodeKey key.NodePublic) {
 | |
| 	if !nodeKey.IsZero() {
 | |
| 		req.Header.Add(tailcfg.LBHeader, nodeKey.String())
 | |
| 	}
 | |
| }
 |