tailscale/control/tsp/nodefile.go
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

106 lines
3.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsp
import (
"encoding/json"
"fmt"
"os"
"tailscale.com/types/key"
)
// ServerInfo identifies a coordination server by its URL and Noise public key.
type ServerInfo struct {
// URL is the base URL of the coordination server, without any path
// (e.g. "https://controlplane.tailscale.com").
//
// There is no default value; a URL must always be supplied.
URL string `json:"server_url"`
// Key is the server's Noise public key, used to establish an encrypted
// channel between the client and the coordination server.
Key key.MachinePublic `json:"server_key"`
}
// NodeFile is the JSON structure for a node credentials file. It contains
// the private keys that authenticate a node to a coordination server.
//
// Example:
//
// {
// "node_key": "privkey:...",
// "machine_key": "privkey:...",
// "server_url": "https://controlplane.tailscale.com",
// "server_key": "mkey:..."
// }
//
// Note that node and machine private keys share the same "privkey:"
// textual form; they are disambiguated by the surrounding JSON field
// names rather than by any prefix in the key itself.
type NodeFile struct {
// NodeKey is the node's WireGuard private key. The corresponding
// public key identifies this node to other peers.
NodeKey key.NodePrivate `json:"node_key"`
// MachineKey is the machine's private key. It authenticates this
// machine to the coordination server over Noise.
MachineKey key.MachinePrivate `json:"machine_key"`
ServerInfo // server_url and server_key
}
// ReadNodeFile reads and parses a node JSON file.
func ReadNodeFile(path string) (NodeFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return NodeFile{}, err
}
var nf NodeFile
if err := json.Unmarshal(data, &nf); err != nil {
return NodeFile{}, fmt.Errorf("parsing node file %q: %w", path, err)
}
return nf, nil
}
// WriteNodeFile writes a node JSON file. The file is created with mode 0600.
func WriteNodeFile(path string, nf NodeFile) error {
if err := nf.Check(); err != nil {
return fmt.Errorf("invalid NodeFile: %w", err)
}
return os.WriteFile(path, nf.AsJSON(), 0600)
}
// AsJSON returns nf as a pretty-printed JSON object, terminated by a newline.
//
// It always succeeds and always returns a valid JSON object. It does not
// validate that the fields of nf are non-zero; it is the caller's
// responsibility to call [NodeFile.Check] first if they want to reject
// incomplete NodeFiles.
func (nf NodeFile) AsJSON() []byte {
out, err := json.MarshalIndent(nf, "", " ")
if err != nil {
panic(fmt.Sprintf("NodeFile.AsJSON: %v", err)) // unreachable: all fields marshal successfully
}
return append(out, '\n')
}
// Check reports whether nf has all required fields set.
// It returns an error describing the first zero-valued field, if any.
func (nf NodeFile) Check() error {
if nf.NodeKey.IsZero() {
return fmt.Errorf("node_key is missing")
}
if nf.MachineKey.IsZero() {
return fmt.Errorf("machine_key is missing")
}
if nf.URL == "" {
return fmt.Errorf("server_url is missing")
}
if nf.ServerInfo.Key.IsZero() {
return fmt.Errorf("server_key is missing")
}
return nil
}