Files
tailscale/control/ts2021/client.go
Will Norris 3ec5be3f51 all: remove AUTHORS file and references to it
This file was never truly necessary and has never actually been used in
the history of Tailscale's open source releases.

A Brief History of AUTHORS files
---

The AUTHORS file was a pattern developed at Google, originally for
Chromium, then adopted by Go and a bunch of other projects. The problem
was that Chromium originally had a copyright line only recognizing
Google as the copyright holder. Because Google (and most open source
projects) do not require copyright assignemnt for contributions, each
contributor maintains their copyright. Some large corporate contributors
then tried to add their own name to the copyright line in the LICENSE
file or in file headers. This quickly becomes unwieldy, and puts a
tremendous burden on anyone building on top of Chromium, since the
license requires that they keep all copyright lines intact.

The compromise was to create an AUTHORS file that would list all of the
copyright holders. The LICENSE file and source file headers would then
include that list by reference, listing the copyright holder as "The
Chromium Authors".

This also become cumbersome to simply keep the file up to date with a
high rate of new contributors. Plus it's not always obvious who the
copyright holder is. Sometimes it is the individual making the
contribution, but many times it may be their employer. There is no way
for the proejct maintainer to know.

Eventually, Google changed their policy to no longer recommend trying to
keep the AUTHORS file up to date proactively, and instead to only add to
it when requested: https://opensource.google/docs/releasing/authors.
They are also clear that:

> Adding contributors to the AUTHORS file is entirely within the
> project's discretion and has no implications for copyright ownership.

It was primarily added to appease a small number of large contributors
that insisted that they be recognized as copyright holders (which was
entirely their right to do). But it's not truly necessary, and not even
the most accurate way of identifying contributors and/or copyright
holders.

In practice, we've never added anyone to our AUTHORS file. It only lists
Tailscale, so it's not really serving any purpose. It also causes
confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header
in other open source repos which don't actually have an AUTHORS file, so
it's ambiguous what that means.

Instead, we just acknowledge that the contributors to Tailscale (whoever
they are) are copyright holders for their individual contributions. We
also have the benefit of using the DCO (developercertificate.org) which
provides some additional certification of their right to make the
contribution.

The source file changes were purely mechanical with:

    git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g'

Updates #cleanup

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2026-01-23 15:49:45 -08:00

313 lines
9.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// 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())
}
}