mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
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>
514 lines
13 KiB
Go
514 lines
13 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Program tsp is a low-level Tailscale protocol tool for performing
|
|
// composable building block operations like generating keys and
|
|
// registering nodes.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/peterbourgon/ff/v3/ffcli"
|
|
"tailscale.com/control/tsp"
|
|
"tailscale.com/hostinfo"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
var globalArgs struct {
|
|
// serverURL is the base URL of the coordination server (-s flag).
|
|
// If empty, tsp.DefaultServerURL is used.
|
|
serverURL string
|
|
|
|
// controlKeyFile is a path to a file containing the server's
|
|
// MachinePublic key in MarshalText form (--control-key flag).
|
|
// When set, server key discovery is skipped.
|
|
controlKeyFile string
|
|
}
|
|
|
|
func main() {
|
|
args := os.Args[1:]
|
|
if err := rootCmd.Parse(args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err.Error())
|
|
os.Exit(1)
|
|
}
|
|
err := rootCmd.Run(context.Background())
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
os.Exit(0)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
var rootCmd = &ffcli.Command{
|
|
Name: "tsp",
|
|
ShortUsage: "tsp [-s url] <subcommand> [flags]",
|
|
ShortHelp: "Low-level Tailscale protocol tool.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("tsp", flag.ExitOnError)
|
|
fs.StringVar(&globalArgs.serverURL, "s", "", "base URL of coordination server (default: "+tsp.DefaultServerURL+")")
|
|
fs.StringVar(&globalArgs.controlKeyFile, "control-key", "", "file containing the server's public key (skips discovery)")
|
|
return fs
|
|
})(),
|
|
Subcommands: []*ffcli.Command{
|
|
newMachineKeyCmd,
|
|
newNodeKeyCmd,
|
|
newNodeCmd,
|
|
registerCmd,
|
|
mapCmd,
|
|
discoverServerKeyCmd,
|
|
},
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
return flag.ErrHelp
|
|
},
|
|
}
|
|
|
|
var newMachineKeyArgs struct {
|
|
output string
|
|
}
|
|
|
|
var newMachineKeyCmd = &ffcli.Command{
|
|
Name: "new-machine-key",
|
|
ShortUsage: "tsp new-machine-key [-o file]",
|
|
ShortHelp: "Generate a new machine key.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("new-machine-key", flag.ExitOnError)
|
|
fs.StringVar(&newMachineKeyArgs.output, "o", "", "output file (default: stdout)")
|
|
return fs
|
|
})(),
|
|
Exec: runNewMachineKey,
|
|
}
|
|
|
|
func runNewMachineKey(ctx context.Context, args []string) error {
|
|
k := key.NewMachine()
|
|
text, err := k.MarshalText()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text = append(text, '\n')
|
|
return writeOutput(newMachineKeyArgs.output, text)
|
|
}
|
|
|
|
var newNodeKeyArgs struct {
|
|
output string
|
|
}
|
|
|
|
var newNodeKeyCmd = &ffcli.Command{
|
|
Name: "new-node-key",
|
|
ShortUsage: "tsp new-node-key [-o file]",
|
|
ShortHelp: "Generate a new node key.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("new-node-key", flag.ExitOnError)
|
|
fs.StringVar(&newNodeKeyArgs.output, "o", "", "output file (default: stdout)")
|
|
return fs
|
|
})(),
|
|
Exec: runNewNodeKey,
|
|
}
|
|
|
|
func runNewNodeKey(ctx context.Context, args []string) error {
|
|
k := key.NewNode()
|
|
text, err := k.MarshalText()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text = append(text, '\n')
|
|
return writeOutput(newNodeKeyArgs.output, text)
|
|
}
|
|
|
|
var discoverServerKeyArgs struct {
|
|
output string
|
|
}
|
|
|
|
var discoverServerKeyCmd = &ffcli.Command{
|
|
Name: "discover-server-key",
|
|
ShortUsage: "tsp [-s url] discover-server-key [-o file]",
|
|
ShortHelp: "Discover and print the coordination server's public key.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("discover-server-key", flag.ExitOnError)
|
|
fs.StringVar(&discoverServerKeyArgs.output, "o", "", "output file (default: stdout)")
|
|
return fs
|
|
})(),
|
|
Exec: runDiscoverServerKey,
|
|
}
|
|
|
|
func runDiscoverServerKey(ctx context.Context, args []string) error {
|
|
k, err := tsp.DiscoverServerKey(ctx, globalArgs.serverURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text, err := k.MarshalText()
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling server key: %w", err)
|
|
}
|
|
text = append(text, '\n')
|
|
return writeOutput(discoverServerKeyArgs.output, text)
|
|
}
|
|
|
|
var newNodeArgs struct {
|
|
nodeKeyFile string
|
|
machineKeyFile string
|
|
output string
|
|
}
|
|
|
|
var newNodeCmd = &ffcli.Command{
|
|
Name: "new-node",
|
|
ShortUsage: "tsp [-s url] [--control-key file] new-node [-n node-key-file] [-m machine-key-file] [-o output]",
|
|
ShortHelp: "Generate a new node JSON file with keys and server info.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("new-node", flag.ExitOnError)
|
|
fs.StringVar(&newNodeArgs.nodeKeyFile, "n", "", "existing node key file (default: generate new)")
|
|
fs.StringVar(&newNodeArgs.machineKeyFile, "m", "", "existing machine key file (default: generate new)")
|
|
fs.StringVar(&newNodeArgs.output, "o", "", "output file (default: stdout)")
|
|
return fs
|
|
})(),
|
|
Exec: runNewNode,
|
|
}
|
|
|
|
func runNewNode(ctx context.Context, args []string) error {
|
|
var nodeKey key.NodePrivate
|
|
if newNodeArgs.nodeKeyFile != "" {
|
|
var err error
|
|
nodeKey, err = readNodeKeyFile(newNodeArgs.nodeKeyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading node key: %w", err)
|
|
}
|
|
} else {
|
|
nodeKey = key.NewNode()
|
|
}
|
|
|
|
var machineKey key.MachinePrivate
|
|
if newNodeArgs.machineKeyFile != "" {
|
|
var err error
|
|
machineKey, err = readMachineKeyFile(newNodeArgs.machineKeyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading machine key: %w", err)
|
|
}
|
|
} else {
|
|
machineKey = key.NewMachine()
|
|
}
|
|
|
|
serverURL := cmp.Or(globalArgs.serverURL, tsp.DefaultServerURL)
|
|
|
|
var serverKey key.MachinePublic
|
|
if globalArgs.controlKeyFile != "" {
|
|
var err error
|
|
serverKey, err = readControlKeyFile(globalArgs.controlKeyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading control key: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
serverKey, err = tsp.DiscoverServerKey(ctx, serverURL)
|
|
if err != nil {
|
|
return fmt.Errorf("discovering server key: %w", err)
|
|
}
|
|
}
|
|
|
|
nf := tsp.NodeFile{
|
|
NodeKey: nodeKey,
|
|
MachineKey: machineKey,
|
|
ServerInfo: tsp.ServerInfo{URL: serverURL, Key: serverKey},
|
|
}
|
|
|
|
out, err := json.MarshalIndent(nf, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding node file: %w", err)
|
|
}
|
|
out = append(out, '\n')
|
|
return writeOutput(newNodeArgs.output, out)
|
|
}
|
|
|
|
var registerArgs struct {
|
|
nodeFile string
|
|
output string
|
|
hostname string
|
|
ephemeral bool
|
|
authKey string
|
|
tags string
|
|
}
|
|
|
|
var registerCmd = &ffcli.Command{
|
|
Name: "register",
|
|
ShortUsage: "tsp [-s url] register -n <node-file> [flags]",
|
|
ShortHelp: "Register a node key with a coordination server.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("register", flag.ExitOnError)
|
|
fs.StringVar(®isterArgs.nodeFile, "n", "", "node JSON file (required)")
|
|
fs.StringVar(®isterArgs.output, "o", "", "output file (default: stdout)")
|
|
fs.StringVar(®isterArgs.hostname, "hostname", "", "hostname to register")
|
|
fs.BoolVar(®isterArgs.ephemeral, "ephemeral", false, "register as ephemeral node")
|
|
fs.StringVar(®isterArgs.authKey, "auth-key", "", "pre-authorized auth key or file containing one")
|
|
fs.StringVar(®isterArgs.tags, "tags", "", "comma-separated ACL tags")
|
|
return fs
|
|
})(),
|
|
Exec: runRegister,
|
|
}
|
|
|
|
func runRegister(ctx context.Context, args []string) error {
|
|
if registerArgs.nodeFile == "" {
|
|
return fmt.Errorf("flag -n (node file) is required")
|
|
}
|
|
|
|
nf, err := tsp.ReadNodeFile(registerArgs.nodeFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading node file: %w", err)
|
|
}
|
|
|
|
hi := hostinfo.New()
|
|
if registerArgs.hostname != "" {
|
|
hi.Hostname = registerArgs.hostname
|
|
}
|
|
|
|
var tags []string
|
|
if registerArgs.tags != "" {
|
|
tags = strings.Split(registerArgs.tags, ",")
|
|
}
|
|
|
|
authKey, err := resolveAuthKey(registerArgs.authKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := tsp.NewClient(tsp.ClientOpts{
|
|
ServerURL: cmp.Or(globalArgs.serverURL, nf.URL),
|
|
MachineKey: nf.MachineKey,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("creating client: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if globalArgs.controlKeyFile != "" {
|
|
controlKey, err := readControlKeyFile(globalArgs.controlKeyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading control key: %w", err)
|
|
}
|
|
client.SetControlPublicKey(controlKey)
|
|
} else {
|
|
client.SetControlPublicKey(nf.ServerInfo.Key)
|
|
}
|
|
|
|
resp, err := client.Register(ctx, tsp.RegisterOpts{
|
|
NodeKey: nf.NodeKey,
|
|
Hostinfo: hi,
|
|
Ephemeral: registerArgs.ephemeral,
|
|
AuthKey: authKey,
|
|
Tags: tags,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out, err := json.MarshalIndent(resp, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding response: %w", err)
|
|
}
|
|
out = append(out, '\n')
|
|
|
|
if err := writeOutput(registerArgs.output, out); err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.AuthURL != "" {
|
|
fmt.Fprintf(os.Stderr, "AuthURL: %s\n", resp.AuthURL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var mapArgs struct {
|
|
nodeFile string
|
|
stream bool
|
|
peers bool
|
|
quiet bool
|
|
output string
|
|
}
|
|
|
|
var mapCmd = &ffcli.Command{
|
|
Name: "map",
|
|
ShortUsage: "tsp [-s url] map -n <node-file> [-stream]",
|
|
ShortHelp: "Send a map request to the coordination server.",
|
|
FlagSet: (func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("map", flag.ExitOnError)
|
|
fs.StringVar(&mapArgs.nodeFile, "n", "", "node JSON file (required)")
|
|
fs.BoolVar(&mapArgs.stream, "stream", false, "stream map responses")
|
|
fs.BoolVar(&mapArgs.peers, "peers", true, "include peers in map response")
|
|
fs.BoolVar(&mapArgs.quiet, "quiet", true, "suppress keepalives and handled c2n ping requests from output")
|
|
fs.StringVar(&mapArgs.output, "o", "", "output file (default: stdout)")
|
|
return fs
|
|
})(),
|
|
Exec: runMap,
|
|
}
|
|
|
|
func runMap(ctx context.Context, args []string) error {
|
|
if mapArgs.nodeFile == "" {
|
|
return fmt.Errorf("flag -n (node file) is required")
|
|
}
|
|
|
|
nf, err := tsp.ReadNodeFile(mapArgs.nodeFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading node file: %w", err)
|
|
}
|
|
|
|
if globalArgs.serverURL != "" && globalArgs.serverURL != nf.URL {
|
|
return fmt.Errorf("server URL mismatch: -s flag is %q but node file is for %q", globalArgs.serverURL, nf.URL)
|
|
}
|
|
|
|
hi := hostinfo.New()
|
|
|
|
client, err := tsp.NewClient(tsp.ClientOpts{
|
|
ServerURL: cmp.Or(globalArgs.serverURL, nf.URL),
|
|
MachineKey: nf.MachineKey,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("creating client: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if globalArgs.controlKeyFile != "" {
|
|
controlKey, err := readControlKeyFile(globalArgs.controlKeyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading control key: %w", err)
|
|
}
|
|
client.SetControlPublicKey(controlKey)
|
|
} else {
|
|
client.SetControlPublicKey(nf.ServerInfo.Key)
|
|
}
|
|
|
|
session, err := client.Map(ctx, tsp.MapOpts{
|
|
NodeKey: nf.NodeKey,
|
|
Hostinfo: hi,
|
|
Stream: mapArgs.stream,
|
|
OmitPeers: !mapArgs.peers,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer session.Close()
|
|
|
|
gotResponse := false
|
|
for {
|
|
resp, err := session.Next()
|
|
if err == io.EOF {
|
|
if !gotResponse {
|
|
return fmt.Errorf("server returned no map response")
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("reading map response: %w", err)
|
|
}
|
|
gotResponse = true
|
|
|
|
if pr := resp.PingRequest; pr != nil && pr.Types == "c2n" {
|
|
if client.AnswerC2NPing(ctx, pr, session.NoiseRoundTrip) && mapArgs.quiet {
|
|
resp.PingRequest = nil
|
|
}
|
|
}
|
|
if mapArgs.quiet {
|
|
resp.KeepAlive = false
|
|
}
|
|
|
|
if isZeroMapResponse(resp) {
|
|
continue
|
|
}
|
|
|
|
out, err := json.MarshalIndent(resp, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding response: %w", err)
|
|
}
|
|
out = append(out, '\n')
|
|
if err := writeOutput(mapArgs.output, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// readMachineKeyFile reads a machine private key from a file.
|
|
func readMachineKeyFile(path string) (key.MachinePrivate, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return key.MachinePrivate{}, err
|
|
}
|
|
var k key.MachinePrivate
|
|
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
|
|
return key.MachinePrivate{}, fmt.Errorf("parsing machine key from %q: %w", path, err)
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
// readNodeKeyFile reads a node private key from a file.
|
|
func readNodeKeyFile(path string) (key.NodePrivate, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return key.NodePrivate{}, err
|
|
}
|
|
var k key.NodePrivate
|
|
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
|
|
return key.NodePrivate{}, fmt.Errorf("parsing node key from %q: %w", path, err)
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
// readControlKeyFile reads a file containing a server's MachinePublic key
|
|
// in its MarshalText form (e.g. "mkey:...").
|
|
func readControlKeyFile(path string) (key.MachinePublic, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return key.MachinePublic{}, err
|
|
}
|
|
var k key.MachinePublic
|
|
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
|
|
return key.MachinePublic{}, fmt.Errorf("parsing control key from %q: %w", path, err)
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
// resolveAuthKey returns the auth key from v. If v is empty, it returns "".
|
|
// If v starts with "tskey-", it's used directly. Otherwise v is treated as a
|
|
// filename and its contents are read and trimmed.
|
|
func resolveAuthKey(v string) (string, error) {
|
|
if v == "" {
|
|
return "", nil
|
|
}
|
|
if strings.HasPrefix(strings.TrimSpace(v), "tskey-") {
|
|
return strings.TrimSpace(v), nil
|
|
}
|
|
data, err := os.ReadFile(v)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading auth key file: %w", err)
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|
|
|
|
func writeOutput(path string, data []byte) error {
|
|
if path == "" {
|
|
_, err := os.Stdout.Write(data)
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
// isZeroMapResponse reports whether all fields of resp are zero values.
|
|
func isZeroMapResponse(resp *tailcfg.MapResponse) bool {
|
|
v := reflect.ValueOf(*resp)
|
|
for i := range v.NumField() {
|
|
if !v.Field(i).IsZero() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|