Brad Fitzpatrick 50d7176333 control/tsp, cmd/tsp: add low-level Tailscale protocol client and tool
Add a new control/tsp package providing a client for speaking the
Tailscale protocol to a coordination server over Noise, along with a
cmd/tsp binary exposing it as a low-level composable tool for
generating keys, registering nodes, and issuing map requests.

Previously developed out-of-tree at github.com/bradfitz/tsp; imported
here without git history.

Updates #12542

Change-Id: I6ad21143c4aefe8939d4a46ae65b2184173bf69f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-16 20:00:25 -07:00

252 lines
7.0 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package tsp provides a client for speaking the Tailscale protocol
// to a coordination server over Noise.
package tsp
import (
"bufio"
"bytes"
"cmp"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"sync"
"tailscale.com/control/ts2021"
"tailscale.com/ipn"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/version"
)
// DefaultServerURL is the default coordination server base URL,
// used when ClientOpts.ServerURL is empty.
const DefaultServerURL = ipn.DefaultControlURL
// ClientOpts contains options for creating a new Client.
type ClientOpts struct {
// ServerURL is the base URL of the coordination server
// (e.g. "https://controlplane.tailscale.com").
// If empty, DefaultServerURL is used.
ServerURL string
// MachineKey is this node's machine private key. Required.
MachineKey key.MachinePrivate
// Logf is the log function. If nil, logger.Discard is used.
Logf logger.Logf
}
// Client is a Tailscale protocol client that speaks to a coordination
// server over Noise.
type Client struct {
opts ClientOpts
serverURL string
logf logger.Logf
mu sync.Mutex
nc *ts2021.Client // nil until noiseClient called
serverPub key.MachinePublic // zero until set or discovered
}
// NewClient creates a new Client configured to talk to the coordination server
// specified in opts. It performs no I/O; the server's public key is discovered
// lazily on first use or can be set explicitly via SetControlPublicKey.
func NewClient(opts ClientOpts) (*Client, error) {
if opts.MachineKey.IsZero() {
return nil, fmt.Errorf("MachineKey is required")
}
logf := opts.Logf
if logf == nil {
logf = logger.Discard
}
return &Client{
opts: opts,
serverURL: cmp.Or(opts.ServerURL, DefaultServerURL),
logf: logf,
}, nil
}
// SetControlPublicKey sets the server's public key, bypassing lazy discovery.
// Any existing noise client is invalidated and will be re-created on next use.
func (c *Client) SetControlPublicKey(k key.MachinePublic) {
c.mu.Lock()
defer c.mu.Unlock()
c.serverPub = k
c.nc = nil
}
// DiscoverServerKey fetches the server's public key from the coordination
// server and stores it for subsequent use. Any existing noise client is
// invalidated.
func (c *Client) DiscoverServerKey(ctx context.Context) (key.MachinePublic, error) {
k, err := DiscoverServerKey(ctx, c.serverURL)
if err != nil {
return key.MachinePublic{}, err
}
c.mu.Lock()
defer c.mu.Unlock()
c.serverPub = k
c.nc = nil
return k, nil
}
// DiscoverServerKey fetches the coordination server's public key from the
// given server URL. It is a standalone function that requires no client state.
func DiscoverServerKey(ctx context.Context, serverURL string) (key.MachinePublic, error) {
serverURL = cmp.Or(serverURL, DefaultServerURL)
keysURL := serverURL + "/key?v=" + strconv.Itoa(int(tailcfg.CurrentCapabilityVersion))
req, err := http.NewRequestWithContext(ctx, "GET", keysURL, nil)
if err != nil {
return key.MachinePublic{}, fmt.Errorf("creating key request: %w", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return key.MachinePublic{}, fmt.Errorf("fetching server key: %w", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return key.MachinePublic{}, fmt.Errorf("fetching server key: %s", res.Status)
}
var keys struct {
PublicKey key.MachinePublic
}
if err := json.NewDecoder(res.Body).Decode(&keys); err != nil {
return key.MachinePublic{}, fmt.Errorf("decoding server key: %w", err)
}
return keys.PublicKey, nil
}
// noiseClient returns the ts2021 noise client, creating it lazily if needed.
// If the server's public key is not yet known, it is discovered via HTTP.
func (c *Client) noiseClient(ctx context.Context) (*ts2021.Client, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.nc != nil {
return c.nc, nil
}
if c.serverPub.IsZero() {
// Discover server key without holding the lock, to avoid blocking
// other callers during the HTTP request.
c.mu.Unlock()
k, err := DiscoverServerKey(ctx, c.serverURL)
c.mu.Lock()
if err != nil {
return nil, err
}
// Re-check: another goroutine may have set it while we were unlocked.
if c.serverPub.IsZero() {
c.serverPub = k
}
// If nc was created by another goroutine while unlocked, use it.
if c.nc != nil {
return c.nc, nil
}
}
nc, err := ts2021.NewClient(ts2021.ClientOpts{
ServerURL: c.serverURL,
PrivKey: c.opts.MachineKey,
ServerPubKey: c.serverPub,
Dialer: tsdial.NewFromFuncForDebug(c.logf, (&net.Dialer{}).DialContext),
Logf: c.logf,
})
if err != nil {
return nil, fmt.Errorf("creating noise client: %w", err)
}
c.nc = nc
return nc, nil
}
// AnswerC2NPing handles a c2n PingRequest from the control plane by parsing the
// embedded HTTP request in the payload, routing it locally, and POSTing the HTTP
// response back to pr.URL using doNoiseRequest. The POST is done in a new
// goroutine so this method does not block.
//
// It reports whether the ping was handled. Unhandled pings (nil pr, non-c2n
// types, or unrecognized c2n paths) return false.
func (c *Client) AnswerC2NPing(ctx context.Context, pr *tailcfg.PingRequest, doNoiseRequest func(*http.Request) (*http.Response, error)) (handled bool) {
if pr == nil || pr.Types != "c2n" {
return false
}
// Parse the HTTP request from the payload.
httpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(pr.Payload)))
if err != nil {
c.logf("parsing c2n ping payload: %v", err)
return false
}
// Route the request locally.
var httpResp *http.Response
switch httpReq.URL.Path {
case "/echo":
body, _ := io.ReadAll(httpReq.Body)
httpResp = &http.Response{
StatusCode: 200,
Status: "200 OK",
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
Body: io.NopCloser(bytes.NewReader(body)),
ContentLength: int64(len(body)),
}
default:
c.logf("ignoring c2n ping request for unhandled path %q", httpReq.URL.Path)
return false
}
// Serialize the HTTP response.
var buf bytes.Buffer
if err := httpResp.Write(&buf); err != nil {
c.logf("serializing c2n ping response: %v", err)
return false
}
// Send the response back to the control plane over the Noise channel.
go func() {
req, err := http.NewRequestWithContext(ctx, "POST", pr.URL, &buf)
if err != nil {
c.logf("creating c2n ping reply request: %v", err)
return
}
resp, err := doNoiseRequest(req)
if err != nil {
c.logf("sending c2n ping reply: %v", err)
return
}
resp.Body.Close()
}()
return true
}
// Close closes the client and releases resources.
func (c *Client) Close() error {
c.mu.Lock()
nc := c.nc
c.nc = nil
c.mu.Unlock()
if nc != nil {
nc.Close()
}
return nil
}
func defaultHostinfo() *tailcfg.Hostinfo {
return &tailcfg.Hostinfo{
OS: version.OS(),
IPNVersion: version.Long(),
}
}