diff --git a/tool/tctl/common/admin_action_test.go b/tool/tctl/common/admin_action_test.go index 51ffb7cb893f1..e999a312b36d2 100644 --- a/tool/tctl/common/admin_action_test.go +++ b/tool/tctl/common/admin_action_test.go @@ -174,6 +174,12 @@ func (s *adminActionTestSuite) testBots(t *testing.T) { setup: createBot, cleanup: deleteBot, }, + "tctl bots instance add": { + command: fmt.Sprintf("bots instance add %v", botName), + cliCommand: &tctl.BotsCommand{}, + setup: createBot, + cleanup: deleteBot, + }, } { t.Run(name, func(t *testing.T) { s.testCommand(t, ctx, tc) diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 34f06be59d11a..1cca70353894d 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -19,10 +19,12 @@ package common import ( + "cmp" "context" "encoding/json" "errors" "fmt" + "io" "maps" "os" "strings" @@ -55,26 +57,33 @@ type BotsCommand struct { lockExpires string lockTTL time.Duration - botName string - botRoles string - tokenID string - tokenTTL time.Duration - addRoles string + botName string + botRoles string + tokenID string + tokenTTL time.Duration + addRoles string + instanceID string allowedLogins []string addLogins string setLogins string - botsList *kingpin.CmdClause - botsAdd *kingpin.CmdClause - botsRemove *kingpin.CmdClause - botsLock *kingpin.CmdClause - botsUpdate *kingpin.CmdClause + botsList *kingpin.CmdClause + botsAdd *kingpin.CmdClause + botsRemove *kingpin.CmdClause + botsLock *kingpin.CmdClause + botsUpdate *kingpin.CmdClause + botsInstances *kingpin.CmdClause + botsInstancesShow *kingpin.CmdClause + botsInstancesList *kingpin.CmdClause + botsInstancesAdd *kingpin.CmdClause + + stdout io.Writer } // Initialize sets up the "tctl bots" command. func (c *BotsCommand) Initialize(app *kingpin.Application, config *servicecfg.Config) { - bots := app.Command("bots", "Operate on certificate renewal bots registered with the cluster.") + bots := app.Command("bots", "Manage Machine ID bots on the cluster.").Alias("bot") c.botsList = bots.Command("ls", "List all certificate renewal bots registered with the cluster.") c.botsList.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) @@ -102,6 +111,23 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, config *servicecfg.Co c.botsUpdate.Flag("add-roles", "Adds a comma-separated list of roles to an existing bot.").StringVar(&c.addRoles) c.botsUpdate.Flag("set-logins", "Sets the bot's logins to the given comma-separated list, replacing any existing logins.").StringVar(&c.setLogins) c.botsUpdate.Flag("add-logins", "Adds a comma-separated list of logins to an existing bot.").StringVar(&c.addLogins) + + c.botsInstances = bots.Command("instances", "Manage bot instances.").Alias("instance") + + c.botsInstancesShow = c.botsInstances.Command("show", "Shows information about a specific bot instance").Alias("get").Alias("describe") + c.botsInstancesShow.Arg("id", "The full ID of the bot instance, in the form of [bot name]/[uuid]").Required().StringVar(&c.instanceID) + + c.botsInstancesList = c.botsInstances.Command("list", "List bot instances").Alias("ls") + c.botsInstancesList.Arg("name", "The name of the bot from which to list instances. If unset, lists instances from all bots.").StringVar(&c.botName) + + c.botsInstancesAdd = c.botsInstances.Command("add", "Join a new instance onto an existing bot.").Alias("join") + c.botsInstancesAdd.Arg("name", "The name of the existing bot for which to add a new instance.").Required().StringVar(&c.botName) + c.botsInstancesAdd.Flag("token", "The token to use, if any. If unset, a new one-time-use token will be created.").StringVar(&c.tokenID) + c.botsInstancesAdd.Flag("format", "Output format, one of: text, json").Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) + + if c.stdout == nil { + c.stdout = os.Stdout + } } // TryRun attempts to run subcommands. @@ -117,6 +143,12 @@ func (c *BotsCommand) TryRun(ctx context.Context, cmd string, client *authclient err = c.LockBot(ctx, client) case c.botsUpdate.FullCommand(): err = c.UpdateBot(ctx, client) + case c.botsInstancesShow.FullCommand(): + err = c.ShowBotInstance(ctx, client) + case c.botsInstancesList.FullCommand(): + err = c.ListBotInstances(ctx, client) + case c.botsInstancesAdd.FullCommand(): + err = c.AddBotInstance(ctx, client) default: return false, nil } @@ -144,7 +176,7 @@ func (c *BotsCommand) ListBots(ctx context.Context, client *authclient.Client) e if c.format == teleport.Text { if len(bots) == 0 { - fmt.Println("No bots found") + fmt.Fprintln(c.stdout, "No bots found") return nil } t := asciitable.MakeTable([]string{"Bot", "User", "Roles"}) @@ -153,9 +185,11 @@ func (c *BotsCommand) ListBots(ctx context.Context, client *authclient.Client) e u.Metadata.Name, u.Status.UserName, strings.Join(u.Spec.GetRoles(), ","), }) } - fmt.Println(t.AsBuffer().String()) + fmt.Fprintln(c.stdout, t.AsBuffer().String()) + + fmt.Fprintf(c.stdout, "\nTo view active instances of a bot, run:\n\n> %s bots instances list [name]\n", os.Args[0]) } else { - err := utils.WriteJSONArray(os.Stdout, bots) + err := utils.WriteJSONArray(c.stdout, bots) if err != nil { return trace.Wrap(err, "failed to marshal bots") } @@ -199,7 +233,10 @@ Please note: - /var/lib/teleport/bot must be accessible to the bot user, or --data-dir must point to another accessible directory to store internal bot data. - This invitation token will expire in {{.minutes}} minutes - - {{.addr}} must be reachable from the new node + - {{.addr}} must be reachable from the new node{{if eq .join_method "token"}} + - This is a single-token that will be consumed upon usage. For scalable + alternatives, see our documentation on other supported join methods: + https://goteleport.com/docs/enroll-resources/machine-id/deployment/{{end}} `)) // AddBot adds a new certificate renewal bot to the cluster. @@ -281,60 +318,7 @@ func (c *BotsCommand) AddBot(ctx context.Context, client *authclient.Client) err return trace.Wrap(err) } - if c.format == teleport.JSON { - tokenTTL := time.Duration(0) - if exp := token.Expiry(); !exp.IsZero() { - tokenTTL = time.Until(exp) - } - // This struct is equivalent to a legacy bit of JSON we used to output - // when we called an older RPC. We've preserved it here to avoid - // breaking customer scripts. - response := struct { - UserName string `json:"user_name"` - RoleName string `json:"role_name"` - TokenID string `json:"token_id"` - TokenTTL time.Duration `json:"token_ttl"` - }{ - UserName: bot.Status.UserName, - RoleName: bot.Status.RoleName, - TokenID: token.GetName(), - TokenTTL: tokenTTL, - } - out, err := json.MarshalIndent(response, "", " ") - if err != nil { - return trace.Wrap(err, "failed to marshal CreateBot response") - } - - fmt.Println(string(out)) - return nil - } - - proxies, err := client.GetProxies() - if err != nil { - return trace.Wrap(err) - } - if len(proxies) == 0 { - return trace.Errorf("bot was created but this cluster does not have any proxy servers running so unable to display success message") - } - addr := proxies[0].GetPublicAddr() - if addr == "" { - addr = proxies[0].GetAddr() - } - - joinMethod := token.GetJoinMethod() - if joinMethod == types.JoinMethodUnspecified { - joinMethod = types.JoinMethodToken - } - - templateData := map[string]interface{}{ - "token": token.GetName(), - "addr": addr, - "join_method": joinMethod, - } - if !token.Expiry().IsZero() { - templateData["minutes"] = int(time.Until(token.Expiry()).Minutes()) - } - return startMessageTemplate.Execute(os.Stdout, templateData) + return trace.Wrap(outputToken(c.stdout, c.format, client, bot, token)) } func (c *BotsCommand) RemoveBot(ctx context.Context, client *authclient.Client) error { @@ -345,7 +329,7 @@ func (c *BotsCommand) RemoveBot(ctx context.Context, client *authclient.Client) return trace.Wrap(err) } - fmt.Printf("Bot %q deleted successfully.\n", c.botName) + fmt.Fprintf(c.stdout, "Bot %q deleted successfully.\n", c.botName) return nil } @@ -386,7 +370,7 @@ func (c *BotsCommand) LockBot(ctx context.Context, client *authclient.Client) er return trace.Wrap(err) } - fmt.Printf("Created a lock with name %q.\n", lock.GetName()) + fmt.Fprintf(c.stdout, "Created a lock with name %q.\n", lock.GetName()) return nil } @@ -546,6 +530,292 @@ func (c *BotsCommand) UpdateBot(ctx context.Context, client *authclient.Client) return nil } +// ListBotInstances lists bot instances, possibly filtering for a specific bot +func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.Client) error { + var instances []*machineidv1pb.BotInstance + req := &machineidv1pb.ListBotInstancesRequest{} + + if c.botName != "" { + req.FilterBotName = c.botName + } + + for { + resp, err := client.BotInstanceServiceClient().ListBotInstances(ctx, req) + if err != nil { + return trace.Wrap(err) + } + + instances = append(instances, resp.BotInstances...) + if resp.NextPageToken == "" { + break + } + req.PageToken = resp.NextPageToken + } + + if c.format == teleport.JSON { + err := utils.WriteJSONArray(c.stdout, instances) + if err != nil { + return trace.Wrap(err, "failed to marshal bot instances") + } + + return nil + } + + if len(instances) == 0 { + if c.botName == "" { + fmt.Fprintln(c.stdout, "No bot instances found.") + } else { + fmt.Fprintf(c.stdout, "No bot instances found with name %q.\n", c.botName) + } + return nil + } + + t := asciitable.MakeTable([]string{"ID", "Join Method", "Hostname", "Joined", "Last Seen", "Generation"}) + for _, i := range instances { + var ( + joinMethod string + hostname string + generation string + ) + + joined := i.Status.InitialAuthentication.AuthenticatedAt.AsTime().Format(time.RFC3339) + initialJoinMethod := i.Status.InitialAuthentication.JoinMethod + + lastSeen := i.Status.InitialAuthentication.AuthenticatedAt.AsTime() + + if len(i.Status.LatestAuthentications) == 0 { + generation = "n/a" + } else { + auth := i.Status.LatestAuthentications[len(i.Status.LatestAuthentications)-1] + + generation = fmt.Sprint(auth.Generation) + + if auth.JoinMethod == initialJoinMethod { + joinMethod = auth.JoinMethod + } else { + // If the join method changed, show the original method and latest + joinMethod = fmt.Sprintf("%s (%s)", auth.JoinMethod, initialJoinMethod) + } + + if auth.AuthenticatedAt.AsTime().After(lastSeen) { + lastSeen = auth.AuthenticatedAt.AsTime() + } + } + + if len(i.Status.LatestHeartbeats) == 0 { + hostname = "n/a" + } else { + hb := i.Status.LatestHeartbeats[len(i.Status.LatestHeartbeats)-1] + + hostname = hb.Hostname + + if hb.RecordedAt.AsTime().After(lastSeen) { + lastSeen = hb.RecordedAt.AsTime() + } + } + + t.AddRow([]string{ + fmt.Sprintf("%s/%s", i.Spec.BotName, i.Spec.InstanceId), joinMethod, + hostname, joined, lastSeen.Format(time.RFC3339), generation, + }) + } + fmt.Fprintln(c.stdout, t.AsBuffer().String()) + + fmt.Fprintf(c.stdout, "\nTo view more information on a particular instance, run:\n\n> %s bots instances show [id]\n", os.Args[0]) + + if c.botName != "" { + fmt.Fprintf(c.stdout, "\nTo onboard a new instance for this bot, run:\n\n> %s bots instances add %s\n", os.Args[0], c.botName) + } + + return nil +} + +// AddBotInstance begins onboarding a new instance of an existing bot. +func (c *BotsCommand) AddBotInstance(ctx context.Context, client *authclient.Client) error { + // A bit of a misnomer but makes the terminology a bit more consistent. This + // doesn't directly create a bot instance, but creates token that allows a + // bot to join, which creates a new instance. + + bot, err := client.BotServiceClient().GetBot(ctx, &machineidv1pb.GetBotRequest{ + BotName: c.botName, + }) + if err != nil { + return trace.Wrap(err) + } + + var token types.ProvisionToken + + if c.tokenID == "" { + // If there's no token specified, generate one + tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return trace.Wrap(err) + } + ttl := cmp.Or(c.tokenTTL, defaults.DefaultBotJoinTTL) + tokenSpec := types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + JoinMethod: types.JoinMethodToken, + BotName: c.botName, + } + token, err = types.NewProvisionTokenFromSpec(tokenName, time.Now().Add(ttl), tokenSpec) + if err != nil { + return trace.Wrap(err) + } + if err := client.UpsertToken(ctx, token); err != nil { + return trace.Wrap(err) + } + + return trace.Wrap(outputToken(c.stdout, c.format, client, bot, token)) + } + + // There's not much to do in this case, but we can validate the token. + // The bot and token should already exist in this case, so we'll just + // print joining instructions. + + // If there is, check the token matches the potential bot + token, err = client.GetToken(ctx, c.tokenID) + if err != nil { + if trace.IsNotFound(err) { + return trace.NotFound("token with name %q not found, create the token or do not set TokenName: %v", + c.tokenID, err) + } + return trace.Wrap(err) + } + if !token.GetRoles().Include(types.RoleBot) { + return trace.BadParameter("token %q is not valid for role %q", + c.tokenID, types.RoleBot) + } + if token.GetBotName() != c.botName { + return trace.BadParameter("token %q is valid for bot with name %q, not %q", + c.tokenID, token.GetBotName(), c.botName) + } + + return trace.Wrap(outputToken(c.stdout, c.format, client, bot, token)) +} + +var showMessageTemplate = template.Must(template.New("show").Funcs(template.FuncMap{ + "bold": bold, +}).Parse(`Bot: {{.instance.Spec.BotName}} +ID: {{.instance.Spec.InstanceId}} + +Initial Authentication: {{.initial_authentication_table}} + +Latest Authentication: {{.latest_authentication_table}} + +Latest Heartbeat: {{.heartbeat_table}} + +To view a full, machine-readable record including past heartbeats and +authentication records, run: + +> {{.executable}} get bot_instance/{{.instance.Spec.BotName}}/{{.instance.Spec.InstanceId}} + +To onboard a new instance for this bot, run: + +> {{.executable}} bots instances add {{.instance.Spec.BotName}} +`)) + +func (c *BotsCommand) ShowBotInstance(ctx context.Context, client *authclient.Client) error { + botName, instanceID, err := parseInstanceID(c.instanceID) + if err != nil { + return trace.Wrap(err) + } + + instance, err := client.BotInstanceServiceClient().GetBotInstance(ctx, &machineidv1pb.GetBotInstanceRequest{ + BotName: botName, + InstanceId: instanceID, + }) + if err != nil { + return trace.Wrap(err) + } + + initialAuthenticationTable := formatBotInstanceAuthentication(instance.Status.InitialAuthentication) + + var latestAuthenticationTable string + if len(instance.Status.LatestAuthentications) > 0 { + latest := instance.Status.LatestAuthentications[len(instance.Status.LatestAuthentications)-1] + latestAuthenticationTable = formatBotInstanceAuthentication(latest) + } else { + latestAuthenticationTable = "No authentication records." + } + + var heartbeatTable string + if len(instance.Status.LatestHeartbeats) > 0 { + latest := instance.Status.LatestHeartbeats[len(instance.Status.LatestHeartbeats)-1] + heartbeatTable = formatBotInstanceHeartbeat(latest) + } else { + heartbeatTable = "No heartbeat records." + } + + templateData := map[string]interface{}{ + "executable": os.Args[0], + "instance": instance, + "initial_authentication_table": initialAuthenticationTable, + "latest_authentication_table": latestAuthenticationTable, + "heartbeat_table": heartbeatTable, + } + + return trace.Wrap(showMessageTemplate.Execute(os.Stdout, templateData)) +} + +// botJSONResponse is a response generated by the `tctl bots add` family of +// commands when the format is `json` +type botJSONResponse struct { + UserName string `json:"user_name"` + RoleName string `json:"role_name"` + TokenID string `json:"token_id"` + TokenTTL time.Duration `json:"token_ttl"` +} + +// outputToken writes token information to stdout, depending on the token format. +func outputToken(wr io.Writer, format string, client *authclient.Client, bot *machineidv1pb.Bot, token types.ProvisionToken) error { + if format == teleport.JSON { + tokenTTL := time.Duration(0) + if exp := token.Expiry(); !exp.IsZero() { + tokenTTL = time.Until(exp) + } + // This struct is equivalent to a legacy bit of JSON we used to output + // when we called an older RPC. We've preserved it here to avoid + // breaking customer scripts. + response := botJSONResponse{ + UserName: bot.Status.UserName, + RoleName: bot.Status.RoleName, + TokenID: token.GetName(), + TokenTTL: tokenTTL, + } + out, err := json.MarshalIndent(response, "", " ") + if err != nil { + return trace.Wrap(err, "failed to marshal CreateBot response") + } + + fmt.Fprintln(wr, string(out)) + return nil + } + + proxies, err := client.GetProxies() + if err != nil { + return trace.Wrap(err) + } + if len(proxies) == 0 { + return trace.Errorf("bot was created but this cluster does not have any proxy servers running so unable to display success message") + } + addr := cmp.Or(proxies[0].GetPublicAddr(), proxies[0].GetAddr()) + + joinMethod := token.GetJoinMethod() + if joinMethod == types.JoinMethodUnspecified { + joinMethod = types.JoinMethodToken + } + + templateData := map[string]interface{}{ + "token": token.GetName(), + "addr": addr, + "join_method": joinMethod, + } + if !token.Expiry().IsZero() { + templateData["minutes"] = int(time.Until(token.Expiry()).Minutes()) + } + return startMessageTemplate.Execute(wr, templateData) +} + // splitEntries splits a comma separated string into an array of entries, // ignoring empty or whitespace-only elements. func splitEntries(flag string) []string { @@ -559,3 +829,61 @@ func splitEntries(flag string) []string { } return roles } + +// formatBotInstanceAuthentication returns a multiline, indented string showing +// a textual representation of a bot authentication record. +func formatBotInstanceAuthentication(record *machineidv1pb.BotInstanceStatusAuthentication) string { + table := asciitable.MakeHeadlessTable(2) + table.AddRow([]string{"Authenticated At:", record.AuthenticatedAt.AsTime().Format(time.RFC3339)}) + table.AddRow([]string{"Join Method:", record.JoinMethod}) + table.AddRow([]string{"Join Token:", record.JoinToken}) + table.AddRow([]string{"Join Metadata:", record.Metadata.String()}) + table.AddRow([]string{"Generation:", fmt.Sprint(record.Generation)}) + table.AddRow([]string{"Public Key:", fmt.Sprintf("<%d bytes>", len(record.PublicKey))}) + + return "\n" + indentString(table.AsBuffer().String(), " ") +} + +// formatBotInstanceHeartbeat returns a multiline, indented string containing +// a textual representation of a bot heartbeat. +func formatBotInstanceHeartbeat(record *machineidv1pb.BotInstanceStatusHeartbeat) string { + table := asciitable.MakeHeadlessTable(2) + table.AddRow([]string{"Recorded At:", record.RecordedAt.AsTime().Format(time.RFC3339)}) + table.AddRow([]string{"Is Startup:", fmt.Sprint(record.IsStartup)}) + table.AddRow([]string{"Version:", record.Version}) + table.AddRow([]string{"Hostname:", record.Hostname}) + table.AddRow([]string{"Uptime:", record.Uptime.AsDuration().String()}) + table.AddRow([]string{"Join Method:", record.JoinMethod}) + table.AddRow([]string{"One Shot:", fmt.Sprint(record.OneShot)}) + table.AddRow([]string{"Architecture:", record.Architecture}) + table.AddRow([]string{"OS:", record.Os}) + + return "\n" + indentString(table.AsBuffer().String(), " ") +} + +// parseInstanceID converts an instance ID string in the form of +// '[bot name]/[uuid]' to separate bot name and UUID strings. +func parseInstanceID(s string) (name string, uuid string, err error) { + name, uuid, ok := strings.Cut(s, "/") + if !ok { + return "", "", trace.BadParameter("invalid bot instance syntax, must be: [bot name]/[uuid]") + } + + return +} + +// indentString prefixes each line (ending with \n) with the provided prefix. +func indentString(s string, indent string) string { + buf := strings.Builder{} + splits := strings.SplitAfter(s, "\n") + + for _, line := range splits { + if line == "" { + continue + } + + fmt.Fprintf(&buf, "%s%s", indent, line) + } + + return buf.String() +} diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index af32d0c261360..5d31e84e80961 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -20,17 +20,23 @@ package common import ( "context" + "encoding/json" "slices" + "strings" "testing" "github.com/gravitational/trace" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/fieldmaskpb" + "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/constants" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/lib/config" + "github.com/gravitational/teleport/tool/teleport/testenv" ) func TestUpdateBotLogins(t *testing.T) { @@ -226,3 +232,65 @@ func TestUpdateBotRoles(t *testing.T) { }) } } + +func TestAddAndListBotInstancesJSON(t *testing.T) { + dynAddr := helpers.NewDynamicServiceAddr(t) + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: dynAddr.AuthAddr, + }, + }, + } + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors)) + ctx := context.Background() + client := testenv.MakeDefaultAuthClient(t, process) + + tokens, err := client.GetTokens(ctx) + require.NoError(t, err) + require.Empty(t, tokens) + + // Create an initial bot + bot, err := client.BotServiceClient().CreateBot(ctx, &machineidv1pb.CreateBotRequest{ + Bot: &machineidv1pb.Bot{ + Metadata: &headerv1.Metadata{ + Name: "test", + }, + Spec: &machineidv1pb.BotSpec{}, + }, + }) + require.NoError(t, err) + + // Attempt to add a new instance and ensure a new token was created. + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + botName: bot.Metadata.Name, + } + require.NoError(t, cmd.AddBotInstance(ctx, client)) + + response := botJSONResponse{} + require.NoError(t, json.Unmarshal([]byte(buf.String()), &response)) + + _, err = client.GetToken(ctx, response.TokenID) + require.NoError(t, err) + + // Run the command again to ensure multiple distinct tokens can be created. + buf.Reset() + require.NoError(t, cmd.AddBotInstance(ctx, client)) + + response2 := botJSONResponse{} + require.NoError(t, json.Unmarshal([]byte(buf.String()), &response2)) + + require.NotEqual(t, response.TokenID, response2.TokenID) + + _, err = client.GetToken(ctx, response2.TokenID) + require.NoError(t, err) + + buf.Reset() +}