You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/cmd/tailscale/cli/network-lock.go

561 lines
16 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strconv"
"strings"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/types/key"
)
var netlockCmd = &ffcli.Command{
Name: "lock",
ShortUsage: "lock <sub-command> <arguments>",
ShortHelp: "Manage tailnet lock",
LongHelp: "Manage tailnet lock",
Subcommands: []*ffcli.Command{
nlInitCmd,
nlStatusCmd,
nlAddCmd,
nlRemoveCmd,
nlSignCmd,
nlDisableCmd,
nlDisablementKDFCmd,
nlLogCmd,
nlLocalDisableCmd,
},
Exec: runNetworkLockStatus,
}
var nlInitArgs struct {
numDisablements int
disablementForSupport bool
confirm bool
}
var nlInitCmd = &ffcli.Command{
Name: "init",
ShortUsage: "init [--gen-disablement-for-support] --gen-disablements N <trusted-key>...",
ShortHelp: "Initialize tailnet lock",
LongHelp: strings.TrimSpace(`
The 'tailscale lock init' command initializes tailnet lock for the
entire tailnet. The tailnet lock keys specified are those initially
trusted to sign nodes or to make further changes to tailnet lock.
You can identify the tailnet lock key for a node you wish to trust by
running 'tailscale lock' on that node, and copying the node's tailnet
lock key.
To disable tailnet lock, use the 'tailscale lock disable' command
along with one of the disablement secrets.
The number of disablement secrets to be generated is specified using the
--gen-disablements flag. Initializing tailnet lock requires at least
one disablement.
If --gen-disablement-for-support is specified, an additional disablement secret
will be generated and transmitted to Tailscale, which support can use to disable
tailnet lock. We recommend setting this flag.
`),
Exec: runNetworkLockInit,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock init")
fs.IntVar(&nlInitArgs.numDisablements, "gen-disablements", 1, "number of disablement secrets to generate")
fs.BoolVar(&nlInitArgs.disablementForSupport, "gen-disablement-for-support", false, "generates and transmits a disablement secret for Tailscale support")
fs.BoolVar(&nlInitArgs.confirm, "confirm", false, "do not prompt for confirmation")
return fs
})(),
}
func runNetworkLockInit(ctx context.Context, args []string) error {
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if st.Enabled {
return errors.New("tailnet lock is already enabled")
}
// Parse initially-trusted keys & disablement values.
keys, disablementValues, err := parseNLArgs(args, true, true)
if err != nil {
return err
}
// Common mistake: Not specifying the current node's key as one of the trusted keys.
foundSelfKey := false
for _, k := range keys {
keyID, err := k.ID()
if err != nil {
return err
}
if bytes.Equal(keyID, st.PublicKey.KeyID()) {
foundSelfKey = true
break
}
}
if !foundSelfKey {
return errors.New("the tailnet lock key of the current node must be one of the trusted keys during initialization")
}
fmt.Println("You are initializing tailnet lock with the following trusted signing keys:")
for _, k := range keys {
fmt.Printf(" - tlpub:%x (%s key)\n", k.Public, k.Kind.String())
}
fmt.Println()
if !nlInitArgs.confirm {
fmt.Printf("%d disablement secrets will be generated.\n", nlInitArgs.numDisablements)
if nlInitArgs.disablementForSupport {
fmt.Println("A disablement secret will be generated and transmitted to Tailscale support.")
}
genSupportFlag := ""
if nlInitArgs.disablementForSupport {
genSupportFlag = "--gen-disablement-for-support "
}
fmt.Println("\nIf this is correct, please re-run this command with the --confirm flag:")
fmt.Printf("\t%s lock init --confirm --gen-disablements %d %s%s", os.Args[0], nlInitArgs.numDisablements, genSupportFlag, strings.Join(args, " "))
fmt.Println()
return nil
}
fmt.Printf("%d disablement secrets have been generated and are printed below. Take note of them now, they WILL NOT be shown again.\n", nlInitArgs.numDisablements)
for i := 0; i < nlInitArgs.numDisablements; i++ {
var secret [32]byte
if _, err := rand.Read(secret[:]); err != nil {
return err
}
fmt.Printf("\tdisablement-secret:%X\n", secret[:])
disablementValues = append(disablementValues, tka.DisablementKDF(secret[:]))
}
var supportDisablement []byte
if nlInitArgs.disablementForSupport {
supportDisablement = make([]byte, 32)
if _, err := rand.Read(supportDisablement); err != nil {
return err
}
disablementValues = append(disablementValues, tka.DisablementKDF(supportDisablement))
fmt.Println("A disablement secret for Tailscale support has been generated and will be transmitted to Tailscale upon initialization.")
}
// The state returned by NetworkLockInit likely doesn't contain the initialized state,
// because that has to tick through from netmaps.
if _, err := localClient.NetworkLockInit(ctx, keys, disablementValues, supportDisablement); err != nil {
return err
}
fmt.Println("Initialization complete.")
return nil
}
var nlStatusArgs struct {
json bool
}
var nlStatusCmd = &ffcli.Command{
Name: "status",
ShortUsage: "status",
ShortHelp: "Outputs the state of tailnet lock",
LongHelp: "Outputs the state of tailnet lock",
Exec: runNetworkLockStatus,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock status")
fs.BoolVar(&nlStatusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
return fs
})(),
}
func runNetworkLockStatus(ctx context.Context, args []string) error {
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if nlStatusArgs.json {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(st)
}
if st.Enabled {
fmt.Println("Tailnet lock is ENABLED.")
} else {
fmt.Println("Tailnet lock is NOT enabled.")
}
fmt.Println()
if st.Enabled && st.NodeKey != nil && !st.PublicKey.IsZero() {
if st.NodeKeySigned {
fmt.Println("This node is accessible under tailnet lock.")
} else {
fmt.Println("This node is LOCKED OUT by tailnet-lock, and action is required to establish connectivity.")
fmt.Printf("Run the following command on a node with a trusted key:\n\ttailscale lock sign %v %s\n", st.NodeKey, st.PublicKey.CLIString())
}
fmt.Println()
}
if !st.PublicKey.IsZero() {
fmt.Printf("This node's tailnet-lock key: %s\n", st.PublicKey.CLIString())
fmt.Println()
}
if st.Enabled && len(st.TrustedKeys) > 0 {
fmt.Println("Trusted signing keys:")
for _, k := range st.TrustedKeys {
var line strings.Builder
line.WriteString("\t")
line.WriteString(k.Key.CLIString())
line.WriteString("\t")
line.WriteString(fmt.Sprint(k.Votes))
line.WriteString("\t")
if k.Key == st.PublicKey {
line.WriteString("(self)")
}
fmt.Println(line.String())
}
}
if st.Enabled && len(st.FilteredPeers) > 0 {
fmt.Println()
fmt.Println("The following nodes are locked out by tailnet lock and cannot connect to other nodes:")
for _, p := range st.FilteredPeers {
var line strings.Builder
line.WriteString("\t")
line.WriteString(p.Name)
line.WriteString("\t")
for i, addr := range p.TailscaleIPs {
line.WriteString(addr.String())
if i < len(p.TailscaleIPs)-1 {
line.WriteString(", ")
}
}
line.WriteString("\t")
line.WriteString(string(p.StableID))
fmt.Println(line.String())
}
}
return nil
}
var nlAddCmd = &ffcli.Command{
Name: "add",
ShortUsage: "add <public-key>...",
ShortHelp: "Adds one or more trusted signing keys to tailnet lock",
LongHelp: "Adds one or more trusted signing keys to tailnet lock",
Exec: func(ctx context.Context, args []string) error {
return runNetworkLockModify(ctx, args, nil)
},
}
var nlRemoveCmd = &ffcli.Command{
Name: "remove",
ShortUsage: "remove <public-key>...",
ShortHelp: "Removes one or more trusted signing keys from tailnet lock",
LongHelp: "Removes one or more trusted signing keys from tailnet lock",
Exec: func(ctx context.Context, args []string) error {
return runNetworkLockModify(ctx, nil, args)
},
}
// parseNLArgs parses a slice of strings into slices of tka.Key & disablement
// values/secrets.
// The keys encoded in args should be specified using their key.NLPublic.MarshalText
// representation with an optional '?<votes>' suffix.
// Disablement values or secrets must be encoded in hex with a prefix of 'disablement:' or
// 'disablement-secret:'.
//
// If any element could not be parsed,
// a nil slice is returned along with an appropriate error.
func parseNLArgs(args []string, parseKeys, parseDisablements bool) (keys []tka.Key, disablements [][]byte, err error) {
for i, a := range args {
if parseDisablements && (strings.HasPrefix(a, "disablement:") || strings.HasPrefix(a, "disablement-secret:")) {
b, err := hex.DecodeString(a[strings.Index(a, ":")+1:])
if err != nil {
return nil, nil, fmt.Errorf("parsing disablement %d: %v", i+1, err)
}
disablements = append(disablements, b)
continue
}
if !parseKeys {
return nil, nil, fmt.Errorf("parsing argument %d: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", i+1, a)
}
var nlpk key.NLPublic
spl := strings.SplitN(a, "?", 2)
if err := nlpk.UnmarshalText([]byte(spl[0])); err != nil {
return nil, nil, fmt.Errorf("parsing key %d: %v", i+1, err)
}
k := tka.Key{
Kind: tka.Key25519,
Public: nlpk.Verifier(),
Votes: 1,
}
if len(spl) > 1 {
votes, err := strconv.Atoi(spl[1])
if err != nil {
return nil, nil, fmt.Errorf("parsing key %d votes: %v", i+1, err)
}
k.Votes = uint(votes)
}
keys = append(keys, k)
}
return keys, disablements, nil
}
func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) error {
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if !st.Enabled {
return errors.New("tailnet lock is not enabled")
}
addKeys, _, err := parseNLArgs(addArgs, true, false)
if err != nil {
return err
}
removeKeys, _, err := parseNLArgs(removeArgs, true, false)
if err != nil {
return err
}
if err := localClient.NetworkLockModify(ctx, addKeys, removeKeys); err != nil {
return err
}
return nil
}
var nlSignCmd = &ffcli.Command{
Name: "sign",
ShortUsage: "sign <node-key> [<rotation-key>]",
ShortHelp: "Signs a node key and transmits the signature to the coordination server",
LongHelp: "Signs a node key and transmits the signature to the coordination server",
Exec: runNetworkLockSign,
}
func runNetworkLockSign(ctx context.Context, args []string) error {
var (
nodeKey key.NodePublic
rotationKey key.NLPublic
)
if len(args) == 0 || len(args) > 2 {
return errors.New("usage: lock sign <node-key> [<rotation-key>]")
}
if err := nodeKey.UnmarshalText([]byte(args[0])); err != nil {
return fmt.Errorf("decoding node-key: %w", err)
}
if len(args) > 1 {
if err := rotationKey.UnmarshalText([]byte(args[1])); err != nil {
return fmt.Errorf("decoding rotation-key: %w", err)
}
}
return localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
}
var nlDisableCmd = &ffcli.Command{
Name: "disable",
ShortUsage: "disable <disablement-secret>",
ShortHelp: "Consumes a disablement secret to shut down tailnet lock for the tailnet",
LongHelp: strings.TrimSpace(`
The 'tailscale lock disable' command uses the specified disablement
secret to disable tailnet lock.
If tailnet lock is re-enabled, new disablement secrets can be generated.
Once this secret is used, it has been distributed
to all nodes in the tailnet and should be considered public.
`),
Exec: runNetworkLockDisable,
}
func runNetworkLockDisable(ctx context.Context, args []string) error {
_, secrets, err := parseNLArgs(args, false, true)
if err != nil {
return err
}
if len(secrets) != 1 {
return errors.New("usage: lock disable <disablement-secret>")
}
return localClient.NetworkLockDisable(ctx, secrets[0])
}
var nlLocalDisableCmd = &ffcli.Command{
Name: "local-disable",
ShortUsage: "local-disable",
ShortHelp: "Disables tailnet lock for this node only",
LongHelp: strings.TrimSpace(`
The 'tailscale lock local-disable' command disables tailnet lock for only
the current node.
If the current node is locked out, this does not mean that it can initiate
connections in a tailnet with tailnet lock enabled. Rather, this means
that the current node will accept traffic from other nodes in the tailnet
that are locked out.
`),
Exec: runNetworkLockLocalDisable,
}
func runNetworkLockLocalDisable(ctx context.Context, args []string) error {
return localClient.NetworkLockForceLocalDisable(ctx)
}
var nlDisablementKDFCmd = &ffcli.Command{
Name: "disablement-kdf",
ShortUsage: "disablement-kdf <hex-encoded-disablement-secret>",
ShortHelp: "Computes a disablement value from a disablement secret (advanced users only)",
LongHelp: "Computes a disablement value from a disablement secret (advanced users only)",
Exec: runNetworkLockDisablementKDF,
}
func runNetworkLockDisablementKDF(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("usage: lock disablement-kdf <hex-encoded-disablement-secret>")
}
secret, err := hex.DecodeString(args[0])
if err != nil {
return err
}
fmt.Printf("disablement:%x\n", tka.DisablementKDF(secret))
return nil
}
var nlLogArgs struct {
limit int
json bool
}
var nlLogCmd = &ffcli.Command{
Name: "log",
ShortUsage: "log [--limit N]",
ShortHelp: "List changes applied to tailnet lock",
LongHelp: "List changes applied to tailnet lock",
Exec: runNetworkLockLog,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock log")
fs.IntVar(&nlLogArgs.limit, "limit", 50, "max number of updates to list")
fs.BoolVar(&nlLogArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
return fs
})(),
}
func nlDescribeUpdate(update ipnstate.NetworkLockUpdate, color bool) (string, error) {
terminalYellow := ""
terminalClear := ""
if color {
terminalYellow = "\x1b[33m"
terminalClear = "\x1b[0m"
}
var stanza strings.Builder
printKey := func(key *tka.Key, prefix string) {
fmt.Fprintf(&stanza, "%sType: %s\n", prefix, key.Kind.String())
if keyID, err := key.ID(); err == nil {
fmt.Fprintf(&stanza, "%sKeyID: %x\n", prefix, keyID)
} else {
// Older versions of the client shouldn't explode when they encounter an
// unknown key type.
fmt.Fprintf(&stanza, "%sKeyID: <Error: %v>\n", prefix, err)
}
if key.Meta != nil {
fmt.Fprintf(&stanza, "%sMetadata: %+v\n", prefix, key.Meta)
}
}
var aum tka.AUM
if err := aum.Unserialize(update.Raw); err != nil {
return "", fmt.Errorf("decoding: %w", err)
}
fmt.Fprintf(&stanza, "%supdate %x (%s)%s\n", terminalYellow, update.Hash, update.Change, terminalClear)
switch update.Change {
case tka.AUMAddKey.String():
printKey(aum.Key, "")
case tka.AUMRemoveKey.String():
fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID)
case tka.AUMUpdateKey.String():
fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID)
if aum.Votes != nil {
fmt.Fprintf(&stanza, "Votes: %d\n", aum.Votes)
}
if aum.Meta != nil {
fmt.Fprintf(&stanza, "Metadata: %+v\n", aum.Meta)
}
case tka.AUMCheckpoint.String():
fmt.Fprintln(&stanza, "Disablement values:")
for _, v := range aum.State.DisablementSecrets {
fmt.Fprintf(&stanza, " - %x\n", v)
}
fmt.Fprintln(&stanza, "Keys:")
for _, k := range aum.State.Keys {
printKey(&k, " ")
}
default:
// Print a JSON encoding of the AUM as a fallback.
e := json.NewEncoder(&stanza)
e.SetIndent("", "\t")
if err := e.Encode(aum); err != nil {
return "", err
}
stanza.WriteRune('\n')
}
return stanza.String(), nil
}
func runNetworkLockLog(ctx context.Context, args []string) error {
updates, err := localClient.NetworkLockLog(ctx, nlLogArgs.limit)
if err != nil {
return fixTailscaledConnectError(err)
}
if nlLogArgs.json {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(updates)
}
useColor := isatty.IsTerminal(os.Stdout.Fd())
stdOut := colorable.NewColorableStdout()
for _, update := range updates {
stanza, err := nlDescribeUpdate(update, useColor)
if err != nil {
return err
}
fmt.Fprintln(stdOut, stanza)
}
return nil
}