mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-25 20:23:43 +00:00
control/controlclient: remove x/net/http2, use net/http
Saves 352 KB, removing one of our two HTTP/2 implementations linked into the binary. Fixes #17305 Updates #15015 Change-Id: I53a04b1f2687dca73c8541949465038b69aa6ade Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
c45f8813b4
commit
1d93bdce20
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/control/ts2021"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/feature"
|
||||
"tailscale.com/feature/buildfeatures"
|
||||
@@ -95,8 +96,8 @@ type Direct struct {
|
||||
serverLegacyKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key; only used for signRegisterRequest on Windows now
|
||||
serverNoiseKey key.MachinePublic
|
||||
|
||||
sfGroup singleflight.Group[struct{}, *NoiseClient] // protects noiseClient creation.
|
||||
noiseClient *NoiseClient
|
||||
sfGroup singleflight.Group[struct{}, *ts2021.Client] // protects noiseClient creation.
|
||||
noiseClient *ts2021.Client
|
||||
|
||||
persist persist.PersistView
|
||||
authKey string
|
||||
@@ -329,7 +330,7 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
}
|
||||
}
|
||||
if opts.NoiseTestClient != nil {
|
||||
c.noiseClient = &NoiseClient{
|
||||
c.noiseClient = &ts2021.Client{
|
||||
Client: opts.NoiseTestClient,
|
||||
}
|
||||
c.serverNoiseKey = key.NewMachine().Public() // prevent early error before hitting test client
|
||||
@@ -359,9 +360,7 @@ func (c *Direct) Close() error {
|
||||
}
|
||||
}
|
||||
c.noiseClient = nil
|
||||
if tr, ok := c.httpc.Transport.(*http.Transport); ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
c.httpc.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -703,8 +702,8 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
if err != nil {
|
||||
return regen, opt.URL, nil, err
|
||||
}
|
||||
addLBHeader(req, request.OldNodeKey)
|
||||
addLBHeader(req, request.NodeKey)
|
||||
ts2021.AddLBHeader(req, request.OldNodeKey)
|
||||
ts2021.AddLBHeader(req, request.NodeKey)
|
||||
|
||||
res, err := httpc.Do(req)
|
||||
if err != nil {
|
||||
@@ -1012,7 +1011,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addLBHeader(req, nodeKey)
|
||||
ts2021.AddLBHeader(req, nodeKey)
|
||||
|
||||
res, err := httpc.Do(req)
|
||||
if err != nil {
|
||||
@@ -1507,7 +1506,7 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, d time.Duration, cl
|
||||
}
|
||||
|
||||
// getNoiseClient returns the noise client, creating one if one doesn't exist.
|
||||
func (c *Direct) getNoiseClient() (*NoiseClient, error) {
|
||||
func (c *Direct) getNoiseClient() (*ts2021.Client, error) {
|
||||
c.mu.Lock()
|
||||
serverNoiseKey := c.serverNoiseKey
|
||||
nc := c.noiseClient
|
||||
@@ -1522,13 +1521,13 @@ func (c *Direct) getNoiseClient() (*NoiseClient, error) {
|
||||
if c.dialPlan != nil {
|
||||
dp = c.dialPlan.Load
|
||||
}
|
||||
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*NoiseClient, error) {
|
||||
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*ts2021.Client, error) {
|
||||
k, err := c.getMachinePrivKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.logf("[v1] creating new noise client")
|
||||
nc, err := NewNoiseClient(NoiseOpts{
|
||||
nc, err := ts2021.NewClient(ts2021.ClientOpts{
|
||||
PrivKey: k,
|
||||
ServerPubKey: serverNoiseKey,
|
||||
ServerURL: c.serverURL,
|
||||
@@ -1562,7 +1561,7 @@ func (c *Direct) setDNSNoise(ctx context.Context, req *tailcfg.SetDNSRequest) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := nc.post(ctx, "/machine/set-dns", newReq.NodeKey, &newReq)
|
||||
res, err := nc.Post(ctx, "/machine/set-dns", newReq.NodeKey, &newReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1696,7 +1695,7 @@ func (c *Direct) ReportWarnableChange(w *health.Warnable, us *health.UnhealthySt
|
||||
// Best effort, no logging:
|
||||
ctx, cancel := context.WithTimeout(c.closedCtx, 5*time.Second)
|
||||
defer cancel()
|
||||
res, err := np.post(ctx, "/machine/update-health", nodeKey, req)
|
||||
res, err := np.Post(ctx, "/machine/update-health", nodeKey, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -1741,7 +1740,7 @@ func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) e
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
res, err := nc.doWithBody(ctx, "PATCH", "/machine/set-device-attr", nodeKey, req)
|
||||
res, err := nc.DoWithBody(ctx, "PATCH", "/machine/set-device-attr", nodeKey, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1782,7 +1781,7 @@ func (c *Direct) sendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequ
|
||||
panic("tainted client")
|
||||
}
|
||||
|
||||
res, err := nc.post(ctx, "/machine/audit-log", nodeKey, req)
|
||||
res, err := nc.Post(ctx, "/machine/audit-log", nodeKey, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errHTTPPostFailure, err)
|
||||
}
|
||||
@@ -1794,12 +1793,6 @@ func (c *Direct) sendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequ
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
|
||||
if !nodeKey.IsZero() {
|
||||
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
|
||||
}
|
||||
}
|
||||
|
||||
// makeScreenTimeDetectingDialFunc returns dialFunc, optionally wrapped (on
|
||||
// Apple systems) with a func that sets the returned atomic.Bool for whether
|
||||
// Screen Time seemed to intercept the connection.
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/control/ts2021"
|
||||
"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/singleflight"
|
||||
)
|
||||
|
||||
// NoiseClient provides a http.Client to connect to tailcontrol over
|
||||
// the ts2021 protocol.
|
||||
type NoiseClient struct {
|
||||
// Client is an HTTP client to talk to the coordination server.
|
||||
// It automatically makes a new Noise connection as needed.
|
||||
// It does not support node key proofs. To do that, call
|
||||
// noiseClient.getConn instead to make a connection.
|
||||
*http.Client
|
||||
|
||||
// h2t is the HTTP/2 transport we use a bit to create new
|
||||
// *http2.ClientConns. We don't use its connection pool and we don't use its
|
||||
// dialing. We use it for exactly one reason: its idle timeout that can only
|
||||
// be configured via the HTTP/1 config. And then we call NewClientConn (with
|
||||
// an existing Noise connection) on the http2.Transport which sets up an
|
||||
// http2.ClientConn using that idle timeout from an http1.Transport.
|
||||
h2t *http2.Transport
|
||||
|
||||
// sfDial ensures that two concurrent requests for a noise connection only
|
||||
// produce one shared one between the two callers.
|
||||
sfDial singleflight.Group[struct{}, *ts2021.Conn]
|
||||
|
||||
dialer *tsdial.Dialer
|
||||
dnsCache *dnscache.Resolver
|
||||
privKey key.MachinePrivate
|
||||
serverPubKey key.MachinePublic
|
||||
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
|
||||
|
||||
// dialPlan optionally returns a ControlDialPlan previously received
|
||||
// from the control server; either the function or the return value can
|
||||
// be nil.
|
||||
dialPlan func() *tailcfg.ControlDialPlan
|
||||
|
||||
logf logger.Logf
|
||||
netMon *netmon.Monitor
|
||||
health *health.Tracker
|
||||
|
||||
// mu only protects the following variables.
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
last *ts2021.Conn // or nil
|
||||
nextID int
|
||||
connPool map[int]*ts2021.Conn // active connections not yet closed; see ts2021.Conn.Close
|
||||
}
|
||||
|
||||
// NoiseOpts contains options for the NewNoiseClient function. All fields are
|
||||
// required unless otherwise specified.
|
||||
type NoiseOpts struct {
|
||||
// PrivKey is this node's private key.
|
||||
PrivKey key.MachinePrivate
|
||||
// ServerPubKey is the public key of the server.
|
||||
ServerPubKey key.MachinePublic
|
||||
// ServerURL is the URL of the server to connect to.
|
||||
ServerURL string
|
||||
// Dialer's SystemDial function is used to connect to the server.
|
||||
Dialer *tsdial.Dialer
|
||||
// DNSCache is the caching Resolver to use to connect to the server.
|
||||
//
|
||||
// This field can be nil.
|
||||
DNSCache *dnscache.Resolver
|
||||
// Logf is the log function to use. This field can be nil.
|
||||
Logf logger.Logf
|
||||
// NetMon is the network monitor that, if set, 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewNoiseClient returns a new noiseClient for the provided server and machine key.
|
||||
// serverURL is of the form https://<host>:<port> (no trailing slash).
|
||||
//
|
||||
// netMon may be nil, if non-nil it's used to do faster interface lookups.
|
||||
// dialPlan may be nil
|
||||
func NewNoiseClient(opts NoiseOpts) (*NoiseClient, error) {
|
||||
logf := opts.Logf
|
||||
u, err := url.Parse(opts.ServerURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, errors.New("invalid ServerURL scheme, must be http or https")
|
||||
}
|
||||
|
||||
var httpPort string
|
||||
var httpsPort string
|
||||
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 = ""
|
||||
} else {
|
||||
// Otherwise, use the standard ports
|
||||
httpPort = "80"
|
||||
httpsPort = "443"
|
||||
}
|
||||
|
||||
np := &NoiseClient{
|
||||
serverPubKey: opts.ServerPubKey,
|
||||
privKey: opts.PrivKey,
|
||||
host: u.Hostname(),
|
||||
httpPort: httpPort,
|
||||
httpsPort: httpsPort,
|
||||
dialer: opts.Dialer,
|
||||
dnsCache: opts.DNSCache,
|
||||
dialPlan: opts.DialPlan,
|
||||
logf: opts.Logf,
|
||||
netMon: opts.NetMon,
|
||||
health: opts.HealthTracker,
|
||||
}
|
||||
|
||||
// Create the HTTP/2 Transport using a net/http.Transport
|
||||
// (which only does HTTP/1) because it's the only way to
|
||||
// configure certain properties on the http2.Transport. But we
|
||||
// never actually use the net/http.Transport for any HTTP/1
|
||||
// requests.
|
||||
h2Transport, err := http2.ConfigureTransports(&http.Transport{
|
||||
IdleConnTimeout: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
np.h2t = h2Transport
|
||||
|
||||
np.Client = &http.Client{Transport: np}
|
||||
return np, nil
|
||||
}
|
||||
|
||||
// contextErr is an error that wraps another error and is used to indicate that
|
||||
// the error was because a context expired.
|
||||
type contextErr struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e contextErr) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e contextErr) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// getConn returns a ts2021.Conn that can be used to make requests to the
|
||||
// coordination server. It may return a cached connection or create a new one.
|
||||
// Dials are singleflighted, so concurrent calls to getConn may only dial once.
|
||||
// As such, context values may not be respected as there are no guarantees that
|
||||
// the context passed to getConn is the same as the context passed to dial.
|
||||
func (nc *NoiseClient) getConn(ctx context.Context) (*ts2021.Conn, error) {
|
||||
nc.mu.Lock()
|
||||
if last := nc.last; last != nil && last.CanTakeNewRequest() {
|
||||
nc.mu.Unlock()
|
||||
return last, nil
|
||||
}
|
||||
nc.mu.Unlock()
|
||||
|
||||
for {
|
||||
// We singeflight the dial to avoid making multiple connections, however
|
||||
// that means that we can't simply cancel the dial if the context is
|
||||
// canceled. Instead, we have to additionally check that the context
|
||||
// which was canceled is our context and retry if our context is still
|
||||
// valid.
|
||||
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*ts2021.Conn, error) {
|
||||
c, err := nc.dial(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, contextErr{ctx.Err()}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
})
|
||||
var ce contextErr
|
||||
if err == nil || !errors.As(err, &ce) {
|
||||
return conn, err
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
// The dial failed because of a context error, but our context
|
||||
// is still valid. Retry.
|
||||
continue
|
||||
}
|
||||
// The dial failed because our context was canceled. Return the
|
||||
// underlying error.
|
||||
return nil, ce.Unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
func (nc *NoiseClient) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
ctx := req.Context()
|
||||
conn, err := nc.getConn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.RoundTrip(req)
|
||||
}
|
||||
|
||||
// connClosed removes the connection with the provided ID from the pool
|
||||
// of active connections.
|
||||
func (nc *NoiseClient) connClosed(id int) {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
conn := nc.connPool[id]
|
||||
if conn != nil {
|
||||
delete(nc.connPool, id)
|
||||
if nc.last == conn {
|
||||
nc.last = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes all the underlying noise connections.
|
||||
// It is a no-op and returns nil if the connection is already closed.
|
||||
func (nc *NoiseClient) Close() error {
|
||||
nc.mu.Lock()
|
||||
nc.closed = true
|
||||
conns := nc.connPool
|
||||
nc.connPool = nil
|
||||
nc.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, c := range conns {
|
||||
if err := c.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// dial opens a new connection to tailcontrol, fetching the server noise key
|
||||
// if not cached.
|
||||
func (nc *NoiseClient) dial(ctx context.Context) (*ts2021.Conn, error) {
|
||||
nc.mu.Lock()
|
||||
connID := nc.nextID
|
||||
nc.nextID++
|
||||
nc.mu.Unlock()
|
||||
|
||||
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.dialPlan != nil {
|
||||
dialPlan = nc.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()
|
||||
|
||||
clientConn, err := (&controlhttp.Dialer{
|
||||
Hostname: nc.host,
|
||||
HTTPPort: nc.httpPort,
|
||||
HTTPSPort: cmp.Or(nc.httpsPort, controlhttp.NoPort),
|
||||
MachineKey: nc.privKey,
|
||||
ControlKey: nc.serverPubKey,
|
||||
ProtocolVersion: uint16(tailcfg.CurrentCapabilityVersion),
|
||||
Dialer: nc.dialer.SystemDial,
|
||||
DNSCache: nc.dnsCache,
|
||||
DialPlan: dialPlan,
|
||||
Logf: nc.logf,
|
||||
NetMon: nc.netMon,
|
||||
HealthTracker: nc.health,
|
||||
Clock: tstime.StdClock{},
|
||||
}).Dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ncc, err := ts2021.New(clientConn.Conn, nc.h2t, connID, nc.connClosed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nc.mu.Lock()
|
||||
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()
|
||||
mak.Set(&nc.connPool, connID, ncc)
|
||||
nc.last = ncc
|
||||
return ncc, nil
|
||||
}
|
||||
|
||||
// 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 *NoiseClient) post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
|
||||
return nc.doWithBody(ctx, "POST", path, nodeKey, body)
|
||||
}
|
||||
|
||||
func (nc *NoiseClient) 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")
|
||||
conn, err := nc.getConn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.RoundTrip(req)
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/control/controlhttp/controlhttpserver"
|
||||
"tailscale.com/control/ts2021"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest/nettest"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/eventbus/eventbustest"
|
||||
)
|
||||
|
||||
// maxAllowedNoiseVersion is the highest we expect the Tailscale
|
||||
// capability version to ever get. It's a value close to 2^16, but
|
||||
// with enough leeway that we get a very early warning that it's time
|
||||
// to rework the wire protocol to allow larger versions, while still
|
||||
// giving us headroom to bump this test and fix the build.
|
||||
//
|
||||
// Code elsewhere in the client will panic() if the tailcfg capability
|
||||
// version exceeds 16 bits, so take a failure of this test seriously.
|
||||
const maxAllowedNoiseVersion = math.MaxUint16 - 5000
|
||||
|
||||
func TestNoiseVersion(t *testing.T) {
|
||||
if tailcfg.CurrentCapabilityVersion > maxAllowedNoiseVersion {
|
||||
t.Fatalf("tailcfg.CurrentCapabilityVersion is %d, want <=%d", tailcfg.CurrentCapabilityVersion, maxAllowedNoiseVersion)
|
||||
}
|
||||
}
|
||||
|
||||
type noiseClientTest struct {
|
||||
sendEarlyPayload bool
|
||||
}
|
||||
|
||||
func TestNoiseClientHTTP2Upgrade(t *testing.T) {
|
||||
noiseClientTest{}.run(t)
|
||||
}
|
||||
|
||||
func TestNoiseClientHTTP2Upgrade_earlyPayload(t *testing.T) {
|
||||
noiseClientTest{
|
||||
sendEarlyPayload: true,
|
||||
}.run(t)
|
||||
}
|
||||
|
||||
func makeClientWithURL(t *testing.T, url string) *NoiseClient {
|
||||
nc, err := NewNoiseClient(NoiseOpts{
|
||||
Logf: t.Logf,
|
||||
ServerURL: url,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return nc
|
||||
}
|
||||
|
||||
func TestNoiseClientPortsAreSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantHTTPS string
|
||||
wantHTTP string
|
||||
}{
|
||||
{
|
||||
name: "https-url",
|
||||
url: "https://example.com",
|
||||
wantHTTPS: "443",
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "http-url",
|
||||
url: "http://example.com",
|
||||
wantHTTPS: "443", // TODO(bradfitz): questionable; change?
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "https-url-custom-port",
|
||||
url: "https://example.com:123",
|
||||
wantHTTPS: "123",
|
||||
wantHTTP: "",
|
||||
},
|
||||
{
|
||||
name: "http-url-custom-port",
|
||||
url: "http://example.com:123",
|
||||
wantHTTPS: "443", // TODO(bradfitz): questionable; change?
|
||||
wantHTTP: "123",
|
||||
},
|
||||
{
|
||||
name: "http-loopback-no-port",
|
||||
url: "http://127.0.0.1",
|
||||
wantHTTPS: "",
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "http-loopback-custom-port",
|
||||
url: "http://127.0.0.1:8080",
|
||||
wantHTTPS: "",
|
||||
wantHTTP: "8080",
|
||||
},
|
||||
{
|
||||
name: "http-localhost-no-port",
|
||||
url: "http://localhost",
|
||||
wantHTTPS: "",
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "http-localhost-custom-port",
|
||||
url: "http://localhost:8080",
|
||||
wantHTTPS: "",
|
||||
wantHTTP: "8080",
|
||||
},
|
||||
{
|
||||
name: "http-private-ip-no-port",
|
||||
url: "http://192.168.2.3",
|
||||
wantHTTPS: "",
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "http-private-ip-custom-port",
|
||||
url: "http://192.168.2.3:8080",
|
||||
wantHTTPS: "",
|
||||
wantHTTP: "8080",
|
||||
},
|
||||
{
|
||||
name: "http-public-ip",
|
||||
url: "http://1.2.3.4",
|
||||
wantHTTPS: "443", // TODO(bradfitz): questionable; change?
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "http-public-ip-custom-port",
|
||||
url: "http://1.2.3.4:8080",
|
||||
wantHTTPS: "443", // TODO(bradfitz): questionable; change?
|
||||
wantHTTP: "8080",
|
||||
},
|
||||
{
|
||||
name: "https-public-ip",
|
||||
url: "https://1.2.3.4",
|
||||
wantHTTPS: "443",
|
||||
wantHTTP: "80",
|
||||
},
|
||||
{
|
||||
name: "https-public-ip-custom-port",
|
||||
url: "https://1.2.3.4:8080",
|
||||
wantHTTPS: "8080",
|
||||
wantHTTP: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nc := makeClientWithURL(t, tt.url)
|
||||
if nc.httpsPort != tt.wantHTTPS {
|
||||
t.Errorf("nc.httpsPort = %q; want %q", nc.httpsPort, tt.wantHTTPS)
|
||||
}
|
||||
if nc.httpPort != tt.wantHTTP {
|
||||
t.Errorf("nc.httpPort = %q; want %q", nc.httpPort, tt.wantHTTP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (tt noiseClientTest) run(t *testing.T) {
|
||||
serverPrivate := key.NewMachine()
|
||||
clientPrivate := key.NewMachine()
|
||||
chalPrivate := key.NewChallenge()
|
||||
bus := eventbustest.NewBus(t)
|
||||
|
||||
const msg = "Hello, client"
|
||||
h2 := &http2.Server{}
|
||||
nw := nettest.GetNetwork(t)
|
||||
hs := nettest.NewHTTPServer(nw, &Upgrader{
|
||||
h2srv: h2,
|
||||
noiseKeyPriv: serverPrivate,
|
||||
sendEarlyPayload: tt.sendEarlyPayload,
|
||||
challenge: chalPrivate,
|
||||
httpBaseConfig: &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(w, msg)
|
||||
}),
|
||||
},
|
||||
})
|
||||
defer hs.Close()
|
||||
|
||||
dialer := tsdial.NewDialer(netmon.NewStatic())
|
||||
dialer.SetBus(bus)
|
||||
if nettest.PreferMemNetwork() {
|
||||
dialer.SetSystemDialerForTest(nw.Dial)
|
||||
}
|
||||
|
||||
nc, err := NewNoiseClient(NoiseOpts{
|
||||
PrivKey: clientPrivate,
|
||||
ServerPubKey: serverPrivate.Public(),
|
||||
ServerURL: hs.URL,
|
||||
Dialer: dialer,
|
||||
Logf: t.Logf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Get a conn and verify it read its early payload before the http/2
|
||||
// handshake.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, err := nc.getConn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := c.GetEarlyPayload(ctx)
|
||||
if err != nil {
|
||||
t.Fatal("timed out waiting for didReadHeaderCh")
|
||||
}
|
||||
|
||||
gotNonNil := payload != nil
|
||||
if gotNonNil != tt.sendEarlyPayload {
|
||||
t.Errorf("sendEarlyPayload = %v but got earlyPayload = %T", tt.sendEarlyPayload, payload)
|
||||
}
|
||||
if payload != nil {
|
||||
if payload.NodeKeyChallenge != chalPrivate.Public() {
|
||||
t.Errorf("earlyPayload.NodeKeyChallenge = %v; want %v", payload.NodeKeyChallenge, chalPrivate.Public())
|
||||
}
|
||||
}
|
||||
|
||||
checkRes := func(t *testing.T, res *http.Response) {
|
||||
t.Helper()
|
||||
defer res.Body.Close()
|
||||
all, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(all) != msg {
|
||||
t.Errorf("got response %q; want %q", all, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// And verify we can do HTTP/2 against that conn.
|
||||
res, err := (&http.Client{Transport: c}).Get("https://unused.example/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkRes(t, res)
|
||||
|
||||
// And try using the high-level nc.post API as well.
|
||||
res, err = nc.post(context.Background(), "/", key.NodePublic{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkRes(t, res)
|
||||
}
|
||||
|
||||
// Upgrader is an http.Handler that hijacks and upgrades POST-with-Upgrade
|
||||
// request to a Tailscale 2021 connection, then hands the resulting
|
||||
// controlbase.Conn off to h2srv.
|
||||
type Upgrader struct {
|
||||
// h2srv is that will handle requests after the
|
||||
// connection has been upgraded to HTTP/2-over-noise.
|
||||
h2srv *http2.Server
|
||||
|
||||
// httpBaseConfig is the http1 server config that h2srv is
|
||||
// associated with.
|
||||
httpBaseConfig *http.Server
|
||||
|
||||
logf logger.Logf
|
||||
|
||||
noiseKeyPriv key.MachinePrivate
|
||||
challenge key.ChallengePrivate
|
||||
|
||||
sendEarlyPayload bool
|
||||
}
|
||||
|
||||
func (up *Upgrader) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if up == nil || up.h2srv == nil {
|
||||
http.Error(w, "invalid server config", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/ts2021" {
|
||||
http.Error(w, "ts2021 upgrader installed at wrong path", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if up.noiseKeyPriv.IsZero() {
|
||||
http.Error(w, "keys not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
earlyWriteFn := func(protocolVersion int, w io.Writer) error {
|
||||
if !up.sendEarlyPayload {
|
||||
return nil
|
||||
}
|
||||
earlyJSON, err := json.Marshal(&tailcfg.EarlyNoise{
|
||||
NodeKeyChallenge: up.challenge.Public(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 5 bytes that won't be mistaken for an HTTP/2 frame:
|
||||
// https://httpwg.org/specs/rfc7540.html#rfc.section.4.1 (Especially not
|
||||
// an HTTP/2 settings frame, which isn't of type 'T')
|
||||
var notH2Frame [5]byte
|
||||
copy(notH2Frame[:], ts2021.EarlyPayloadMagic)
|
||||
var lenBuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(earlyJSON)))
|
||||
// These writes are all buffered by caller, so fine to do them
|
||||
// separately:
|
||||
if _, err := w.Write(notH2Frame[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(earlyJSON[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cbConn, err := controlhttpserver.AcceptHTTP(r.Context(), w, r, up.noiseKeyPriv, earlyWriteFn)
|
||||
if err != nil {
|
||||
up.logf("controlhttp: Accept: %v", err)
|
||||
return
|
||||
}
|
||||
defer cbConn.Close()
|
||||
|
||||
up.h2srv.ServeConn(cbConn, &http2.ServeConnOpts{
|
||||
BaseConfig: up.httpBaseConfig,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user