summaryrefslogtreecommitdiffhomepage
path: root/control/ts2021/client.go
blob: ca10b1d1b5bc644eb3daeb6718938c87e1a40ef5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
// 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())
	}
}