Files
teleport/tool/tctl/common/node_command.go
T

280 lines
8.9 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package common
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"text/template"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/api/client/proto"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/authclient"
libclient "github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
)
// NodeCommand implements `tctl nodes` group of commands
type NodeCommand struct {
config *servicecfg.Config
// format is the output format, e.g. text or json
format string
// list of roles for the new node to assume
roles string
// TTL: duration of time during which a generated node token will
// be valid.
ttl time.Duration
// namespace is node namespace
namespace string
// token is an optional custom token supplied by client,
// if not specified, is autogenerated
token string
searchKeywords string
predicateExpr string
labels string
// ls output format -- text or json
lsFormat string
// verbose sets whether full table output should be shown for labels
verbose bool
// CLI subcommands (clauses)
nodeAdd *kingpin.CmdClause
nodeList *kingpin.CmdClause
}
// Initialize allows NodeCommand to plug itself into the CLI parser
func (c *NodeCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
c.config = config
// add node command
nodes := app.Command("nodes", "Issue invites for other nodes to join the cluster.")
c.nodeAdd = nodes.Command("add", "Generate a node invitation token.")
c.nodeAdd.Flag("roles", "Comma-separated list of roles for the new node to assume [node]").Default("node").StringVar(&c.roles)
c.nodeAdd.Flag("ttl", "Time to live for a generated token").Default(defaults.ProvisioningTokenTTL.String()).DurationVar(&c.ttl)
c.nodeAdd.Flag("token", "Override the default random generated token with a specified value").StringVar(&c.token)
c.nodeAdd.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).StringVar(&c.format)
c.nodeAdd.Alias(AddNodeHelp)
c.nodeList = nodes.Command("ls", "List all active SSH nodes within the cluster.")
c.nodeList.Flag("namespace", "Namespace of the nodes").Hidden().Default(apidefaults.Namespace).StringVar(&c.namespace)
c.nodeList.Flag("format", "Output format, 'text', or 'yaml'").Default(teleport.Text).StringVar(&c.lsFormat)
c.nodeList.Flag("verbose", "Verbose table output, shows full label output").Short('v').BoolVar(&c.verbose)
c.nodeList.Alias(ListNodesHelp)
c.nodeList.Arg("labels", labelHelp).StringVar(&c.labels)
c.nodeList.Flag("search", searchHelp).StringVar(&c.searchKeywords)
c.nodeList.Flag("query", queryHelp).StringVar(&c.predicateExpr)
}
// TryRun takes the CLI command as an argument (like "nodes ls") and executes it.
func (c *NodeCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) {
var commandFunc func(ctx context.Context, client *authclient.Client) error
switch cmd {
case c.nodeAdd.FullCommand():
commandFunc = c.Invite
case c.nodeList.FullCommand():
commandFunc = c.ListActive
default:
return false, nil
}
client, closeFn, err := clientFunc(ctx)
if err != nil {
return false, trace.Wrap(err)
}
err = commandFunc(ctx, client)
closeFn(ctx)
return true, trace.Wrap(err)
}
const trustedClusterMessage = `The cluster invite token: %v
This token will expire in %d minutes
Use this token when defining a trusted cluster resource on a remote cluster.
`
var nodeMessageTemplate = template.Must(template.New("node").Parse(`The invite token: {{.token}}
This token will expire in {{.minutes}} minutes.
Run this on the new node to join the cluster:
> teleport start \
--roles={{.roles}} \
--token={{.token}} \{{range .ca_pins}}
--ca-pin={{.}} \{{end}}
--auth-server={{.auth_server}}
Please note:
- This invitation token will expire in {{.minutes}} minutes
- {{.auth_server}} must be reachable from the new node
`))
// Invite generates a token which can be used to add another SSH node
// to a cluster
func (c *NodeCommand) Invite(ctx context.Context, client *authclient.Client) error {
// parse --roles flag
roles, err := types.ParseTeleportRoles(c.roles)
if err != nil {
return trace.Wrap(err)
}
token := c.token
if c.token == "" {
token, err = utils.CryptoRandomHex(defaults.TokenLenBytes)
if err != nil {
return trace.WrapWithMessage(err, "generating token value")
}
}
expires := time.Now().Add(c.ttl)
pt, err := types.NewProvisionToken(token, roles, expires)
if err != nil {
return trace.Wrap(err)
}
if err := client.CreateToken(ctx, pt); err != nil {
if trace.IsAlreadyExists(err) {
return trace.AlreadyExists(
"failed to create token (%q already exists), please use another name",
pt.GetName(),
)
}
return trace.Wrap(err, "creating token")
}
// Calculate the CA pins for this cluster. The CA pins are used by the
// client to verify the identity of the Auth Server.
localCAResponse, err := client.GetClusterCACert(ctx)
if err != nil {
return trace.Wrap(err)
}
caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA)
if err != nil {
return trace.Wrap(err)
}
authServers, err := client.GetAuthServers()
if err != nil {
return trace.Wrap(err)
}
if len(authServers) == 0 {
return trace.Errorf("This cluster does not have any auth servers running.")
}
// output format switch:
if c.format == teleport.Text {
if roles.Include(types.RoleTrustedCluster) {
fmt.Printf(trustedClusterMessage, token, int(c.ttl.Minutes()))
} else {
authServer := authServers[0].GetAddr()
pingResponse, err := client.Ping(ctx)
if err != nil {
slog.DebugContext(ctx, "unable to ping auth client", "error", err)
}
if err == nil && pingResponse.GetServerFeatures().Cloud {
proxies, err := client.GetProxies()
if err != nil {
return trace.Wrap(err)
}
if len(proxies) != 0 {
authServer = proxies[0].GetPublicAddr()
}
}
return nodeMessageTemplate.Execute(os.Stdout, map[string]interface{}{
"token": token,
"minutes": int(c.ttl.Minutes()),
"roles": strings.ToLower(roles.String()),
"ca_pins": caPins,
"auth_server": authServer,
})
}
} else {
// Always return a list, otherwise we'll break users tooling. See #1846 for
// more details.
tokens := []string{token}
out, err := json.Marshal(tokens)
if err != nil {
return trace.Wrap(err, "failed to marshal token")
}
fmt.Print(string(out))
}
return nil
}
// ListActive retrieves the list of nodes who recently sent heartbeats to
// to a cluster and prints it to stdout
func (c *NodeCommand) ListActive(ctx context.Context, clt *authclient.Client) error {
labels, err := libclient.ParseLabelSpec(c.labels)
if err != nil {
return trace.Wrap(err)
}
nodes, err := client.GetAllResources[types.Server](ctx, clt, &proto.ListResourcesRequest{
ResourceType: types.KindNode,
Namespace: c.namespace,
Labels: labels,
PredicateExpression: c.predicateExpr,
SearchKeywords: libclient.ParseSearchKeywords(c.searchKeywords, ','),
})
if err != nil {
if utils.IsPredicateError(err) {
return trace.Wrap(utils.PredicateError{Err: err})
}
return trace.Wrap(err)
}
coll := &serverCollection{servers: nodes}
switch c.lsFormat {
case teleport.Text:
if err := coll.writeText(os.Stdout, c.verbose); err != nil {
return trace.Wrap(err)
}
case teleport.YAML:
if err := coll.writeYAML(os.Stdout); err != nil {
return trace.Wrap(err)
}
case teleport.JSON:
if err := coll.writeJSON(os.Stdout); err != nil {
return trace.Wrap(err)
}
default:
return trace.Errorf("Invalid format %s, only text, json and yaml are supported", c.lsFormat)
}
return nil
}