Files
teleport/tool/tctl/common/edit_command.go
T
Brian Joerger d14cd04a7c UX improvements for tctl with Admin MFA (#46891)
* Prompt admin mfa once for creating multiple resources.

* Prompt admin mfa before tctl edit.

* Disable bulk create test for auth preference.

* Get reusable MFA for listing tokens or cert authorities.

* Don't prompt for mfa twice when editing cert authority.

* Address comment.

* Fix getting multiple tokens; add test for getting multiple, not all, tokens or cert authorities; clean up mfa logic for get.
2025-02-05 23:56:35 +00:00

255 lines
7.2 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"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
kyaml "k8s.io/apimachinery/pkg/util/yaml"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/mfa"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
)
// EditCommand implements the `tctl edit` command for modifying
// Teleport resources.
type EditCommand struct {
app *kingpin.Application
cmd *kingpin.CmdClause
config *servicecfg.Config
ref services.Ref
confirm bool
// Editor is used by tests to inject the editing mechanism
// so that different scenarios can be asserted.
Editor func(filename string) error
}
func (e *EditCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
e.app = app
e.config = config
e.cmd = app.Command("edit", "Edit a Teleport resource.")
e.cmd.Arg("resource type/resource name", `Resource to update
<resource type> Type of a resource [for example: rc]
<resource name> Resource name to update
Example:
$ tctl edit rc/remote`).SetValue(&e.ref)
e.cmd.Flag("confirm", "Confirm an unsafe or temporary resource update").Hidden().BoolVar(&e.confirm)
}
func (e *EditCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (bool, error) {
if cmd != e.cmd.FullCommand() {
return false, nil
}
client, closeFn, err := clientFunc(ctx)
if err != nil {
return false, trace.Wrap(err)
}
defer closeFn(ctx)
err = e.editResource(ctx, client)
return true, trace.Wrap(err)
}
func (e *EditCommand) runEditor(ctx context.Context, name string) error {
if e.Editor != nil {
return trace.Wrap(e.Editor(name))
}
textEditor := getTextEditor()
args := strings.Fields(textEditor)
editorCmd := exec.CommandContext(ctx, args[0], append(args[1:], name)...)
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Start(); err != nil {
return trace.BadParameter("could not start editor %v: %v", textEditor, err)
}
if err := editorCmd.Wait(); err != nil {
return trace.BadParameter("skipping resource update, editor did not complete successfully: %v", err)
}
return nil
}
func (e *EditCommand) editResource(ctx context.Context, client *authclient.Client) error {
f, err := os.CreateTemp("", "teleport-resource*.yaml")
if err != nil {
return trace.Wrap(err)
}
defer func() {
if err := os.Remove(f.Name()); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: could not remove temporary file %v\n", f.Name())
}
}()
rc := &ResourceCommand{
refs: services.Refs{e.ref},
format: teleport.YAML,
stdout: f,
filename: f.Name(),
force: true,
withSecrets: true,
confirm: e.confirm,
}
rc.Initialize(e.app, nil, e.config)
// Prompt for admin action MFA if required, before getting any
// resources with secrets and before the update/editor below.
if mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/); err == nil {
ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse)
} else if !errors.Is(err, &mfa.ErrMFANotRequired) && !errors.Is(err, &mfa.ErrMFANotSupported) {
return trace.Wrap(err)
}
err = rc.Get(ctx, client)
if closeErr := f.Close(); closeErr != nil {
return trace.Wrap(err)
}
if err != nil {
return trace.Wrap(err, "could not get resource %v", rc.ref.String())
}
originalSum, err := checksum(f.Name())
if err != nil {
return trace.Wrap(err)
}
originalName, err := resourceName(f.Name())
if err != nil {
return trace.Wrap(err)
}
if err := e.runEditor(ctx, f.Name()); err != nil {
return trace.Wrap(err)
}
newSum, err := checksum(f.Name())
if err != nil {
return trace.Wrap(err)
}
// nothing to do if the resource was not modified
if newSum == originalSum {
fmt.Println("edit canceled, no changes made")
return nil
}
newName, err := resourceName(f.Name())
if err != nil {
return trace.Wrap(err)
}
if originalName != newName {
return trace.NotImplemented("renaming resources is not supported with tctl edit")
}
f, err = utils.OpenFileAllowingUnsafeLinks(rc.filename)
if err != nil {
return trace.Wrap(err)
}
defer f.Close()
decoder := kyaml.NewYAMLOrJSONDecoder(f, defaults.LookaheadBufSize)
var raw services.UnknownResource
if err := decoder.Decode(&raw); err != nil {
if errors.Is(err, io.EOF) {
return trace.BadParameter("no resources found, empty input?")
}
return trace.Wrap(err)
}
// Use the UpdateHandler if the resource has one, otherwise fallback to using
// the CreateHandler. UpdateHandlers are preferred over CreateHandler because an update
// will not forcibly overwrite a resource unlike with create which requires the force
// flag to be set to update an existing resource.
updator, found := rc.UpdateHandlers[ResourceKind(raw.Kind)]
if found {
return trace.Wrap(updator(ctx, client, raw))
}
// TODO(tross) remove the fallback to CreateHandlers once all the resources
// have been updated to implement an UpdateHandler.
if creator, found := rc.CreateHandlers[ResourceKind(raw.Kind)]; found {
return trace.Wrap(creator(ctx, client, raw))
}
return trace.BadParameter("updating resources of type %q is not supported", raw.Kind)
}
// getTextEditor returns the text editor to be used for editing the resource.
func getTextEditor() string {
for _, v := range []string{"TELEPORT_EDITOR", "VISUAL", "EDITOR"} {
if value := os.Getenv(v); value != "" {
return value
}
}
return "vi"
}
func checksum(filename string) (string, error) {
f, err := utils.OpenFileAllowingUnsafeLinks(filename)
if err != nil {
return "", trace.Wrap(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", trace.Wrap(err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func resourceName(filename string) (string, error) {
f, err := utils.OpenFileAllowingUnsafeLinks(filename)
if err != nil {
return "", trace.Wrap(err)
}
defer f.Close()
decoder := kyaml.NewYAMLOrJSONDecoder(f, defaults.LookaheadBufSize)
var raw services.UnknownResource
if err := decoder.Decode(&raw); err != nil {
return "", trace.Wrap(err)
}
return raw.GetName(), nil
}