mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 14:59:53 +08:00
* * Implement tsh --headless * Implement tsh headless approve * Add better headless authn state handling. * Add godoc for new tsh field.
4673 lines
159 KiB
Go
4673 lines
159 KiB
Go
/*
|
|
Copyright 2016-2021 Gravitational, Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"runtime/pprof"
|
|
runtimetrace "runtime/trace"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/ghodss/yaml"
|
|
"github.com/gravitational/kingpin"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/sirupsen/logrus"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/exp/slices"
|
|
"golang.org/x/sync/errgroup"
|
|
corev1 "k8s.io/api/core/v1"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/types"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/api/types/wrappers"
|
|
"github.com/gravitational/teleport/api/utils/keys"
|
|
"github.com/gravitational/teleport/lib/asciitable"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
|
|
"github.com/gravitational/teleport/lib/benchmark"
|
|
"github.com/gravitational/teleport/lib/client"
|
|
dbprofile "github.com/gravitational/teleport/lib/client/db"
|
|
"github.com/gravitational/teleport/lib/client/identityfile"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/kube/kubeconfig"
|
|
"github.com/gravitational/teleport/lib/modules"
|
|
"github.com/gravitational/teleport/lib/observability/tracing"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/session"
|
|
"github.com/gravitational/teleport/lib/sshutils/sftp"
|
|
"github.com/gravitational/teleport/lib/sshutils/x11"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
"github.com/gravitational/teleport/lib/utils/prompt"
|
|
"github.com/gravitational/teleport/tool/common"
|
|
)
|
|
|
|
var log = logrus.WithFields(logrus.Fields{
|
|
trace.Component: teleport.ComponentTSH,
|
|
})
|
|
|
|
const (
|
|
// mfaModeAuto automatically chooses the best MFA device(s), without any
|
|
// restrictions.
|
|
// Allows both Webauthn and OTP.
|
|
mfaModeAuto = "auto"
|
|
// mfaModeCrossPlatform utilizes only cross-platform devices, such as
|
|
// pluggable hardware keys.
|
|
// Implies Webauthn.
|
|
mfaModeCrossPlatform = "cross-platform"
|
|
// mfaModePlatform utilizes only platform devices, such as Touch ID.
|
|
// Implies Webauthn.
|
|
mfaModePlatform = "platform"
|
|
// mfaModeOTP utilizes only OTP devices.
|
|
mfaModeOTP = "otp"
|
|
|
|
hostnameOrIDPredicateTemplate = `resource.spec.hostname == "%[1]s" || resource.metadata.name == "%[1]s"`
|
|
)
|
|
|
|
// CLIConf stores command line arguments and flags:
|
|
type CLIConf struct {
|
|
// UserHost contains "[login]@hostname" argument to SSH command
|
|
UserHost string
|
|
// Commands to execute on a remote host
|
|
RemoteCommand []string
|
|
// DesiredRoles indicates one or more roles which should be requested.
|
|
DesiredRoles string
|
|
// RequestReason indicates the reason for an access request.
|
|
RequestReason string
|
|
// SuggestedReviewers is a list of suggested request reviewers.
|
|
SuggestedReviewers string
|
|
// NoWait can be used with an access request to exit without waiting for a request resolution.
|
|
NoWait bool
|
|
// RequestedResourceIDs is a list of resources to request access to.
|
|
RequestedResourceIDs []string
|
|
// RequestID is an access request ID
|
|
RequestID string
|
|
// RequestIDs is a list of access request IDs
|
|
RequestIDs []string
|
|
// ReviewReason indicates the reason for an access review.
|
|
ReviewReason string
|
|
// ReviewableRequests indicates that only requests which can be reviewed should
|
|
// be listed.
|
|
ReviewableRequests bool
|
|
// SuggestedRequests indicates that only requests which suggest the current user
|
|
// as a reviewer should be listed.
|
|
SuggestedRequests bool
|
|
// MyRequests indicates that only requests created by the current user
|
|
// should be listed.
|
|
MyRequests bool
|
|
// Approve/Deny indicates the desired review kind.
|
|
Approve, Deny bool
|
|
// ResourceKind is the resource kind to search for
|
|
ResourceKind string
|
|
// Username is the Teleport user's username (to login into proxies)
|
|
Username string
|
|
// ExplicitUsername is true if Username was initially set by the end-user
|
|
// (for example, using command-line flags).
|
|
ExplicitUsername bool
|
|
// Proxy keeps the hostname:port of the Teleport proxy to use
|
|
Proxy string
|
|
// TTL defines how long a session must be active (in minutes)
|
|
MinsToLive int32
|
|
// SSH Port on a remote SSH host
|
|
NodePort int32
|
|
// Login on a remote SSH host
|
|
NodeLogin string
|
|
// InsecureSkipVerify bypasses verification of HTTPS certificate when talking to web proxy
|
|
InsecureSkipVerify bool
|
|
// SessionID identifies the session tsh is operating on.
|
|
// For `tsh join`, it is the ID of the session to join.
|
|
// For `tsh play`, it is either the ID of the session to play,
|
|
// or the path to a local session file which has already been
|
|
// downloaded.
|
|
SessionID string
|
|
// Src:dest parameter for SCP
|
|
CopySpec []string
|
|
// -r flag for scp
|
|
RecursiveCopy bool
|
|
// -L flag for ssh. Local port forwarding like 'ssh -L 80:remote.host:80 -L 443:remote.host:443'
|
|
LocalForwardPorts []string
|
|
// DynamicForwardedPorts is port forwarding using SOCKS5. It is similar to
|
|
// "ssh -D 8080 example.com".
|
|
DynamicForwardedPorts []string
|
|
// ForwardAgent agent to target node. Equivalent of -A for OpenSSH.
|
|
ForwardAgent bool
|
|
// ProxyJump is an optional -J flag pointing to the list of jumphosts,
|
|
// it is an equivalent of --proxy flag in tsh interpretation
|
|
ProxyJump string
|
|
// --local flag for ssh
|
|
LocalExec bool
|
|
// SiteName specifies remote site go login to
|
|
SiteName string
|
|
// KubernetesCluster specifies the kubernetes cluster to login to.
|
|
KubernetesCluster string
|
|
// DaemonAddr is the daemon listening address.
|
|
DaemonAddr string
|
|
// DaemonCertsDir is the directory containing certs used to create secure gRPC connection with daemon service
|
|
DaemonCertsDir string
|
|
// DaemonPrehogAddr is the URL where prehog events should be submitted.
|
|
DaemonPrehogAddr string
|
|
// DaemonPid is the PID to be stopped
|
|
DaemonPid int
|
|
// DatabaseService specifies the database proxy server to log into.
|
|
DatabaseService string
|
|
// DatabaseUser specifies database user to embed in the certificate.
|
|
DatabaseUser string
|
|
// DatabaseName specifies database name to embed in the certificate.
|
|
DatabaseName string
|
|
// AppName specifies proxied application name.
|
|
AppName string
|
|
// Interactive, when set to true, launches remote command with the terminal attached
|
|
Interactive bool
|
|
// Quiet mode, -q command (disables progress printing)
|
|
Quiet bool
|
|
// Namespace is used to select cluster namespace
|
|
Namespace string
|
|
// NoCache is used to turn off client cache for nodes discovery
|
|
NoCache bool
|
|
// BenchDuration is a duration for the benchmark
|
|
BenchDuration time.Duration
|
|
// BenchRate is a requests per second rate to maintain
|
|
BenchRate int
|
|
// BenchInteractive indicates that we should create interactive session
|
|
BenchInteractive bool
|
|
// BenchExport exports the latency profile
|
|
BenchExport bool
|
|
// BenchExportPath saves the latency profile in provided path
|
|
BenchExportPath string
|
|
// BenchTicks ticks per half distance
|
|
BenchTicks int32
|
|
// BenchValueScale value at which to scale the values recorded
|
|
BenchValueScale float64
|
|
// Context is a context to control execution
|
|
Context context.Context
|
|
// IdentityFileIn is an argument to -i flag (path to the private key+cert file)
|
|
IdentityFileIn string
|
|
// Compatibility flags, --compat, specifies OpenSSH compatibility flags.
|
|
Compatibility string
|
|
// CertificateFormat defines the format of the user SSH certificate.
|
|
CertificateFormat string
|
|
// IdentityFileOut is an argument to -out flag
|
|
IdentityFileOut string
|
|
// IdentityFormat (used for --format flag for 'tsh login') defines which
|
|
// format to use with --out to store a freshly retrieved certificate
|
|
IdentityFormat identityfile.Format
|
|
// IdentityOverwrite when true will overwrite any existing identity file at
|
|
// IdentityFileOut. When false, user will be prompted before overwriting
|
|
// any files.
|
|
IdentityOverwrite bool
|
|
|
|
// BindAddr is an address in the form of host:port to bind to
|
|
// during `tsh login` command
|
|
BindAddr string
|
|
|
|
// AuthConnector is the name of the connector to use.
|
|
AuthConnector string
|
|
|
|
// MFAMode is the preferred mode for MFA/Passwordless assertions.
|
|
MFAMode string
|
|
|
|
// SkipVersionCheck skips version checking for client and server
|
|
SkipVersionCheck bool
|
|
|
|
// Options is a list of OpenSSH options in the format used in the
|
|
// configuration file.
|
|
Options []string
|
|
|
|
// Verbose is used to print extra output.
|
|
Verbose bool
|
|
|
|
// Format is used to change the format of output
|
|
Format string
|
|
|
|
// SearchKeywords is a list of search keywords to match against resource field values.
|
|
SearchKeywords string
|
|
|
|
// PredicateExpression defines boolean conditions that will be matched against the resource.
|
|
PredicateExpression string
|
|
|
|
// NoRemoteExec will not execute a remote command after connecting to a host,
|
|
// will block instead. Useful when port forwarding. Equivalent of -N for OpenSSH.
|
|
NoRemoteExec bool
|
|
|
|
// X11ForwardingUntrusted will set up untrusted X11 forwarding for the session ('ssh -X')
|
|
X11ForwardingUntrusted bool
|
|
|
|
// X11Forwarding will set up trusted X11 forwarding for the session ('ssh -Y')
|
|
X11ForwardingTrusted bool
|
|
|
|
// X11ForwardingTimeout can optionally set to set a timeout for untrusted X11 forwarding.
|
|
X11ForwardingTimeout time.Duration
|
|
|
|
// Debug sends debug logs to stdout.
|
|
Debug bool
|
|
|
|
// Browser can be used to pass the name of a browser to override the system default
|
|
// (not currently implemented), or set to 'none' to suppress browser opening entirely.
|
|
Browser string
|
|
|
|
// UseLocalSSHAgent set to false will prevent this client from attempting to
|
|
// connect to the local ssh-agent (or similar) socket at $SSH_AUTH_SOCK.
|
|
//
|
|
// Deprecated in favor of `AddKeysToAgent`.
|
|
UseLocalSSHAgent bool
|
|
|
|
// AddKeysToAgent specifies the behavior of how certs are handled.
|
|
AddKeysToAgent string
|
|
|
|
// EnableEscapeSequences will scan stdin for SSH escape sequences during
|
|
// command/shell execution. This also requires stdin to be an interactive
|
|
// terminal.
|
|
EnableEscapeSequences bool
|
|
|
|
// PreserveAttrs preserves access/modification times from the original file.
|
|
PreserveAttrs bool
|
|
|
|
// RequestTTL is the expiration time of the Access Request (how long it
|
|
// will await approval).
|
|
RequestTTL time.Duration
|
|
|
|
// SessionTTL is the expiration time for the elevated certificate that will
|
|
// be issued if the Access Request is approved.
|
|
SessionTTL time.Duration
|
|
|
|
// executablePath is the absolute path to the current executable.
|
|
executablePath string
|
|
|
|
// unsetEnvironment unsets Teleport related environment variables.
|
|
unsetEnvironment bool
|
|
|
|
// overrideStdout allows to switch standard output source for resource command. Used in tests.
|
|
overrideStdout io.Writer
|
|
// overrideStderr allows to switch standard error source for resource command. Used in tests.
|
|
overrideStderr io.Writer
|
|
// overrideStdin allows to switch standard in source for resource command. Used in tests.
|
|
overrideStdin io.Reader
|
|
|
|
// mockSSOLogin used in tests to override sso login handler in teleport client.
|
|
mockSSOLogin client.SSOLoginFunc
|
|
|
|
// HomePath is where tsh stores profiles
|
|
HomePath string
|
|
|
|
// GlobalTshConfigPath is a path to global TSH config. Can be overridden with TELEPORT_GLOBAL_TSH_CONFIG.
|
|
GlobalTshConfigPath string
|
|
|
|
// LocalProxyPort is a port used by local proxy listener.
|
|
LocalProxyPort string
|
|
// LocalProxyCertFile is the client certificate used by local proxy.
|
|
// DEPRECATED DELETE IN 14.0
|
|
LocalProxyCertFile string
|
|
// LocalProxyKeyFile is the client key used by local proxy.
|
|
// DEPRECATED DELETE IN 14.0
|
|
LocalProxyKeyFile string
|
|
// LocalProxyTunnel specifies whether local proxy will open auth'd tunnel.
|
|
LocalProxyTunnel bool
|
|
|
|
// Exec is the command to run via tsh aws.
|
|
Exec string
|
|
// AWSRole is Amazon Role ARN or role name that will be used for AWS CLI access.
|
|
AWSRole string
|
|
// AWSCommandArgs contains arguments that will be forwarded to AWS CLI binary.
|
|
AWSCommandArgs []string
|
|
// AWSEndpointURLMode is an AWS proxy mode that serves an AWS endpoint URL
|
|
// proxy instead of an HTTPS proxy.
|
|
AWSEndpointURLMode bool
|
|
|
|
// AzureIdentity is Azure identity that will be used for Azure CLI access.
|
|
AzureIdentity string
|
|
// AzureCommandArgs contains arguments that will be forwarded to Azure CLI binary.
|
|
AzureCommandArgs []string
|
|
|
|
// GCPServiceAccount is GCP service account name that will be used for GCP CLI access.
|
|
GCPServiceAccount string
|
|
// GCPCommandArgs contains arguments that will be forwarded to GCP CLI binary.
|
|
GCPCommandArgs []string
|
|
|
|
// Reason is the reason for starting an ssh or kube session.
|
|
Reason string
|
|
|
|
// Invited is a list of invited users to an ssh or kube session.
|
|
Invited []string
|
|
|
|
// JoinMode is the participant mode someone is joining a session as.
|
|
JoinMode string
|
|
|
|
// displayParticipantRequirements is set if verbose participant requirement information should be printed for moderated sessions.
|
|
displayParticipantRequirements bool
|
|
|
|
// TshConfig is the loaded tsh configuration file ~/.tsh/config/config.yaml.
|
|
TshConfig TshConfig
|
|
|
|
// ListAll specifies if an ls command should return results from all clusters and proxies.
|
|
ListAll bool
|
|
|
|
// SampleTraces indicates whether traces should be sampled.
|
|
SampleTraces bool
|
|
|
|
// TraceExporter is a manually provided URI to send traces to instead of
|
|
// forwarding them to the Auth service.
|
|
TraceExporter string
|
|
|
|
// TracingProvider is the provider to use to create tracers, from which spans can be created.
|
|
TracingProvider oteltrace.TracerProvider
|
|
|
|
// disableAccessRequest disables automatic resource access requests.
|
|
disableAccessRequest bool
|
|
|
|
// FromUTC is the start time to use for the range of sessions listed by the session recordings listing command
|
|
FromUTC string
|
|
|
|
// ToUTC is the start time to use for the range of sessions listed by the session recordings listing command
|
|
ToUTC string
|
|
|
|
// maxRecordingsToShow is the maximum number of session recordings to show per page of results
|
|
maxRecordingsToShow int
|
|
|
|
// recordingsSince is a duration which sets the time into the past in which to list session recordings
|
|
recordingsSince string
|
|
|
|
// command is the selected command (and subcommands) parsed from command
|
|
// line args. Note that this command does not contain the binary (e.g. tsh).
|
|
command string
|
|
|
|
// cmdRunner is a custom function to execute provided exec.Cmd. Mainly used
|
|
// in testing.
|
|
cmdRunner func(*exec.Cmd) error
|
|
// kubernetesImpersonationConfig allows to configure custom kubernetes impersonation values.
|
|
kubernetesImpersonationConfig impersonationConfig
|
|
// kubeNamespace allows to configure the default Kubernetes namespace.
|
|
kubeNamespace string
|
|
|
|
// kubeAllNamespaces allows users to search for pods in every namespace.
|
|
kubeAllNamespaces bool
|
|
|
|
// kubeConfigPath is the location of the Kubeconfig for the current test.
|
|
// Setting this value allows Teleport tests to run `tsh login` commands in
|
|
// parallel.
|
|
// It shouldn't be used outside testing.
|
|
kubeConfigPath string
|
|
|
|
// Client only version display. Skips checking proxy version.
|
|
clientOnlyVersionCheck bool
|
|
|
|
// tracer is the tracer used to trace tsh commands.
|
|
tracer oteltrace.Tracer
|
|
|
|
// Headless uses headless login for the client session.
|
|
Headless bool
|
|
|
|
// HeadlessAuthenticationID is the ID of a headless authentication.
|
|
HeadlessAuthenticationID string
|
|
}
|
|
|
|
// Stdout returns the stdout writer.
|
|
func (c *CLIConf) Stdout() io.Writer {
|
|
if c.overrideStdout != nil {
|
|
return c.overrideStdout
|
|
}
|
|
return os.Stdout
|
|
}
|
|
|
|
// Stderr returns the stderr writer.
|
|
func (c *CLIConf) Stderr() io.Writer {
|
|
if c.overrideStderr != nil {
|
|
return c.overrideStderr
|
|
}
|
|
return os.Stderr
|
|
}
|
|
|
|
// Stdin returns the stdin reader.
|
|
func (c *CLIConf) Stdin() io.Reader {
|
|
if c.overrideStdin != nil {
|
|
return c.overrideStdin
|
|
}
|
|
return os.Stdin
|
|
}
|
|
|
|
// CommandWithBinary returns the current/selected command with the binary.
|
|
func (c *CLIConf) CommandWithBinary() string {
|
|
return fmt.Sprintf("%s %s", teleport.ComponentTSH, c.command)
|
|
}
|
|
|
|
// RunCommand executes provided command.
|
|
func (c *CLIConf) RunCommand(cmd *exec.Cmd) error {
|
|
if c.cmdRunner != nil {
|
|
return trace.Wrap(c.cmdRunner(cmd))
|
|
}
|
|
return trace.Wrap(cmd.Run())
|
|
}
|
|
|
|
func main() {
|
|
cmdLineOrig := os.Args[1:]
|
|
var cmdLine []string
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
|
defer cancel()
|
|
|
|
// lets see: if the executable name is 'ssh' or 'scp' we convert
|
|
// that to "tsh ssh" or "tsh scp"
|
|
switch path.Base(os.Args[0]) {
|
|
case "ssh":
|
|
cmdLine = append([]string{"ssh"}, cmdLineOrig...)
|
|
case "scp":
|
|
cmdLine = append([]string{"scp"}, cmdLineOrig...)
|
|
default:
|
|
cmdLine = cmdLineOrig
|
|
}
|
|
|
|
err := Run(ctx, cmdLine)
|
|
prompt.NotifyExit() // Allow prompt to restore terminal state on exit.
|
|
if err != nil {
|
|
var exitError *common.ExitCodeError
|
|
if errors.As(err, &exitError) {
|
|
os.Exit(exitError.Code)
|
|
}
|
|
utils.FatalError(err)
|
|
}
|
|
}
|
|
|
|
const (
|
|
authEnvVar = "TELEPORT_AUTH"
|
|
clusterEnvVar = "TELEPORT_CLUSTER"
|
|
kubeClusterEnvVar = "TELEPORT_KUBE_CLUSTER"
|
|
loginEnvVar = "TELEPORT_LOGIN"
|
|
bindAddrEnvVar = "TELEPORT_LOGIN_BIND_ADDR"
|
|
proxyEnvVar = "TELEPORT_PROXY"
|
|
headlessEnvVar = "TELEPORT_HEADLESS"
|
|
// TELEPORT_SITE uses the older deprecated "site" terminology to refer to a
|
|
// cluster. All new code should use TELEPORT_CLUSTER instead.
|
|
siteEnvVar = "TELEPORT_SITE"
|
|
userEnvVar = "TELEPORT_USER"
|
|
addKeysToAgentEnvVar = "TELEPORT_ADD_KEYS_TO_AGENT"
|
|
useLocalSSHAgentEnvVar = "TELEPORT_USE_LOCAL_SSH_AGENT"
|
|
globalTshConfigEnvVar = "TELEPORT_GLOBAL_TSH_CONFIG"
|
|
mfaModeEnvVar = "TELEPORT_MFA_MODE"
|
|
debugEnvVar = teleport.VerboseLogsEnvVar // "TELEPORT_DEBUG"
|
|
identityFileEnvVar = "TELEPORT_IDENTITY_FILE"
|
|
gcloudSecretEnvVar = "TELEPORT_GCLOUD_SECRET"
|
|
awsAccessKeyIDEnvVar = "TELEPORT_AWS_ACCESS_KEY_ID"
|
|
awsSecretAccessKeyEnvVar = "TELEPORT_AWS_SECRET_ACCESS_KEY"
|
|
awsRegionEnvVar = "TELEPORT_AWS_REGION"
|
|
awsKeystoreEnvVar = "TELEPORT_AWS_KEYSTORE"
|
|
awsWorkgroupEnvVar = "TELEPORT_AWS_WORKGROUP"
|
|
|
|
clusterHelp = "Specify the Teleport cluster to connect"
|
|
browserHelp = "Set to 'none' to suppress browser opening on login"
|
|
searchHelp = `List of comma separated search keywords or phrases enclosed in quotations (e.g. --search=foo,bar,"some phrase")`
|
|
queryHelp = `Query by predicate language enclosed in single quotes. Supports ==, !=, &&, and || (e.g. --query='labels["key1"] == "value1" && labels["key2"] != "value2"')`
|
|
labelHelp = "List of comma separated labels to filter by labels (e.g. key1=value1,key2=value2)"
|
|
// proxyDefaultResolutionTimeout is how long to wait for an unknown proxy
|
|
// port to be resolved.
|
|
//
|
|
// Originally based on the RFC-8305 "Maximum Connection Attempt Delay"
|
|
// recommended default value of 2s. In the RFC this value is for the
|
|
// establishment of a TCP connection, rather than the full HTTP round-
|
|
// trip that we measure against, so some tweaking may be needed.
|
|
//
|
|
// Raised to 5 seconds when fallback measure was removed to account for
|
|
// users with higher latency connections.
|
|
proxyDefaultResolutionTimeout = 5 * time.Second
|
|
)
|
|
|
|
// env vars that tsh status will check to provide hints about active env vars to a user.
|
|
var tshStatusEnvVars = [...]string{proxyEnvVar, clusterEnvVar, siteEnvVar, kubeClusterEnvVar, teleport.EnvKubeConfig}
|
|
|
|
// cliOption is used in tests to inject/override configuration within Run
|
|
type cliOption func(*CLIConf) error
|
|
|
|
// initLogger initializes the logger taking into account --debug and TELEPORT_DEBUG. If TELEPORT_DEBUG is set, it will also enable CLIConf.Debug.
|
|
func initLogger(cf *CLIConf) {
|
|
isDebug, _ := strconv.ParseBool(os.Getenv(debugEnvVar))
|
|
cf.Debug = cf.Debug || isDebug
|
|
if cf.Debug {
|
|
utils.InitLogger(utils.LoggingForCLI, logrus.DebugLevel)
|
|
} else {
|
|
utils.InitLogger(utils.LoggingForCLI, logrus.WarnLevel)
|
|
}
|
|
}
|
|
|
|
// Run executes TSH client. same as main() but easier to test
|
|
func Run(ctx context.Context, args []string, opts ...cliOption) error {
|
|
cf := CLIConf{
|
|
Context: ctx,
|
|
TracingProvider: tracing.NoopProvider(),
|
|
}
|
|
|
|
// run early to enable debug logging if env var is set.
|
|
// this makes it possible to debug early startup functionality, particularly command aliases.
|
|
initLogger(&cf)
|
|
|
|
moduleCfg := modules.GetModules()
|
|
var cpuProfile, memProfile, traceProfile string
|
|
|
|
// configure CLI argument parser:
|
|
app := utils.InitCLIParser("tsh", "Teleport Command Line Client").Interspersed(true)
|
|
|
|
app.Flag("login", "Remote host login").Short('l').Envar(loginEnvVar).StringVar(&cf.NodeLogin)
|
|
localUser, _ := client.Username()
|
|
app.Flag("proxy", "Teleport proxy address").Envar(proxyEnvVar).StringVar(&cf.Proxy)
|
|
app.Flag("nocache", "do not cache cluster discovery locally").Hidden().BoolVar(&cf.NoCache)
|
|
app.Flag("user", fmt.Sprintf("Teleport user [%s]", localUser)).Envar(userEnvVar).StringVar(&cf.Username)
|
|
app.Flag("mem-profile", "Write memory profile to file").Hidden().StringVar(&memProfile)
|
|
app.Flag("cpu-profile", "Write CPU profile to file").Hidden().StringVar(&cpuProfile)
|
|
app.Flag("trace-profile", "Write trace profile to file").Hidden().StringVar(&traceProfile)
|
|
app.Flag("option", "").Short('o').Hidden().AllowDuplicate().PreAction(func(ctx *kingpin.ParseContext) error {
|
|
return trace.BadParameter("invalid flag, perhaps you want to use this flag as tsh ssh -o?")
|
|
}).String()
|
|
|
|
app.Flag("ttl", "Minutes to live for a session").Int32Var(&cf.MinsToLive)
|
|
app.Flag("identity", "Identity file").Short('i').Envar(identityFileEnvVar).StringVar(&cf.IdentityFileIn)
|
|
app.Flag("compat", "OpenSSH compatibility flag").Hidden().StringVar(&cf.Compatibility)
|
|
app.Flag("cert-format", "SSH certificate format").StringVar(&cf.CertificateFormat)
|
|
app.Flag("trace", "Capture and export distributed traces").Hidden().BoolVar(&cf.SampleTraces)
|
|
app.Flag("trace-exporter", "An OTLP exporter URL to send spans to. Note - only tsh spans will be included.").Hidden().StringVar(&cf.TraceExporter)
|
|
|
|
if !moduleCfg.IsBoringBinary() {
|
|
// The user is *never* allowed to do this in FIPS mode.
|
|
app.Flag("insecure", "Do not verify server's certificate and host name. Use only in test environments").
|
|
Default("false").
|
|
BoolVar(&cf.InsecureSkipVerify)
|
|
}
|
|
|
|
app.Flag("auth", "Specify the name of authentication connector to use.").Envar(authEnvVar).StringVar(&cf.AuthConnector)
|
|
app.Flag("namespace", "Namespace of the cluster").Default(apidefaults.Namespace).Hidden().StringVar(&cf.Namespace)
|
|
app.Flag("skip-version-check", "Skip version checking between server and client.").BoolVar(&cf.SkipVersionCheck)
|
|
// we don't want to add `.Envar(debugEnvVar)` here:
|
|
// - we already process TELEPORT_DEBUG with initLogger(), so we don't need to do it second time
|
|
// - Kingpin is strict about syntax, so TELEPORT_DEBUG=rubbish will crash a program; we don't want such behavior for this variable.
|
|
app.Flag("debug", "Verbose logging to stdout").Short('d').BoolVar(&cf.Debug)
|
|
app.Flag("add-keys-to-agent", fmt.Sprintf("Controls how keys are handled. Valid values are %v.", client.AllAddKeysOptions)).Short('k').Envar(addKeysToAgentEnvVar).Default(client.AddKeysToAgentAuto).StringVar(&cf.AddKeysToAgent)
|
|
app.Flag("use-local-ssh-agent", "Deprecated in favor of the add-keys-to-agent flag.").
|
|
Hidden().
|
|
Envar(useLocalSSHAgentEnvVar).
|
|
Default("true").
|
|
BoolVar(&cf.UseLocalSSHAgent)
|
|
app.Flag("enable-escape-sequences", "Enable support for SSH escape sequences. Type '~?' during an SSH session to list supported sequences. Default is enabled.").
|
|
Default("true").
|
|
BoolVar(&cf.EnableEscapeSequences)
|
|
app.Flag("bind-addr", "Override host:port used when opening a browser for cluster logins").Envar(bindAddrEnvVar).StringVar(&cf.BindAddr)
|
|
modes := []string{mfaModeAuto, mfaModeCrossPlatform, mfaModePlatform, mfaModeOTP}
|
|
app.Flag("mfa-mode", fmt.Sprintf("Preferred mode for MFA and Passwordless assertions (%v)", strings.Join(modes, ", "))).
|
|
Default(mfaModeAuto).
|
|
Envar(mfaModeEnvVar).
|
|
EnumVar(&cf.MFAMode, modes...)
|
|
app.Flag("headless", "Use headless login. Shorthand for --auth=headless.").Envar(headlessEnvVar).BoolVar(&cf.Headless)
|
|
app.HelpFlag.Short('h')
|
|
|
|
ver := app.Command("version", "Print the tsh client and Proxy server versions for the current context.")
|
|
ver.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
ver.Flag("client", "Show the client version only (no server required).").
|
|
BoolVar(&cf.clientOnlyVersionCheck)
|
|
// ssh
|
|
// Use Interspersed(false) to forward all flags to ssh.
|
|
ssh := app.Command("ssh", "Run shell or execute a command on a remote SSH node").Interspersed(false)
|
|
ssh.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cf.UserHost)
|
|
ssh.Arg("command", "Command to execute on a remote host").StringsVar(&cf.RemoteCommand)
|
|
app.Flag("jumphost", "SSH jumphost").Short('J').StringVar(&cf.ProxyJump)
|
|
ssh.Flag("port", "SSH port on a remote host").Short('p').Int32Var(&cf.NodePort)
|
|
ssh.Flag("forward-agent", "Forward agent to target node").Short('A').BoolVar(&cf.ForwardAgent)
|
|
ssh.Flag("forward", "Forward localhost connections to remote server").Short('L').StringsVar(&cf.LocalForwardPorts)
|
|
ssh.Flag("dynamic-forward", "Forward localhost connections to remote server using SOCKS5").Short('D').StringsVar(&cf.DynamicForwardedPorts)
|
|
ssh.Flag("local", "Execute command on localhost after connecting to SSH node").Default("false").BoolVar(&cf.LocalExec)
|
|
ssh.Flag("tty", "Allocate TTY").Short('t').BoolVar(&cf.Interactive)
|
|
ssh.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
ssh.Flag("option", "OpenSSH options in the format used in the configuration file").Short('o').AllowDuplicate().StringsVar(&cf.Options)
|
|
ssh.Flag("no-remote-exec", "Don't execute remote command, useful for port forwarding").Short('N').BoolVar(&cf.NoRemoteExec)
|
|
ssh.Flag("x11-untrusted", "Requests untrusted (secure) X11 forwarding for this session").Short('X').BoolVar(&cf.X11ForwardingUntrusted)
|
|
ssh.Flag("x11-trusted", "Requests trusted (insecure) X11 forwarding for this session. This can make your local machine vulnerable to attacks, use with caution").Short('Y').BoolVar(&cf.X11ForwardingTrusted)
|
|
ssh.Flag("x11-untrusted-timeout", "Sets a timeout for untrusted X11 forwarding, after which the client will reject any forwarding requests from the server").Default("10m").DurationVar((&cf.X11ForwardingTimeout))
|
|
ssh.Flag("participant-req", "Displays a verbose list of required participants in a moderated session.").BoolVar(&cf.displayParticipantRequirements)
|
|
ssh.Flag("request-reason", "Reason for requesting access").StringVar(&cf.RequestReason)
|
|
ssh.Flag("disable-access-request", "Disable automatic resource access requests").BoolVar(&cf.disableAccessRequest)
|
|
|
|
// Daemon service for teleterm client
|
|
daemon := app.Command("daemon", "Daemon is the tsh daemon service").Hidden()
|
|
daemonStart := daemon.Command("start", "Starts tsh daemon service").Hidden()
|
|
daemonStart.Flag("addr", "Addr is the daemon listening address.").StringVar(&cf.DaemonAddr)
|
|
daemonStart.Flag("certs-dir", "Directory containing certs used to create secure gRPC connection with daemon service").StringVar(&cf.DaemonCertsDir)
|
|
daemonStart.Flag("prehog-addr", "URL where prehog events should be submitted").StringVar(&cf.DaemonPrehogAddr)
|
|
daemonStop := daemon.Command("stop", "Gracefully stops a process on Windows by sending Ctrl-Break to it").Hidden()
|
|
daemonStop.Flag("pid", "PID to be stopped").IntVar(&cf.DaemonPid)
|
|
|
|
// AWS.
|
|
// Use Interspersed(false) to forward all flags to AWS CLI.
|
|
aws := app.Command("aws", "Access AWS API.").Interspersed(false)
|
|
aws.Arg("command", "AWS command and subcommands arguments that are going to be forwarded to AWS CLI.").StringsVar(&cf.AWSCommandArgs)
|
|
aws.Flag("app", "Optional Name of the AWS application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
aws.Flag("endpoint-url", "Run local proxy to serve as an AWS endpoint URL. If not specified, local proxy serves as an HTTPS proxy.").
|
|
Short('e').Hidden().BoolVar(&cf.AWSEndpointURLMode)
|
|
aws.Flag("exec", "Execute different commands (e.g. terraform) under Teleport credentials").StringVar(&cf.Exec)
|
|
|
|
azure := app.Command("az", "Access Azure API.").Interspersed(false)
|
|
azure.Arg("command", "`az` command and subcommands arguments that are going to be forwarded to Azure CLI.").StringsVar(&cf.AzureCommandArgs)
|
|
azure.Flag("app", "Optional name of the Azure application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
|
|
gcloud := app.Command("gcloud", "Access GCP API with the gcloud command.").Interspersed(false)
|
|
gcloud.Arg("command", "`gcloud` command and subcommands arguments.").StringsVar(&cf.GCPCommandArgs)
|
|
gcloud.Flag("app", "Optional name of the GCP application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
gcloud.Alias("gcp")
|
|
|
|
gsutil := app.Command("gsutil", "Access Google Cloud Storage with the gsutil command.").Interspersed(false)
|
|
gsutil.Arg("command", "`gsutil` command and subcommands arguments.").StringsVar(&cf.GCPCommandArgs)
|
|
gsutil.Flag("app", "Optional name of the GCP application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
|
|
// Applications.
|
|
apps := app.Command("apps", "View and control proxied applications.").Alias("app")
|
|
lsApps := apps.Command("ls", "List available applications.")
|
|
lsApps.Flag("verbose", "Show extra application fields.").Short('v').BoolVar(&cf.Verbose)
|
|
lsApps.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
lsApps.Flag("search", searchHelp).StringVar(&cf.SearchKeywords)
|
|
lsApps.Flag("query", queryHelp).StringVar(&cf.PredicateExpression)
|
|
lsApps.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
lsApps.Arg("labels", labelHelp).StringVar(&cf.UserHost)
|
|
lsApps.Flag("all", "List apps from all clusters and proxies.").Short('R').BoolVar(&cf.ListAll)
|
|
appLogin := apps.Command("login", "Retrieve short-lived certificate for an app.")
|
|
appLogin.Arg("app", "App name to retrieve credentials for. Can be obtained from `tsh apps ls` output.").Required().StringVar(&cf.AppName)
|
|
appLogin.Flag("aws-role", "(For AWS CLI access only) Amazon IAM role ARN or role name.").StringVar(&cf.AWSRole)
|
|
appLogin.Flag("azure-identity", "(For Azure CLI access only) Azure managed identity name.").StringVar(&cf.AzureIdentity)
|
|
appLogin.Flag("gcp-service-account", "(For GCP CLI access only) GCP service account name.").StringVar(&cf.GCPServiceAccount)
|
|
appLogout := apps.Command("logout", "Remove app certificate.")
|
|
appLogout.Arg("app", "App to remove credentials for.").StringVar(&cf.AppName)
|
|
appConfig := apps.Command("config", "Print app connection information.")
|
|
appConfig.Arg("app", "App to print information for. Required when logged into multiple apps.").StringVar(&cf.AppName)
|
|
appConfig.Flag("format", fmt.Sprintf("Optional print format, one of: %q to print app address, %q to print CA cert path, %q to print cert path, %q print key path, %q to print example curl command, %q or %q to print everything as JSON or YAML.",
|
|
appFormatURI, appFormatCA, appFormatCert, appFormatKey, appFormatCURL, appFormatJSON, appFormatYAML),
|
|
).Short('f').StringVar(&cf.Format)
|
|
|
|
// Recordings.
|
|
recordings := app.Command("recordings", "View and control session recordings.").Alias("recording")
|
|
lsRecordings := recordings.Command("ls", "List recorded sessions.")
|
|
lsRecordings.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)+". Defaults to 'text'.").Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
lsRecordings.Flag("from-utc", fmt.Sprintf("Start of time range in which recordings are listed. Format %s. Defaults to 24 hours ago.", defaults.TshTctlSessionListTimeFormat)).StringVar(&cf.FromUTC)
|
|
lsRecordings.Flag("to-utc", fmt.Sprintf("End of time range in which recordings are listed. Format %s. Defaults to current time.", defaults.TshTctlSessionListTimeFormat)).StringVar(&cf.ToUTC)
|
|
lsRecordings.Flag("limit", fmt.Sprintf("Maximum number of recordings to show. Default %s.", defaults.TshTctlSessionListLimit)).Default(defaults.TshTctlSessionListLimit).IntVar(&cf.maxRecordingsToShow)
|
|
lsRecordings.Flag("last", "Duration into the past from which session recordings should be listed. Format 5h30m40s").StringVar(&cf.recordingsSince)
|
|
|
|
// Local TLS proxy.
|
|
proxy := app.Command("proxy", "Run local TLS proxy allowing connecting to Teleport in single-port mode")
|
|
proxySSH := proxy.Command("ssh", "Start local TLS proxy for ssh connections when using Teleport in single-port mode")
|
|
proxySSH.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cf.UserHost)
|
|
proxySSH.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
proxyDB := proxy.Command("db", "Start local TLS proxy for database connections when using Teleport in single-port mode")
|
|
proxyDB.Arg("db", "The name of the database to start local proxy for").Required().StringVar(&cf.DatabaseService)
|
|
proxyDB.Flag("port", "Specifies the source port used by proxy db listener").Short('p').StringVar(&cf.LocalProxyPort)
|
|
// --cert-file and --key-file are deprecated in favor of --tunnel flag.
|
|
proxyDB.Flag("cert-file", "Certificate file for proxy client TLS configuration").Hidden().StringVar(&cf.LocalProxyCertFile)
|
|
proxyDB.Flag("key-file", "Key file for proxy client TLS configuration").Hidden().StringVar(&cf.LocalProxyKeyFile)
|
|
proxyDB.Flag("tunnel", "Open authenticated tunnel using database's client certificate so clients don't need to authenticate").BoolVar(&cf.LocalProxyTunnel)
|
|
proxyDB.Flag("db-user", "Optional database user to log in as.").StringVar(&cf.DatabaseUser)
|
|
proxyDB.Flag("db-name", "Optional database name to log in to.").StringVar(&cf.DatabaseName)
|
|
proxyDB.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
|
|
proxyApp := proxy.Command("app", "Start local TLS proxy for app connection when using Teleport in single-port mode")
|
|
proxyApp.Arg("app", "The name of the application to start local proxy for").Required().StringVar(&cf.AppName)
|
|
proxyApp.Flag("port", "Specifies the source port used by by the proxy app listener").Short('p').StringVar(&cf.LocalProxyPort)
|
|
|
|
proxyAWS := proxy.Command("aws", "Start local proxy for AWS access.")
|
|
proxyAWS.Flag("app", "Optional Name of the AWS application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
proxyAWS.Flag("port", "Specifies the source port used by the proxy listener.").Short('p').StringVar(&cf.LocalProxyPort)
|
|
proxyAWS.Flag("endpoint-url", "Run local proxy to serve as an AWS endpoint URL. If not specified, local proxy serves as an HTTPS proxy.").Short('e').BoolVar(&cf.AWSEndpointURLMode)
|
|
proxyAWS.Flag("format", awsProxyFormatFlagDescription()).Short('f').Default(envVarDefaultFormat()).EnumVar(&cf.Format, awsProxyFormats...)
|
|
|
|
proxyAzure := proxy.Command("azure", "Start local proxy for Azure access.")
|
|
proxyAzure.Flag("app", "Optional Name of the Azure application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
proxyAzure.Flag("port", "Specifies the source port used by the proxy listener.").Short('p').StringVar(&cf.LocalProxyPort)
|
|
proxyAzure.Flag("format", envVarFormatFlagDescription()).Short('f').Default(envVarDefaultFormat()).EnumVar(&cf.Format, envVarFormats...)
|
|
proxyAzure.Alias("az")
|
|
|
|
proxyGcloud := proxy.Command("gcloud", "Start local proxy for GCP access.")
|
|
proxyGcloud.Flag("app", "Optional Name of the GCP application to use if logged into multiple.").StringVar(&cf.AppName)
|
|
proxyGcloud.Flag("port", "Specifies the source port used by the proxy listener.").Short('p').StringVar(&cf.LocalProxyPort)
|
|
proxyGcloud.Flag("format", envVarFormatFlagDescription()).Short('f').Default(envVarDefaultFormat()).EnumVar(&cf.Format, envVarFormats...)
|
|
proxyGcloud.Alias("gcp")
|
|
|
|
// Databases.
|
|
db := app.Command("db", "View and control proxied databases.")
|
|
db.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
dbList := db.Command("ls", "List all available databases.")
|
|
dbList.Flag("verbose", "Show extra database fields.").Short('v').BoolVar(&cf.Verbose)
|
|
dbList.Flag("search", searchHelp).StringVar(&cf.SearchKeywords)
|
|
dbList.Flag("query", queryHelp).StringVar(&cf.PredicateExpression)
|
|
dbList.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
dbList.Flag("all", "List databases from all clusters and proxies.").Short('R').BoolVar(&cf.ListAll)
|
|
dbList.Arg("labels", labelHelp).StringVar(&cf.UserHost)
|
|
dbLogin := db.Command("login", "Retrieve credentials for a database.")
|
|
dbLogin.Arg("db", "Database to retrieve credentials for. Can be obtained from 'tsh db ls' output.").Required().StringVar(&cf.DatabaseService)
|
|
dbLogin.Flag("db-user", "Optional database user to configure as default.").StringVar(&cf.DatabaseUser)
|
|
dbLogin.Flag("db-name", "Optional database name to configure as default.").StringVar(&cf.DatabaseName)
|
|
dbLogout := db.Command("logout", "Remove database credentials.")
|
|
dbLogout.Arg("db", "Database to remove credentials for.").StringVar(&cf.DatabaseService)
|
|
dbEnv := db.Command("env", "Print environment variables for the configured database.")
|
|
dbEnv.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
dbEnv.Arg("db", "Print environment for the specified database").StringVar(&cf.DatabaseService)
|
|
// --db flag is deprecated in favor of positional argument for consistency with other commands.
|
|
dbEnv.Flag("db", "Print environment for the specified database.").Hidden().StringVar(&cf.DatabaseService)
|
|
dbConfig := db.Command("config", "Print database connection information. Useful when configuring GUI clients.")
|
|
dbConfig.Arg("db", "Print information for the specified database.").StringVar(&cf.DatabaseService)
|
|
// --db flag is deprecated in favor of positional argument for consistency with other commands.
|
|
dbConfig.Flag("db", "Print information for the specified database.").Hidden().StringVar(&cf.DatabaseService)
|
|
dbConfig.Flag("format", fmt.Sprintf("Print format: %q to print in table format (default), %q to print connect command, %q or %q to print in JSON or YAML.",
|
|
dbFormatText, dbFormatCommand, dbFormatJSON, dbFormatYAML)).Short('f').EnumVar(&cf.Format, dbFormatText, dbFormatCommand, dbFormatJSON, dbFormatYAML)
|
|
dbConnect := db.Command("connect", "Connect to a database.")
|
|
dbConnect.Arg("db", "Database service name to connect to.").StringVar(&cf.DatabaseService)
|
|
dbConnect.Flag("db-user", "Optional database user to log in as.").StringVar(&cf.DatabaseUser)
|
|
dbConnect.Flag("db-name", "Optional database name to log in to.").StringVar(&cf.DatabaseName)
|
|
|
|
// join
|
|
join := app.Command("join", "Join the active SSH or Kubernetes session")
|
|
join.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
join.Flag("mode", "Mode of joining the session, valid modes are observer and moderator").Short('m').Default("peer").StringVar(&cf.JoinMode)
|
|
join.Flag("reason", "The purpose of the session.").StringVar(&cf.Reason)
|
|
join.Flag("invite", "A comma separated list of people to mark as invited for the session.").StringsVar(&cf.Invited)
|
|
join.Arg("session-id", "ID of the session to join").Required().StringVar(&cf.SessionID)
|
|
// play
|
|
play := app.Command("play", "Replay the recorded session (SSH, Kubernetes, App)")
|
|
play.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
play.Flag("format", defaults.FormatFlagDescription(
|
|
teleport.PTY, teleport.JSON, teleport.YAML,
|
|
)).Short('f').Default(teleport.PTY).EnumVar(&cf.Format, teleport.PTY, teleport.JSON, teleport.YAML)
|
|
play.Arg("session-id", "ID of the session to play").Required().StringVar(&cf.SessionID)
|
|
|
|
// scp
|
|
scp := app.Command("scp", "Transfer files to a remote Node")
|
|
scp.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
scp.Arg("from, to", "Source and destination to copy, one must be a local path and one must be a remote path").Required().StringsVar(&cf.CopySpec)
|
|
scp.Flag("recursive", "Recursive copy of subdirectories").Short('r').BoolVar(&cf.RecursiveCopy)
|
|
scp.Flag("port", "Port to connect to on the remote host").Short('P').Int32Var(&cf.NodePort)
|
|
scp.Flag("preserve", "Preserves access and modification times from the original file").Short('p').BoolVar(&cf.PreserveAttrs)
|
|
scp.Flag("quiet", "Quiet mode").Short('q').BoolVar(&cf.Quiet)
|
|
// ls
|
|
ls := app.Command("ls", "List remote SSH nodes")
|
|
ls.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
ls.Flag("verbose", "One-line output (for text format), including node UUIDs").Short('v').BoolVar(&cf.Verbose)
|
|
ls.Flag("format", defaults.FormatFlagDescription(
|
|
teleport.Text, teleport.JSON, teleport.YAML, teleport.Names,
|
|
)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, teleport.Text, teleport.JSON, teleport.YAML, teleport.Names)
|
|
ls.Arg("labels", labelHelp).StringVar(&cf.UserHost)
|
|
ls.Flag("search", searchHelp).StringVar(&cf.SearchKeywords)
|
|
ls.Flag("query", queryHelp).StringVar(&cf.PredicateExpression)
|
|
ls.Flag("all", "List nodes from all clusters and proxies.").Short('R').BoolVar(&cf.ListAll)
|
|
// clusters
|
|
clusters := app.Command("clusters", "List available Teleport clusters")
|
|
clusters.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
clusters.Flag("quiet", "Quiet mode").Short('q').BoolVar(&cf.Quiet)
|
|
|
|
// login logs in with remote proxy and obtains a "session certificate" which gets
|
|
// stored in ~/.tsh directory
|
|
login := app.Command("login", "Log in to a cluster and retrieve the session certificate")
|
|
login.Flag("out", "Identity output").Short('o').AllowDuplicate().StringVar(&cf.IdentityFileOut)
|
|
login.Flag("format", fmt.Sprintf("Identity format: %s, %s (for OpenSSH compatibility) or %s (for kubeconfig)",
|
|
identityfile.DefaultFormat,
|
|
identityfile.FormatOpenSSH,
|
|
identityfile.FormatKubernetes,
|
|
)).Default(string(identityfile.DefaultFormat)).Short('f').StringVar((*string)(&cf.IdentityFormat))
|
|
login.Flag("overwrite", "Whether to overwrite the existing identity file.").BoolVar(&cf.IdentityOverwrite)
|
|
login.Flag("request-roles", "Request one or more extra roles").StringVar(&cf.DesiredRoles)
|
|
login.Flag("request-reason", "Reason for requesting additional roles").StringVar(&cf.RequestReason)
|
|
login.Flag("request-reviewers", "Suggested reviewers for role request").StringVar(&cf.SuggestedReviewers)
|
|
login.Flag("request-nowait", "Finish without waiting for request resolution").BoolVar(&cf.NoWait)
|
|
login.Flag("request-id", "Login with the roles requested in the given request").StringVar(&cf.RequestID)
|
|
login.Arg("cluster", clusterHelp).StringVar(&cf.SiteName)
|
|
login.Flag("browser", browserHelp).StringVar(&cf.Browser)
|
|
login.Flag("kube-cluster", "Name of the Kubernetes cluster to login to").StringVar(&cf.KubernetesCluster)
|
|
login.Flag("verbose", "Show extra status information").Short('v').BoolVar(&cf.Verbose)
|
|
login.Alias(loginUsageFooter)
|
|
|
|
// logout deletes obtained session certificates in ~/.tsh
|
|
logout := app.Command("logout", "Delete a cluster certificate")
|
|
|
|
// bench
|
|
bench := app.Command("bench", "Run shell or execute a command on a remote SSH node").Hidden()
|
|
bench.Flag("cluster", clusterHelp).Short('c').StringVar(&cf.SiteName)
|
|
bench.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cf.UserHost)
|
|
bench.Arg("command", "Command to execute on a remote host").Required().StringsVar(&cf.RemoteCommand)
|
|
bench.Flag("port", "SSH port on a remote host").Short('p').Int32Var(&cf.NodePort)
|
|
bench.Flag("duration", "Test duration").Default("1s").DurationVar(&cf.BenchDuration)
|
|
bench.Flag("rate", "Requests per second rate").Default("10").IntVar(&cf.BenchRate)
|
|
bench.Flag("interactive", "Create interactive SSH session").BoolVar(&cf.BenchInteractive)
|
|
bench.Flag("export", "Export the latency profile").BoolVar(&cf.BenchExport)
|
|
bench.Flag("path", "Directory to save the latency profile to, default path is the current directory").Default(".").StringVar(&cf.BenchExportPath)
|
|
bench.Flag("ticks", "Ticks per half distance").Default("100").Int32Var(&cf.BenchTicks)
|
|
bench.Flag("scale", "Value scale in which to scale the recorded values").Default("1.0").Float64Var(&cf.BenchValueScale)
|
|
|
|
// show key
|
|
show := app.Command("show", "Read an identity from file and print to stdout").Hidden()
|
|
show.Arg("identity_file", "The file containing a public key or a certificate").Required().StringVar(&cf.IdentityFileIn)
|
|
|
|
// The status command shows which proxy the user is logged into and metadata
|
|
// about the certificate.
|
|
status := app.Command("status", "Display the list of proxy servers and retrieved certificates")
|
|
status.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
status.Flag("verbose", "Show extra status information after successful login").Short('v').BoolVar(&cf.Verbose)
|
|
|
|
// The environment command prints out environment variables for the configured
|
|
// proxy and cluster. Can be used to create sessions "sticky" to a terminal
|
|
// even if the user runs "tsh login" again in another window.
|
|
environment := app.Command("env", "Print commands to set Teleport session environment variables")
|
|
environment.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
environment.Flag("unset", "Print commands to clear Teleport session environment variables").BoolVar(&cf.unsetEnvironment)
|
|
|
|
req := app.Command("request", "Manage access requests").Alias("requests")
|
|
|
|
reqList := req.Command("ls", "List access requests").Alias("list")
|
|
reqList.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
reqList.Flag("reviewable", "Only show requests reviewable by current user").BoolVar(&cf.ReviewableRequests)
|
|
reqList.Flag("suggested", "Only show requests that suggest current user as reviewer").BoolVar(&cf.SuggestedRequests)
|
|
reqList.Flag("my-requests", "Only show requests created by current user").BoolVar(&cf.MyRequests)
|
|
|
|
reqShow := req.Command("show", "Show request details").Alias("details")
|
|
reqShow.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)
|
|
reqShow.Arg("request-id", "ID of the target request").Required().StringVar(&cf.RequestID)
|
|
|
|
// Note: The "tsh request new" subcommand should not be used anymore. It
|
|
// will be kept around for users that built automation around it, but all
|
|
// public facing documentation should now refer to "tsh request create".
|
|
reqCreate := req.Command("create", "Create a new access request").Alias("new")
|
|
reqCreate.Flag("roles", "Roles to be requested").StringVar(&cf.DesiredRoles)
|
|
reqCreate.Flag("reason", "Reason for requesting").StringVar(&cf.RequestReason)
|
|
reqCreate.Flag("reviewers", "Suggested reviewers").StringVar(&cf.SuggestedReviewers)
|
|
reqCreate.Flag("nowait", "Finish without waiting for request resolution").BoolVar(&cf.NoWait)
|
|
reqCreate.Flag("resource", "Resource ID to be requested").StringsVar(&cf.RequestedResourceIDs)
|
|
reqCreate.Flag("request-ttl", "Expiration time for the access request").DurationVar(&cf.RequestTTL)
|
|
reqCreate.Flag("session-ttl", "Expiration time for the elevated certificate").DurationVar(&cf.SessionTTL)
|
|
|
|
reqReview := req.Command("review", "Review an access request")
|
|
reqReview.Arg("request-id", "ID of target request").Required().StringVar(&cf.RequestID)
|
|
reqReview.Flag("approve", "Review proposes approval").BoolVar(&cf.Approve)
|
|
reqReview.Flag("deny", "Review proposes denial").BoolVar(&cf.Deny)
|
|
reqReview.Flag("reason", "Review reason message").StringVar(&cf.ReviewReason)
|
|
|
|
reqSearch := req.Command("search", "Search for resources to request access to")
|
|
reqSearch.Flag("kind",
|
|
fmt.Sprintf("Resource kind to search for (%s)",
|
|
strings.Join(types.RequestableResourceKinds, ", ")),
|
|
).Required().EnumVar(&cf.ResourceKind, types.RequestableResourceKinds...)
|
|
reqSearch.Flag("search", searchHelp).StringVar(&cf.SearchKeywords)
|
|
reqSearch.Flag("query", queryHelp).StringVar(&cf.PredicateExpression)
|
|
reqSearch.Flag("labels", labelHelp).StringVar(&cf.UserHost)
|
|
reqSearch.Flag("kube-cluster", "Kubernetes Cluster to search for Pods").StringVar(&cf.KubernetesCluster)
|
|
reqSearch.Flag("kube-namespace", "Kubernetes Namespace to search for Pods").Default(corev1.NamespaceDefault).StringVar(&cf.kubeNamespace)
|
|
reqSearch.Flag("all-kube-namespaces", "Search Pods in every namespace").BoolVar(&cf.kubeAllNamespaces)
|
|
|
|
// Headless login approval
|
|
headless := app.Command("headless", "headless commands").Interspersed(true)
|
|
approve := headless.Command("approve", "headless approval").Interspersed(true)
|
|
approve.Arg("request id", "headless authentication request id").StringVar(&cf.HeadlessAuthenticationID)
|
|
|
|
reqDrop := req.Command("drop", "Drop one more access requests from current identity")
|
|
reqDrop.Arg("request-id", "IDs of requests to drop (default drops all requests)").Default("*").StringsVar(&cf.RequestIDs)
|
|
kubectl := app.Command("kubectl", "Runs a kubectl command on a Kubernetes cluster").Interspersed(false)
|
|
// This hack is required in order to accept any args for tsh kubectl.
|
|
kubectl.Arg("", "").StringsVar(new([]string))
|
|
// Kubernetes subcommands.
|
|
kube := newKubeCommand(app)
|
|
// MFA subcommands.
|
|
mfa := newMFACommand(app)
|
|
|
|
config := app.Command("config", "Print OpenSSH configuration details")
|
|
|
|
f2 := app.Command("fido2", "FIDO2 commands").Hidden()
|
|
f2Diag := f2.Command("diag", "Run FIDO2 diagnostics").Hidden()
|
|
|
|
// touchid subcommands.
|
|
tid := newTouchIDCommand(app)
|
|
|
|
webauthnwin := newWebauthnwinCommand(app)
|
|
|
|
// Device Trust commands.
|
|
deviceCmd := newDeviceCommand(app)
|
|
|
|
if runtime.GOOS == constants.WindowsOS {
|
|
bench.Hidden()
|
|
}
|
|
|
|
var err error
|
|
|
|
cf.executablePath, err = os.Executable()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// configs
|
|
setEnvFlags(&cf, os.Getenv)
|
|
|
|
confOptions, err := loadAllConfigs(cf)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
cf.TshConfig = *confOptions
|
|
|
|
// aliases
|
|
ar := newAliasRunner(cf.TshConfig.Aliases)
|
|
aliasCommand, runtimeArgs := findAliasCommand(args)
|
|
if aliasDefinition, ok := ar.getAliasDefinition(aliasCommand); ok {
|
|
return ar.runAlias(ctx, aliasCommand, aliasDefinition, cf.executablePath, runtimeArgs)
|
|
}
|
|
|
|
// parse CLI commands+flags:
|
|
utils.UpdateAppUsageTemplate(app, args)
|
|
command, err := app.Parse(args)
|
|
if errors.Is(err, kingpin.ErrExpectedCommand) {
|
|
if _, ok := cf.TshConfig.Aliases[aliasCommand]; ok {
|
|
log.Debugf("Failing due to recursive alias %q. Aliases seen: %v", aliasCommand, ar.getSeenAliases())
|
|
return trace.BadParameter("recursive alias %q; correct alias definition and try again", aliasCommand)
|
|
}
|
|
}
|
|
|
|
// Identity files do not currently contain a proxy address. When loading an
|
|
// Identity file, a proxy must be passed on the command line as well.
|
|
if cf.IdentityFileIn != "" && cf.Proxy == "" {
|
|
return trace.BadParameter("tsh --identity also requires --proxy")
|
|
}
|
|
|
|
// prevent Kingpin from calling os.Exit(), we want to handle errors ourselves.
|
|
// shouldTerminate will be checked after app.Parse() call.
|
|
var shouldTerminate *int
|
|
app.Terminate(func(exitCode int) {
|
|
// make non-zero exit code sticky
|
|
if exitCode == 0 && shouldTerminate != nil {
|
|
return
|
|
}
|
|
shouldTerminate = &exitCode
|
|
})
|
|
|
|
if err != nil {
|
|
app.Usage(args)
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// handle: help command, --help flag, version command, ...
|
|
if shouldTerminate != nil {
|
|
return trace.Wrap(&common.ExitCodeError{Code: *shouldTerminate})
|
|
}
|
|
|
|
// Did we initially get the Username from flags/env?
|
|
cf.ExplicitUsername = cf.Username != ""
|
|
|
|
cf.command = command
|
|
|
|
// apply any options after parsing of arguments to ensure
|
|
// that defaults don't overwrite options.
|
|
for _, opt := range opts {
|
|
if err := opt(&cf); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Enable debug logging if requested by --debug.
|
|
// If TELEPORT_DEBUG was set, it was already enabled by prior call to initLogger().
|
|
initLogger(&cf)
|
|
|
|
// Connect to the span exporter and initialize the trace provider only if
|
|
// the --trace flag was set.
|
|
if cf.SampleTraces {
|
|
// login only needs to be ignored if forwarding to auth
|
|
var ignored []string
|
|
if cf.TraceExporter == "" {
|
|
ignored = []string{login.FullCommand()}
|
|
}
|
|
provider, err := newTraceProvider(&cf, command, ignored)
|
|
if err != nil {
|
|
log.WithError(err).Debug("failed to set up span forwarding.")
|
|
} else {
|
|
// only update the provider if we successfully set it up
|
|
cf.TracingProvider = provider
|
|
|
|
// ensure that the provider is shutdown on exit to flush any spans
|
|
// that haven't been forwarded yet.
|
|
defer func() {
|
|
shutdownCtx, cancel := context.WithTimeout(cf.Context, 1*time.Second)
|
|
defer cancel()
|
|
err := provider.Shutdown(shutdownCtx)
|
|
if err != nil && !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
|
|
log.WithError(err).Debugf("failed to shutdown trace provider")
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// start the span for the command and update the config context so that all spans created
|
|
// in the future will be rooted at this span.
|
|
ctx, span := cf.TracingProvider.Tracer(teleport.ComponentTSH).Start(cf.Context, command)
|
|
cf.Context = ctx
|
|
defer span.End()
|
|
|
|
if err := client.ValidateAgentKeyOption(cf.AddKeysToAgent); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if cpuProfile != "" {
|
|
log.Debugf("writing CPU profile to %v", cpuProfile)
|
|
f, err := os.Create(cpuProfile)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer f.Close()
|
|
if err := pprof.StartCPUProfile(f); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer pprof.StopCPUProfile()
|
|
}
|
|
|
|
if memProfile != "" {
|
|
log.Debugf("writing memory profile to %v", memProfile)
|
|
defer func() {
|
|
f, err := os.Create(memProfile)
|
|
if err != nil {
|
|
log.Errorf("could not open memory profile: %v", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
runtime.GC()
|
|
if err := pprof.WriteHeapProfile(f); err != nil {
|
|
log.Errorf("could not write memory profile: %v", err)
|
|
return
|
|
}
|
|
}()
|
|
}
|
|
|
|
if traceProfile != "" {
|
|
log.Debugf("writing trace profile to %v", traceProfile)
|
|
f, err := os.Create(traceProfile)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if err := runtimetrace.Start(f); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer runtimetrace.Stop()
|
|
}
|
|
|
|
switch command {
|
|
case ver.FullCommand():
|
|
err = onVersion(&cf)
|
|
case ssh.FullCommand():
|
|
err = onSSH(&cf)
|
|
case bench.FullCommand():
|
|
err = onBenchmark(&cf)
|
|
case join.FullCommand():
|
|
err = onJoin(&cf)
|
|
case scp.FullCommand():
|
|
err = onSCP(&cf)
|
|
case play.FullCommand():
|
|
err = onPlay(&cf)
|
|
case ls.FullCommand():
|
|
err = onListNodes(&cf)
|
|
case clusters.FullCommand():
|
|
err = onListClusters(&cf)
|
|
case login.FullCommand():
|
|
err = onLogin(&cf)
|
|
case logout.FullCommand():
|
|
if err := refuseArgs(logout.FullCommand(), args); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
err = onLogout(&cf)
|
|
case show.FullCommand():
|
|
err = onShow(&cf)
|
|
case status.FullCommand():
|
|
err = onStatus(&cf)
|
|
case lsApps.FullCommand():
|
|
err = onApps(&cf)
|
|
case lsRecordings.FullCommand():
|
|
err = onRecordings(&cf)
|
|
case appLogin.FullCommand():
|
|
err = onAppLogin(&cf)
|
|
case appLogout.FullCommand():
|
|
err = onAppLogout(&cf)
|
|
case appConfig.FullCommand():
|
|
err = onAppConfig(&cf)
|
|
case kube.credentials.FullCommand():
|
|
err = kube.credentials.run(&cf)
|
|
case kube.ls.FullCommand():
|
|
err = kube.ls.run(&cf)
|
|
case kube.login.FullCommand():
|
|
err = kube.login.run(&cf)
|
|
case kube.sessions.FullCommand():
|
|
err = kube.sessions.run(&cf)
|
|
case kube.exec.FullCommand():
|
|
err = kube.exec.run(&cf)
|
|
case kube.join.FullCommand():
|
|
err = kube.join.run(&cf)
|
|
|
|
case proxySSH.FullCommand():
|
|
err = onProxyCommandSSH(&cf)
|
|
case proxyDB.FullCommand():
|
|
err = onProxyCommandDB(&cf)
|
|
case proxyApp.FullCommand():
|
|
err = onProxyCommandApp(&cf)
|
|
case proxyAWS.FullCommand():
|
|
err = onProxyCommandAWS(&cf)
|
|
case proxyAzure.FullCommand():
|
|
err = onProxyCommandAzure(&cf)
|
|
case proxyGcloud.FullCommand():
|
|
err = onProxyCommandGCloud(&cf)
|
|
|
|
case dbList.FullCommand():
|
|
err = onListDatabases(&cf)
|
|
case dbLogin.FullCommand():
|
|
err = onDatabaseLogin(&cf)
|
|
case dbLogout.FullCommand():
|
|
err = onDatabaseLogout(&cf)
|
|
case dbEnv.FullCommand():
|
|
err = onDatabaseEnv(&cf)
|
|
case dbConfig.FullCommand():
|
|
err = onDatabaseConfig(&cf)
|
|
case dbConnect.FullCommand():
|
|
err = onDatabaseConnect(&cf)
|
|
case environment.FullCommand():
|
|
err = onEnvironment(&cf)
|
|
case mfa.ls.FullCommand():
|
|
err = mfa.ls.run(&cf)
|
|
case mfa.add.FullCommand():
|
|
err = mfa.add.run(&cf)
|
|
case mfa.rm.FullCommand():
|
|
err = mfa.rm.run(&cf)
|
|
case reqList.FullCommand():
|
|
err = onRequestList(&cf)
|
|
case reqShow.FullCommand():
|
|
err = onRequestShow(&cf)
|
|
case reqCreate.FullCommand():
|
|
err = onRequestCreate(&cf)
|
|
case reqReview.FullCommand():
|
|
err = onRequestReview(&cf)
|
|
case reqSearch.FullCommand():
|
|
err = onRequestSearch(&cf)
|
|
case reqDrop.FullCommand():
|
|
err = onRequestDrop(&cf)
|
|
case config.FullCommand():
|
|
err = onConfig(&cf)
|
|
case aws.FullCommand():
|
|
err = onAWS(&cf)
|
|
case azure.FullCommand():
|
|
err = onAzure(&cf)
|
|
case gcloud.FullCommand():
|
|
err = onGcloud(&cf)
|
|
case gsutil.FullCommand():
|
|
err = onGsutil(&cf)
|
|
case daemonStart.FullCommand():
|
|
err = onDaemonStart(&cf)
|
|
case daemonStop.FullCommand():
|
|
err = onDaemonStop(&cf)
|
|
case f2Diag.FullCommand():
|
|
err = onFIDO2Diag(&cf)
|
|
case tid.diag.FullCommand():
|
|
err = tid.diag.run(&cf)
|
|
case webauthnwin.diag.FullCommand():
|
|
err = webauthnwin.diag.run(&cf)
|
|
case deviceCmd.enroll.FullCommand():
|
|
err = deviceCmd.enroll.run(&cf)
|
|
case deviceCmd.collect.FullCommand():
|
|
err = deviceCmd.collect.run(&cf)
|
|
case deviceCmd.keyget.FullCommand():
|
|
err = deviceCmd.keyget.run(&cf)
|
|
case kubectl.FullCommand():
|
|
idx := slices.Index(args, kubectl.FullCommand())
|
|
err = onKubectlCommand(&cf, args[idx:])
|
|
case approve.FullCommand():
|
|
err = onHeadlessApprove(&cf)
|
|
default:
|
|
// Handle commands that might not be available.
|
|
switch {
|
|
case tid.ls != nil && command == tid.ls.FullCommand():
|
|
err = tid.ls.run(&cf)
|
|
case tid.rm != nil && command == tid.rm.FullCommand():
|
|
err = tid.rm.run(&cf)
|
|
default:
|
|
// This should only happen when there's a missing switch case above.
|
|
err = trace.BadParameter("command %q not configured", command)
|
|
}
|
|
}
|
|
|
|
if trace.IsNotImplemented(err) {
|
|
return handleUnimplementedError(ctx, err, cf)
|
|
}
|
|
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// newTraceProvider initializes the tracing provider that will export spans for tsh.
|
|
//
|
|
// If an explicit exporter url was provided via --trace-exporter all spans will be
|
|
// send to the provided exporter. Otherwise all recorded spans are exported to the
|
|
// Auth server which then forwards to the telemetry backend. The ignored list contains
|
|
// certain commands to have exporting spans be a no-op. Since the provider requires
|
|
// connecting to the auth server, this means a user may have to login first before
|
|
// the provider can be created. By ignoring the login command we can avoid having
|
|
// users logging in twice at the expense of not exporting spans for the login command.
|
|
func newTraceProvider(cf *CLIConf, command string, ignored []string) (*tracing.Provider, error) {
|
|
// don't record any spans for commands that have been allowed
|
|
for _, c := range ignored {
|
|
if strings.EqualFold(command, c) {
|
|
return tracing.NoopProvider(), nil
|
|
}
|
|
}
|
|
|
|
// an explicit exporter url was provided no need to forward to auth
|
|
if cf.TraceExporter != "" {
|
|
provider, err := tracing.NewTraceProvider(cf.Context, tracing.Config{
|
|
Service: teleport.ComponentTSH,
|
|
ExporterURL: cf.TraceExporter,
|
|
// We are using 1 here to record all spans as a result of this tsh command. Teleport
|
|
// will respect the recording flag of remote spans even if the spans it generates
|
|
// wouldn't otherwise be recorded due to its configured sampling rate.
|
|
SamplingRate: 1.0,
|
|
})
|
|
|
|
return provider, trace.Wrap(err)
|
|
}
|
|
|
|
// create a TeleportClient and generate a provider that forwards
|
|
// spans to Auth
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
var provider *tracing.Provider
|
|
if err := client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
clt, err := tc.NewTracingClient(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
p, err := tracing.NewTraceProvider(cf.Context,
|
|
tracing.Config{
|
|
Service: teleport.ComponentTSH,
|
|
Client: clt,
|
|
// We are using 1 here to record all spans as a result of this tsh command. Teleport
|
|
// will respect the recording flag of remote spans even if the spans it generates
|
|
// wouldn't otherwise be recorded due to its configured sampling rate.
|
|
SamplingRate: 1.0,
|
|
})
|
|
if err != nil {
|
|
return trace.NewAggregate(err, clt.Close())
|
|
}
|
|
|
|
provider = p
|
|
return nil
|
|
}); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return provider, nil
|
|
}
|
|
|
|
// onVersion prints version info.
|
|
func onVersion(cf *CLIConf) error {
|
|
proxyVersion := ""
|
|
proxyPublicAddr := ""
|
|
// Check proxy version if not in client only mode
|
|
if !cf.clientOnlyVersionCheck {
|
|
pv, ppa, err := fetchProxyVersion(cf)
|
|
if err != nil {
|
|
fmt.Fprintf(cf.Stderr(), "Failed to fetch proxy version: %s\n", err)
|
|
}
|
|
proxyVersion = pv
|
|
proxyPublicAddr = ppa
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
utils.PrintVersion()
|
|
if proxyVersion != "" {
|
|
fmt.Printf("Proxy version: %s\n", proxyVersion)
|
|
fmt.Printf("Proxy: %s\n", proxyPublicAddr)
|
|
}
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeVersion(format, proxyVersion, proxyPublicAddr)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", cf.Format)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchProxyVersion returns the current version of the Teleport Proxy.
|
|
func fetchProxyVersion(cf *CLIConf) (string, string, error) {
|
|
profile, err := cf.ProfileStatus()
|
|
if err != nil {
|
|
if trace.IsNotFound(err) {
|
|
return "", "", nil
|
|
}
|
|
return "", "", trace.Wrap(err)
|
|
}
|
|
|
|
if profile == nil {
|
|
return "", "", nil
|
|
}
|
|
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return "", "", trace.Wrap(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(cf.Context, time.Second*5)
|
|
defer cancel()
|
|
pingRes, err := tc.Ping(ctx)
|
|
if err != nil {
|
|
return "", "", trace.Wrap(err)
|
|
}
|
|
|
|
return pingRes.ServerVersion, pingRes.Proxy.SSH.PublicAddr, nil
|
|
}
|
|
|
|
func serializeVersion(format string, proxyVersion string, proxyPublicAddress string) (string, error) {
|
|
versionInfo := struct {
|
|
Version string `json:"version"`
|
|
Gitref string `json:"gitref"`
|
|
Runtime string `json:"runtime"`
|
|
ProxyVersion string `json:"proxyVersion,omitempty"`
|
|
ProxyPublicAddress string `json:"proxyPublicAddress,omitempty"`
|
|
}{
|
|
teleport.Version,
|
|
teleport.Gitref,
|
|
runtime.Version(),
|
|
proxyVersion,
|
|
proxyPublicAddress,
|
|
}
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(versionInfo, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(versionInfo)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
// onPlay is used to interact with recorded sessions.
|
|
// It has several modes:
|
|
//
|
|
// 1. If --format is "pty" (the default), then the recorded
|
|
// session is played back in the user's terminal.
|
|
// 2. Otherwise, `tsh play` is used to export a session from the
|
|
// binary protobuf format into YAML or JSON.
|
|
//
|
|
// Each of these modes has two subcases:
|
|
// a) --session-id ends with ".tar" - tsh operates on a local file
|
|
//
|
|
// containing a previously downloaded session
|
|
//
|
|
// b) --session-id is the ID of a session - tsh operates on the session
|
|
//
|
|
// recording by connecting to the Teleport cluster
|
|
func onPlay(cf *CLIConf) error {
|
|
if format := strings.ToLower(cf.Format); format == teleport.PTY {
|
|
return playSession(cf)
|
|
}
|
|
return exportSession(cf)
|
|
}
|
|
|
|
func exportSession(cf *CLIConf) error {
|
|
format := strings.ToLower(cf.Format)
|
|
isLocalFile := path.Ext(cf.SessionID) == ".tar"
|
|
if isLocalFile {
|
|
return trace.Wrap(exportFile(cf.Context, cf.SessionID, format))
|
|
}
|
|
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
events, err := tc.GetSessionEvents(cf.Context, cf.Namespace, cf.SessionID)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
for _, event := range events {
|
|
// when playing from a file, id is not included, this
|
|
// makes the outputs otherwise identical
|
|
delete(event, "id")
|
|
}
|
|
|
|
switch format {
|
|
case teleport.JSON:
|
|
if err := utils.WriteJSON(os.Stdout, events); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
case teleport.YAML:
|
|
if err := utils.WriteYAML(os.Stdout, events); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
default:
|
|
return trace.Errorf("Invalid format %s, only pty, json and yaml are supported", format)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func playSession(cf *CLIConf) error {
|
|
isLocalFile := path.Ext(cf.SessionID) == ".tar"
|
|
if isLocalFile {
|
|
sid := sessionIDFromPath(cf.SessionID)
|
|
tarFile, err := os.Open(cf.SessionID)
|
|
if err != nil {
|
|
return trace.ConvertSystemError(err)
|
|
}
|
|
defer tarFile.Close()
|
|
if err := client.PlayFile(cf.Context, tarFile, sid); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := tc.Play(cf.Context, cf.Namespace, cf.SessionID); err != nil {
|
|
if trace.IsNotFound(err) {
|
|
log.WithError(err).Debug("error playing session")
|
|
return trace.NotFound("Recording for session %s not found.", cf.SessionID)
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sessionIDFromPath(path string) string {
|
|
fileName := filepath.Base(path)
|
|
return strings.TrimSuffix(fileName, ".tar")
|
|
}
|
|
|
|
// exportFile converts the binary protobuf events from the file
|
|
// identified by path to text (JSON/YAML) and writes the converted
|
|
// events to standard out.
|
|
func exportFile(ctx context.Context, path string, format string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return trace.ConvertSystemError(err)
|
|
}
|
|
defer f.Close()
|
|
err = events.Export(ctx, f, os.Stdout, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onLogin logs in with remote proxy and gets signed certificates
|
|
func onLogin(cf *CLIConf) error {
|
|
autoRequest := true
|
|
// special case: --request-roles=no disables auto-request behavior.
|
|
if cf.DesiredRoles == "no" {
|
|
autoRequest = false
|
|
cf.DesiredRoles = ""
|
|
}
|
|
|
|
if cf.IdentityFileIn != "" {
|
|
return trace.BadParameter("-i flag cannot be used here")
|
|
}
|
|
|
|
switch cf.IdentityFormat {
|
|
case identityfile.FormatFile, identityfile.FormatOpenSSH, identityfile.FormatKubernetes:
|
|
default:
|
|
return trace.BadParameter("invalid identity format: %s", cf.IdentityFormat)
|
|
}
|
|
|
|
// Get the status of the active profile as well as the status
|
|
// of any other proxies the user is logged into.
|
|
profile, profiles, err := cf.FullProfileStatus()
|
|
if err != nil {
|
|
if !trace.IsNotFound(err) {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// make the teleport client and retrieve the certificate from the proxy:
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
tc.HomePath = cf.HomePath
|
|
|
|
// DEPRECATED DELETE IN 14.0
|
|
var warnOnDeprecatedKubeSNIPrinted bool
|
|
updateKubeConfigOption := withWarnOnDeprecatedSNI(&warnOnDeprecatedKubeSNIPrinted)
|
|
|
|
// client is already logged in and profile is not expired
|
|
if profile != nil && !profile.IsExpired(clockwork.NewRealClock()) {
|
|
switch {
|
|
// in case if nothing is specified, re-fetch kube clusters and print
|
|
// current status
|
|
// OR
|
|
// in case if parameters match, re-fetch kube clusters and print
|
|
// current status
|
|
case cf.Proxy == "" && cf.SiteName == "" && cf.DesiredRoles == "" && cf.RequestID == "" && cf.IdentityFileOut == "" ||
|
|
host(cf.Proxy) == host(profile.ProxyURL.Host) && cf.SiteName == profile.Cluster && cf.DesiredRoles == "" && cf.RequestID == "":
|
|
_, err := tc.PingAndShowMOTD(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
env := getTshEnv()
|
|
active, others := makeAllProfileInfo(profile, profiles, env)
|
|
printProfiles(cf.Debug, active, others, env, cf.Verbose)
|
|
|
|
return nil
|
|
|
|
// if the proxy names match but nothing else is specified; show motd and update active profile and kube configs
|
|
case host(cf.Proxy) == host(profile.ProxyURL.Host) &&
|
|
cf.SiteName == "" && cf.DesiredRoles == "" && cf.RequestID == "" && cf.IdentityFileOut == "":
|
|
_, err := tc.PingAndShowMOTD(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if err := tc.SaveProfile(true); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Try updating kube config. If it fails, then we may have
|
|
// switched to an inactive profile. Continue to normal login.
|
|
if err := updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption); err == nil {
|
|
return trace.Wrap(onStatus(cf))
|
|
}
|
|
|
|
// proxy is unspecified or the same as the currently provided proxy,
|
|
// but cluster is specified, treat this as selecting a new cluster
|
|
// for the same proxy
|
|
case (cf.Proxy == "" || host(cf.Proxy) == host(profile.ProxyURL.Host)) && cf.SiteName != "":
|
|
_, err := tc.PingAndShowMOTD(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// trigger reissue, preserving any active requests.
|
|
err = tc.ReissueUserCerts(cf.Context, client.CertCacheKeep, client.ReissueParams{
|
|
AccessRequests: profile.ActiveRequests.AccessRequests,
|
|
RouteToCluster: cf.SiteName,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := tc.SaveProfile(true); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return trace.Wrap(onStatus(cf))
|
|
// proxy is unspecified or the same as the currently provided proxy,
|
|
// but desired roles or request ID is specified, treat this as a
|
|
// privilege escalation request for the same login session.
|
|
case (cf.Proxy == "" || host(cf.Proxy) == host(profile.ProxyURL.Host)) && (cf.DesiredRoles != "" || cf.RequestID != "") && cf.IdentityFileOut == "":
|
|
_, err := tc.PingAndShowMOTD(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := executeAccessRequest(cf, tc); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return trace.Wrap(onStatus(cf))
|
|
|
|
// otherwise just pass through to standard login
|
|
default:
|
|
|
|
}
|
|
}
|
|
|
|
if cf.Username == "" {
|
|
cf.Username = tc.Username
|
|
}
|
|
|
|
// stdin hijack is OK for login, since it tsh doesn't read input after the
|
|
// login ceremony is complete.
|
|
// Only allow the option during the login ceremony.
|
|
tc.AllowStdinHijack = true
|
|
|
|
key, err := tc.Login(cf.Context)
|
|
if err != nil {
|
|
if !cf.ExplicitUsername && auth.IsInvalidLocalCredentialError(err) {
|
|
fmt.Fprintf(os.Stderr, "\nhint: set the --user flag to log in as a specific user, or leave it empty to use the system user (%v)\n\n", tc.Username)
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
tc.AllowStdinHijack = false
|
|
|
|
// the login operation may update the username and should be considered the more
|
|
// "authoritative" source.
|
|
cf.Username = tc.Username
|
|
|
|
if err := tc.ActivateKey(cf.Context, key); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// TODO(fspmarshall): Refactor access request & cert reissue logic to allow
|
|
// access requests to be applied to identity files.
|
|
if cf.IdentityFileOut != "" {
|
|
// key.TrustedCA at this point only has the CA of the root cluster we
|
|
// logged into. We need to fetch all the CAs for leaf clusters too, to
|
|
// make them available in the identity file.
|
|
rootClusterName := key.TrustedCerts[0].ClusterName
|
|
authorities, err := tc.GetTrustedCA(cf.Context, rootClusterName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
key.TrustedCerts = auth.AuthoritiesToTrustedCerts(authorities)
|
|
// If we're in multiplexed mode get SNI name for kube from single multiplexed proxy addr
|
|
kubeTLSServerName := ""
|
|
if tc.TLSRoutingEnabled {
|
|
log.Debug("Using Proxy SNI for kube TLS server name")
|
|
kubeHost, _ := tc.KubeProxyHostPort()
|
|
kubeTLSServerName = client.GetKubeTLSServerName(kubeHost)
|
|
}
|
|
filesWritten, err := identityfile.Write(cf.Context, identityfile.WriteConfig{
|
|
OutputPath: cf.IdentityFileOut,
|
|
Key: key,
|
|
Format: cf.IdentityFormat,
|
|
KubeProxyAddr: tc.KubeClusterAddr(),
|
|
OverwriteDestination: cf.IdentityOverwrite,
|
|
KubeStoreAllCAs: tc.LoadAllCAs,
|
|
KubeTLSServerName: kubeTLSServerName,
|
|
KubeClusterName: tc.KubernetesCluster,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Printf("\nThe certificate has been written to %s\n", strings.Join(filesWritten, ","))
|
|
return nil
|
|
}
|
|
|
|
// Attempt device login. This activates a fresh key if successful.
|
|
// We do not save the resulting in the identity file above on purpose, as this
|
|
// certificate is bound to the present device.
|
|
if err := tc.AttemptDeviceLogin(cf.Context, key); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// If the proxy is advertising that it supports Kubernetes, update kubeconfig.
|
|
if tc.KubeProxyAddr != "" {
|
|
if err := updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
pingResp, err := tc.Ping(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Regular login without -i flag.
|
|
if err := tc.SaveProfile(true); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if autoRequest && cf.DesiredRoles == "" && cf.RequestID == "" {
|
|
var requireReason, auto bool
|
|
var prompt string
|
|
roleNames, err := key.CertRoles()
|
|
if err != nil {
|
|
logoutErr := tc.Logout()
|
|
return trace.NewAggregate(err, logoutErr)
|
|
}
|
|
// load all roles from root cluster and collect relevant options.
|
|
// the normal one-off TeleportClient methods don't re-use the auth server
|
|
// connection, so we use WithRootClusterClient to speed things up.
|
|
err = tc.WithRootClusterClient(cf.Context, func(clt auth.ClientI) error {
|
|
for _, roleName := range roleNames {
|
|
role, err := clt.GetRole(cf.Context, roleName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
requireReason = requireReason || role.GetOptions().RequestAccess.RequireReason()
|
|
auto = auto || role.GetOptions().RequestAccess.ShouldAutoRequest()
|
|
if prompt == "" {
|
|
prompt = role.GetOptions().RequestPrompt
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
logoutErr := tc.Logout()
|
|
return trace.NewAggregate(err, logoutErr)
|
|
}
|
|
if requireReason && cf.RequestReason == "" {
|
|
msg := "--request-reason must be specified"
|
|
if prompt != "" {
|
|
msg = msg + ", prompt=" + prompt
|
|
}
|
|
err := trace.BadParameter(msg)
|
|
logoutErr := tc.Logout()
|
|
return trace.NewAggregate(err, logoutErr)
|
|
}
|
|
if auto {
|
|
cf.DesiredRoles = "*"
|
|
}
|
|
}
|
|
|
|
if cf.DesiredRoles != "" || cf.RequestID != "" {
|
|
fmt.Println("") // visually separate access request output
|
|
if err := executeAccessRequest(cf, tc); err != nil {
|
|
logoutErr := tc.Logout()
|
|
return trace.NewAggregate(err, logoutErr)
|
|
}
|
|
}
|
|
|
|
// Update the command line flag for the proxy to make sure any advertised
|
|
// settings are picked up.
|
|
webProxyHost, _ := tc.WebProxyHostPort()
|
|
cf.Proxy = webProxyHost
|
|
|
|
// Print status to show information of the logged in user.
|
|
if err := onStatus(cf); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Display any license compliance warnings
|
|
for _, warning := range pingResp.LicenseWarnings {
|
|
fmt.Fprintf(os.Stderr, "%s\n\n", warning)
|
|
}
|
|
|
|
// Show on-login alerts, all high severity alerts are shown by onStatus
|
|
// so can be excluded here, except when Hardware Key Touch is required
|
|
// which skips on-status alerts.
|
|
alertSeverityMax := types.AlertSeverity_MEDIUM
|
|
if tc.PrivateKeyPolicy == keys.PrivateKeyPolicyHardwareKeyTouch {
|
|
alertSeverityMax = types.AlertSeverity_HIGH
|
|
}
|
|
|
|
if err := common.ShowClusterAlerts(cf.Context, tc, os.Stderr, map[string]string{
|
|
types.AlertOnLogin: "yes",
|
|
}, types.AlertSeverity_LOW, alertSeverityMax); err != nil {
|
|
log.WithError(err).Warn("Failed to display cluster alerts.")
|
|
}
|
|
|
|
// NOTE: we currently print all alerts that are marked as `on-login`, because we
|
|
// don't use the alert API very heavily. If we start to make more use of it, we
|
|
// could probably add a separate `tsh alerts ls` command, and truncate the list
|
|
// with a message like "run 'tsh alerts ls' to view N additional alerts".
|
|
|
|
return nil
|
|
}
|
|
|
|
// onLogout deletes a "session certificate" from ~/.tsh for a given proxy
|
|
func onLogout(cf *CLIConf) error {
|
|
// Extract all clusters the user is currently logged into.
|
|
active, available, err := cf.FullProfileStatus()
|
|
if err != nil {
|
|
if trace.IsNotFound(err) {
|
|
fmt.Printf("All users logged out.\n")
|
|
return nil
|
|
} else if trace.IsAccessDenied(err) {
|
|
fmt.Printf("%v: Logged in user does not have the correct permissions\n", err)
|
|
return nil
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
profiles := append([]*client.ProfileStatus{}, available...)
|
|
if active != nil {
|
|
profiles = append(profiles, active)
|
|
}
|
|
|
|
// Extract the proxy name.
|
|
proxyHost, _, err := net.SplitHostPort(cf.Proxy)
|
|
if err != nil {
|
|
proxyHost = cf.Proxy
|
|
}
|
|
|
|
switch {
|
|
// Proxy and username for key to remove.
|
|
case proxyHost != "" && cf.Username != "":
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Load profile for the requested proxy/user.
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil && !trace.IsNotFound(err) {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Log out user from the databases.
|
|
if profile != nil {
|
|
for _, db := range profile.Databases {
|
|
log.Debugf("Logging %v out of database %v.", profile.Name, db)
|
|
err = dbprofile.Delete(tc, db)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove keys for this user from disk and running agent.
|
|
err = tc.Logout()
|
|
if err != nil {
|
|
if trace.IsNotFound(err) {
|
|
fmt.Printf("User %v already logged out from %v.\n", cf.Username, proxyHost)
|
|
return trace.Wrap(&common.ExitCodeError{Code: 1})
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Get the address of the active Kubernetes proxy to find AuthInfos,
|
|
// Clusters, and Contexts in kubeconfig.
|
|
clusterName, _ := tc.KubeProxyHostPort()
|
|
if tc.SiteName != "" {
|
|
clusterName = fmt.Sprintf("%v.%v", tc.SiteName, clusterName)
|
|
}
|
|
|
|
// Remove Teleport related entries from kubeconfig.
|
|
log.Debugf("Removing Teleport related entries for '%v' from kubeconfig.", clusterName)
|
|
err = kubeconfig.Remove("", clusterName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
fmt.Printf("Logged out %v from %v.\n", cf.Username, proxyHost)
|
|
// Remove all keys.
|
|
case proxyHost == "" && cf.Username == "":
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Remove Teleport related entries from kubeconfig for all clusters.
|
|
for _, profile := range profiles {
|
|
log.Debugf("Removing Teleport related entries for '%v' from kubeconfig.", profile.Cluster)
|
|
err = kubeconfig.Remove("", profile.Cluster)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Remove all database access related profiles as well such as Postgres
|
|
// connection service file.
|
|
for _, profile := range profiles {
|
|
for _, db := range profile.Databases {
|
|
log.Debugf("Logging %v out of database %v.", profile.Name, db)
|
|
err = dbprofile.Delete(tc, db)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove all keys from disk and the running agent.
|
|
err = tc.LogoutAll()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
fmt.Printf("Logged out all users from all proxies.\n")
|
|
default:
|
|
fmt.Printf("Specify --proxy and --user to remove keys for specific user ")
|
|
fmt.Printf("from a proxy or neither to log out all users from all proxies.\n")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onListNodes executes 'tsh ls' command.
|
|
func onListNodes(cf *CLIConf) error {
|
|
if cf.ListAll {
|
|
return trace.Wrap(listNodesAllClusters(cf))
|
|
}
|
|
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
tc.AllowHeadless = true
|
|
|
|
// Get list of all nodes in backend and sort by "Node Name".
|
|
var nodes []types.Server
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
nodes, err = tc.ListNodesWithFilters(cf.Context)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
sort.Slice(nodes, func(i, j int) bool {
|
|
return nodes[i].GetHostname() < nodes[j].GetHostname()
|
|
})
|
|
|
|
if err := printNodes(nodes, cf); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// clusterClient is a client for a particular cluster
|
|
type clusterClient struct {
|
|
name string
|
|
connectionError error
|
|
proxy *client.ProxyClient
|
|
profile *client.ProfileStatus
|
|
req proto.ListResourcesRequest
|
|
}
|
|
|
|
func (c *clusterClient) Close() error {
|
|
if c.connectionError != nil {
|
|
return nil
|
|
}
|
|
|
|
return c.proxy.Close()
|
|
}
|
|
|
|
// getClusterClients establishes a ProxyClient to every cluster
|
|
// that the user has valid credentials for
|
|
func getClusterClients(cf *CLIConf) ([]*clusterClient, error) {
|
|
tracer := cf.TracingProvider.Tracer(teleport.ComponentTSH)
|
|
|
|
// mu guards access to clusters
|
|
var (
|
|
mu sync.Mutex
|
|
clusters []*clusterClient
|
|
)
|
|
|
|
err := forEachProfileParallel(cf, func(ctx context.Context, tc *client.TeleportClient, profile *client.ProfileStatus) error {
|
|
ctx, span := tracer.Start(
|
|
ctx,
|
|
"getClusterClient",
|
|
oteltrace.WithAttributes(attribute.String("cluster", profile.Cluster)),
|
|
)
|
|
defer span.End()
|
|
|
|
logger := log.WithField("cluster", profile.Cluster)
|
|
|
|
logger.Debug("Creating client...")
|
|
proxy, err := tc.ConnectToProxy(ctx)
|
|
if err != nil {
|
|
// log error and return nil so that results may still be retrieved
|
|
// for other clusters.
|
|
logger.Errorf("Failed connecting to proxy: %v", err)
|
|
|
|
mu.Lock()
|
|
clusters = append(clusters, &clusterClient{
|
|
name: profile.Cluster,
|
|
connectionError: trace.ConnectionProblem(err, "failed to connect to cluster %s: %v", profile.Cluster, err),
|
|
})
|
|
mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
sites, err := proxy.GetSites(ctx)
|
|
if err != nil {
|
|
// log error and create a site for the proxy we were able to
|
|
// connect to so that results are still retrieved.
|
|
logger.Errorf("Failed to lookup leaf clusters: %v", err)
|
|
sites = []types.Site{{Name: profile.Cluster}}
|
|
}
|
|
|
|
localClusters := make([]*clusterClient, 0, len(sites))
|
|
for _, site := range sites {
|
|
localClusters = append(localClusters, &clusterClient{
|
|
proxy: proxy,
|
|
profile: profile,
|
|
name: site.Name,
|
|
req: *tc.DefaultResourceFilter(),
|
|
})
|
|
}
|
|
|
|
mu.Lock()
|
|
clusters = append(clusters, localClusters...)
|
|
mu.Unlock()
|
|
|
|
return nil
|
|
})
|
|
|
|
return clusters, trace.Wrap(err)
|
|
}
|
|
|
|
type nodeListing struct {
|
|
Proxy string `json:"proxy"`
|
|
Cluster string `json:"cluster"`
|
|
Node types.Server `json:"node"`
|
|
}
|
|
|
|
type nodeListings []nodeListing
|
|
|
|
func (l nodeListings) Len() int {
|
|
return len(l)
|
|
}
|
|
|
|
func (l nodeListings) Less(i, j int) bool {
|
|
if l[i].Proxy != l[j].Proxy {
|
|
return l[i].Proxy < l[j].Proxy
|
|
}
|
|
if l[i].Cluster != l[j].Cluster {
|
|
return l[i].Cluster < l[j].Cluster
|
|
}
|
|
return l[i].Node.GetHostname() < l[j].Node.GetHostname()
|
|
}
|
|
|
|
func (l nodeListings) Swap(i, j int) {
|
|
l[i], l[j] = l[j], l[i]
|
|
}
|
|
|
|
func listNodesAllClusters(cf *CLIConf) error {
|
|
tracer := cf.TracingProvider.Tracer(teleport.ComponentTSH)
|
|
clusters, err := getClusterClients(cf)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
defer func() {
|
|
// close all clients
|
|
for _, cluster := range clusters {
|
|
_ = cluster.Close()
|
|
}
|
|
}()
|
|
|
|
// Fetch node listings for all clusters in parallel with an upper limit
|
|
group, groupCtx := errgroup.WithContext(cf.Context)
|
|
group.SetLimit(10)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
listings nodeListings
|
|
errors []error
|
|
)
|
|
|
|
for _, cluster := range clusters {
|
|
cluster := cluster
|
|
if cluster.connectionError != nil {
|
|
mu.Lock()
|
|
errors = append(errors, cluster.connectionError)
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
|
|
group.Go(func() error {
|
|
ctx, span := tracer.Start(
|
|
groupCtx,
|
|
"ListNodes",
|
|
oteltrace.WithAttributes(attribute.String("cluster", cluster.name)))
|
|
defer span.End()
|
|
|
|
logger := log.WithField("cluster", cluster.name)
|
|
nodes, err := cluster.proxy.FindNodesByFiltersForCluster(ctx, cluster.req, cluster.name)
|
|
if err != nil {
|
|
logger.Errorf("Failed to get nodes: %v.", err)
|
|
|
|
mu.Lock()
|
|
errors = append(errors, trace.ConnectionProblem(err, "failed to list nodes for cluster %s: %v", cluster.name, err))
|
|
mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
localListings := make(nodeListings, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
localListings = append(localListings, nodeListing{
|
|
Proxy: cluster.profile.ProxyURL.Host,
|
|
Cluster: cluster.name,
|
|
Node: node,
|
|
})
|
|
}
|
|
mu.Lock()
|
|
listings = append(listings, localListings...)
|
|
mu.Unlock()
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// wait for all nodes to be retrieved
|
|
if err := group.Wait(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if len(listings) == 0 && len(errors) > 0 {
|
|
return trace.NewAggregate(errors...)
|
|
}
|
|
|
|
sort.Sort(listings)
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
if err := printNodesWithClusters(listings, cf.Verbose, cf.Stdout()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeNodesWithClusters(listings, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(cf.Stdout(), out); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", format)
|
|
}
|
|
|
|
// Sometimes a user won't see any nodes because they're missing principals.
|
|
if len(listings) == 0 {
|
|
if _, err := fmt.Fprintln(cf.Stderr(), missingPrincipalsFooter); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return trace.NewAggregate(errors...)
|
|
}
|
|
|
|
func printNodesWithClusters(nodes []nodeListing, verbose bool, output io.Writer) error {
|
|
var rows [][]string
|
|
for _, n := range nodes {
|
|
rows = append(rows, getNodeRow(n.Proxy, n.Cluster, n.Node, verbose))
|
|
}
|
|
var t asciitable.Table
|
|
if verbose {
|
|
t = asciitable.MakeTable([]string{"Proxy", "Cluster", "Node Name", "Node ID", "Address", "Labels"}, rows...)
|
|
} else {
|
|
t = asciitable.MakeTableWithTruncatedColumn([]string{"Proxy", "Cluster", "Node Name", "Address", "Labels"}, rows, "Labels")
|
|
}
|
|
if _, err := fmt.Fprintln(output, t.AsBuffer().String()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func serializeNodesWithClusters(nodes []nodeListing, format string) (string, error) {
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(nodes, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(nodes)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
func getAccessRequest(ctx context.Context, tc *client.TeleportClient, requestID, username string) (types.AccessRequest, error) {
|
|
var req types.AccessRequest
|
|
err := tc.WithRootClusterClient(ctx, func(clt auth.ClientI) error {
|
|
reqs, err := clt.GetAccessRequests(ctx, types.AccessRequestFilter{
|
|
ID: requestID,
|
|
User: username,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if len(reqs) != 1 {
|
|
return trace.BadParameter(`invalid access request "%v"`, requestID)
|
|
}
|
|
req = reqs[0]
|
|
return nil
|
|
})
|
|
return req, trace.Wrap(err)
|
|
}
|
|
|
|
func createAccessRequest(cf *CLIConf) (types.AccessRequest, error) {
|
|
roles := utils.SplitIdentifiers(cf.DesiredRoles)
|
|
reviewers := utils.SplitIdentifiers(cf.SuggestedReviewers)
|
|
var requestedResourceIDs []types.ResourceID
|
|
for _, resourceIDString := range cf.RequestedResourceIDs {
|
|
resourceID, err := types.ResourceIDFromString(resourceIDString)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
requestedResourceIDs = append(requestedResourceIDs, resourceID)
|
|
}
|
|
req, err := services.NewAccessRequestWithResources(cf.Username, roles, requestedResourceIDs)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
req.SetRequestReason(cf.RequestReason)
|
|
req.SetSuggestedReviewers(reviewers)
|
|
|
|
// Only set RequestTTL and SessionTTL if values are greater than zero.
|
|
// Otherwise leave defaults and the server will take the zero values and
|
|
// transform them into default expirations accordingly.
|
|
if cf.RequestTTL > 0 {
|
|
req.SetExpiry(time.Now().UTC().Add(cf.RequestTTL))
|
|
}
|
|
if cf.SessionTTL > 0 {
|
|
req.SetAccessExpiry(time.Now().UTC().Add(cf.SessionTTL))
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func executeAccessRequest(cf *CLIConf, tc *client.TeleportClient) error {
|
|
if cf.DesiredRoles == "" && cf.RequestID == "" && len(cf.RequestedResourceIDs) == 0 {
|
|
return trace.BadParameter("at least one role or resource or a request ID must be specified")
|
|
}
|
|
if cf.RequestTTL < 0 {
|
|
return trace.BadParameter("request TTL value must be greater than zero")
|
|
}
|
|
if cf.SessionTTL < 0 {
|
|
return trace.BadParameter("session TTL value must be greater than zero")
|
|
}
|
|
if cf.Username == "" {
|
|
cf.Username = tc.Username
|
|
}
|
|
|
|
var req types.AccessRequest
|
|
var err error
|
|
if cf.RequestID != "" {
|
|
// This access request already exists, fetch it.
|
|
req, err = getAccessRequest(cf.Context, tc, cf.RequestID, cf.Username)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// If the request isn't pending, handle resolution
|
|
if !req.GetState().IsPending() {
|
|
err := onRequestResolution(cf, tc, req)
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Fprint(os.Stdout, "Request pending...\n")
|
|
} else {
|
|
// This is a new access request, create it. This just creates the local
|
|
// object, it is not yet sent to the backend.
|
|
req, err = createAccessRequest(cf)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Watch for resolution events on the given request. Start watcher and wait
|
|
// for it to be ready before creating the request to avoid a potential race.
|
|
requestWatcher := newAccessRequestWatcher(req)
|
|
defer requestWatcher.Close()
|
|
if !cf.NoWait {
|
|
// Don't initialize the watcher unless we'll actually use it.
|
|
if err := requestWatcher.initialize(cf.Context, tc); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Upsert request if it doesn't already exist.
|
|
if cf.RequestID == "" {
|
|
cf.RequestID = req.GetName()
|
|
fmt.Fprint(os.Stdout, "Creating request...\n")
|
|
// always create access request against the root cluster
|
|
if err := tc.WithRootClusterClient(cf.Context, func(clt auth.ClientI) error {
|
|
err := clt.CreateAccessRequest(cf.Context, req)
|
|
return trace.Wrap(err)
|
|
}); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
onRequestShow(cf)
|
|
fmt.Println("")
|
|
|
|
// Don't wait for request to get resolved, just print out request info.
|
|
if cf.NoWait {
|
|
return nil
|
|
}
|
|
|
|
// Wait for the request to be resolved.
|
|
fmt.Fprintf(os.Stdout, "Waiting for request approval...\n")
|
|
resolvedReq, err := requestWatcher.awaitResolution()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := requestWatcher.Close(); err != nil {
|
|
// This was deferred above to catch all other error cases, here we
|
|
// actually handle any errors from requestWatcher.Close().
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Handle resolution and update client certs if approved.
|
|
return trace.Wrap(onRequestResolution(cf, tc, resolvedReq))
|
|
}
|
|
|
|
func printNodes(nodes []types.Server, conf *CLIConf) error {
|
|
format := strings.ToLower(conf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
if err := printNodesAsText(conf.Stdout(), nodes, conf.Verbose); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeNodes(nodes, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if _, err := fmt.Fprintln(conf.Stdout(), out); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
case teleport.Names:
|
|
for _, n := range nodes {
|
|
if _, err := fmt.Fprintln(conf.Stdout(), n.GetHostname()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", format)
|
|
}
|
|
|
|
// Sometimes a user won't see any nodes because they're missing principals.
|
|
if len(nodes) == 0 {
|
|
if _, err := fmt.Fprintln(conf.Stderr(), missingPrincipalsFooter); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func serializeNodes(nodes []types.Server, format string) (string, error) {
|
|
if nodes == nil {
|
|
nodes = []types.Server{}
|
|
}
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(nodes, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(nodes)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
func getNodeRow(proxy, cluster string, node types.Server, verbose bool) []string {
|
|
// Reusable function to get addr or tunnel for each node
|
|
getAddr := func(n types.Server) string {
|
|
if n.GetUseTunnel() {
|
|
return "⟵ Tunnel"
|
|
}
|
|
return n.GetAddr()
|
|
}
|
|
|
|
row := make([]string, 0)
|
|
if proxy != "" && cluster != "" {
|
|
row = append(row, proxy, cluster)
|
|
}
|
|
|
|
if verbose {
|
|
row = append(row, node.GetHostname(), node.GetName(), getAddr(node), node.LabelsString())
|
|
} else {
|
|
row = append(row, node.GetHostname(), getAddr(node), sortedLabels(node.GetAllLabels()))
|
|
}
|
|
return row
|
|
}
|
|
|
|
func printNodesAsText(output io.Writer, nodes []types.Server, verbose bool) error {
|
|
var rows [][]string
|
|
for _, n := range nodes {
|
|
rows = append(rows, getNodeRow("", "", n, verbose))
|
|
}
|
|
var t asciitable.Table
|
|
switch verbose {
|
|
// In verbose mode, print everything on a single line and include the Node
|
|
// ID (UUID). Useful for machines that need to parse the output of "tsh ls".
|
|
case true:
|
|
t = asciitable.MakeTable([]string{"Node Name", "Node ID", "Address", "Labels"}, rows...)
|
|
// In normal mode chunk the labels and print two per line and allow multiple
|
|
// lines per node.
|
|
case false:
|
|
t = asciitable.MakeTableWithTruncatedColumn([]string{"Node Name", "Address", "Labels"}, rows, "Labels")
|
|
}
|
|
if _, err := fmt.Fprintln(output, t.AsBuffer().String()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func sortedLabels(labels map[string]string) string {
|
|
var teleportNamespaced []string
|
|
var namespaced []string
|
|
var result []string
|
|
for key, val := range labels {
|
|
if strings.HasPrefix(key, types.TeleportNamespace+"/") {
|
|
teleportNamespaced = append(teleportNamespaced, key)
|
|
continue
|
|
}
|
|
if strings.Contains(key, "/") {
|
|
namespaced = append(namespaced, fmt.Sprintf("%s=%s", key, val))
|
|
continue
|
|
}
|
|
result = append(result, fmt.Sprintf("%s=%s", key, val))
|
|
}
|
|
sort.Strings(result)
|
|
sort.Strings(namespaced)
|
|
sort.Strings(teleportNamespaced)
|
|
namespaced = append(namespaced, teleportNamespaced...)
|
|
return strings.Join(append(result, namespaced...), ",")
|
|
}
|
|
|
|
func showApps(apps []types.Application, active []tlsca.RouteToApp, format string, verbose bool) error {
|
|
format = strings.ToLower(format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
showAppsAsText(apps, active, verbose)
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeApps(apps, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", format)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func serializeApps(apps []types.Application, format string) (string, error) {
|
|
if apps == nil {
|
|
apps = []types.Application{}
|
|
}
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(apps, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(apps)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
func getAppRow(proxy, cluster string, app types.Application, active []tlsca.RouteToApp, verbose bool) []string {
|
|
var row []string
|
|
if proxy != "" && cluster != "" {
|
|
row = append(row, proxy, cluster)
|
|
}
|
|
|
|
name := app.GetName()
|
|
for _, a := range active {
|
|
if name == a.Name {
|
|
name = fmt.Sprintf("> %v", name)
|
|
break
|
|
}
|
|
}
|
|
|
|
if verbose {
|
|
row = append(row, name, app.GetDescription(), app.GetProtocol(), app.GetPublicAddr(), app.GetURI(), sortedLabels(app.GetAllLabels()))
|
|
} else {
|
|
row = append(row, name, app.GetDescription(), app.GetProtocol(), app.GetPublicAddr(), sortedLabels(app.GetAllLabels()))
|
|
}
|
|
|
|
return row
|
|
}
|
|
|
|
func showAppsAsText(apps []types.Application, active []tlsca.RouteToApp, verbose bool) {
|
|
var rows [][]string
|
|
for _, app := range apps {
|
|
rows = append(rows, getAppRow("", "", app, active, verbose))
|
|
}
|
|
// In verbose mode, print everything on a single line and include host UUID.
|
|
// In normal mode, chunk the labels, print two per line and allow multiple
|
|
// lines per node.
|
|
var t asciitable.Table
|
|
if verbose {
|
|
t = asciitable.MakeTable([]string{"Application", "Description", "Type", "Public Address", "URI", "Labels"}, rows...)
|
|
} else {
|
|
t = asciitable.MakeTableWithTruncatedColumn(
|
|
[]string{"Application", "Description", "Type", "Public Address", "Labels"}, rows, "Labels")
|
|
}
|
|
fmt.Println(t.AsBuffer().String())
|
|
}
|
|
|
|
func showDatabases(w io.Writer, clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, roleSet services.RoleSet, format string, verbose bool) error {
|
|
format = strings.ToLower(format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
showDatabasesAsText(w, clusterFlag, databases, active, roleSet, verbose)
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeDatabases(databases, format, roleSet)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Fprintln(w, out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", format)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func serializeDatabases(databases []types.Database, format string, roleSet services.RoleSet) (string, error) {
|
|
if databases == nil {
|
|
databases = []types.Database{}
|
|
}
|
|
|
|
printObj, err := getDatabasePrintObject(databases, roleSet)
|
|
if err != nil {
|
|
return "", trace.Wrap(err)
|
|
}
|
|
|
|
var out []byte
|
|
switch {
|
|
case format == teleport.JSON:
|
|
out, err = utils.FastMarshalIndent(printObj, "", " ")
|
|
default:
|
|
out, err = yaml.Marshal(printObj)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
func getDatabasePrintObject(databases []types.Database, roleSet services.RoleSet) (any, error) {
|
|
if roleSet == nil || len(databases) == 0 {
|
|
return databases, nil
|
|
}
|
|
dbsWithUsers, err := getDatabasesWithUsers(databases, roleSet)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return dbsWithUsers, nil
|
|
}
|
|
|
|
type dbUsers struct {
|
|
Allowed []string `json:"allowed,omitempty"`
|
|
Denied []string `json:"denied,omitempty"`
|
|
}
|
|
|
|
type databaseWithUsers struct {
|
|
// *DatabaseV3 is used instead of types.Database because we want the db fields marshaled to JSON inline.
|
|
// An embedded interface (like types.Database) does not inline when marshaled to JSON.
|
|
*types.DatabaseV3
|
|
Users *dbUsers `json:"users"`
|
|
}
|
|
|
|
func getDBUsers(db types.Database, roles services.RoleSet) *dbUsers {
|
|
users := roles.EnumerateDatabaseUsers(db)
|
|
var denied []string
|
|
allowed := users.Allowed()
|
|
if users.WildcardAllowed() {
|
|
// start the list with *.
|
|
allowed = append([]string{types.Wildcard}, allowed...)
|
|
// only include denied users if the wildcard is allowed.
|
|
denied = append(denied, users.Denied()...)
|
|
}
|
|
return &dbUsers{
|
|
Allowed: allowed,
|
|
Denied: denied,
|
|
}
|
|
}
|
|
|
|
func newDatabaseWithUsers(db types.Database, roles services.RoleSet) (*databaseWithUsers, error) {
|
|
dbWithUsers := &databaseWithUsers{
|
|
Users: getDBUsers(db, roles),
|
|
}
|
|
switch db := db.(type) {
|
|
case *types.DatabaseV3:
|
|
dbWithUsers.DatabaseV3 = db
|
|
default:
|
|
return nil, trace.BadParameter("unrecognized database type %T", db)
|
|
}
|
|
return dbWithUsers, nil
|
|
}
|
|
|
|
func getDatabasesWithUsers(databases types.Databases, roles services.RoleSet) ([]*databaseWithUsers, error) {
|
|
var dbsWithUsers []*databaseWithUsers
|
|
for _, db := range databases {
|
|
dbWithUsers, err := newDatabaseWithUsers(db, roles)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
dbsWithUsers = append(dbsWithUsers, dbWithUsers)
|
|
}
|
|
return dbsWithUsers, nil
|
|
}
|
|
|
|
func serializeDatabasesAllClusters(dbListings []databaseListing, format string) (string, error) {
|
|
if dbListings == nil {
|
|
dbListings = []databaseListing{}
|
|
}
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(dbListings, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(dbListings)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
func formatUsersForDB(database types.Database, roleSet services.RoleSet) string {
|
|
// may happen if fetching the role set failed for any reason.
|
|
if roleSet == nil {
|
|
return "(unknown)"
|
|
}
|
|
|
|
dbUsers := getDBUsers(database, roleSet)
|
|
if len(dbUsers.Allowed) == 0 {
|
|
return "(none)"
|
|
}
|
|
|
|
if len(dbUsers.Denied) == 0 {
|
|
return fmt.Sprintf("%v", dbUsers.Allowed)
|
|
}
|
|
return fmt.Sprintf("%v, except: %v", dbUsers.Allowed, dbUsers.Denied)
|
|
}
|
|
|
|
func getDatabaseRow(proxy, cluster, clusterFlag string, database types.Database, active []tlsca.RouteToDatabase, roleSet services.RoleSet, verbose bool) []string {
|
|
name := database.GetName()
|
|
var connect string
|
|
for _, a := range active {
|
|
if a.ServiceName == name {
|
|
name = formatActiveDB(a)
|
|
switch a.Protocol {
|
|
case defaults.ProtocolDynamoDB:
|
|
// DynamoDB does not support "tsh db connect", so print the proxy command instead.
|
|
connect = formatDatabaseProxyCommand(clusterFlag, a)
|
|
default:
|
|
connect = formatDatabaseConnectCommand(clusterFlag, a)
|
|
}
|
|
}
|
|
}
|
|
|
|
row := make([]string, 0)
|
|
if proxy != "" && cluster != "" {
|
|
row = append(row, proxy, cluster)
|
|
}
|
|
|
|
if verbose {
|
|
row = append(row,
|
|
name,
|
|
database.GetDescription(),
|
|
database.GetProtocol(),
|
|
database.GetType(),
|
|
database.GetURI(),
|
|
formatUsersForDB(database, roleSet),
|
|
database.LabelsString(),
|
|
connect,
|
|
database.Expiry().Format(constants.HumanDateFormatSeconds),
|
|
)
|
|
} else {
|
|
row = append(row,
|
|
name,
|
|
database.GetDescription(),
|
|
formatUsersForDB(database, roleSet),
|
|
formatDatabaseLabels(database),
|
|
connect,
|
|
)
|
|
}
|
|
|
|
return row
|
|
}
|
|
|
|
func showDatabasesAsText(w io.Writer, clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, roleSet services.RoleSet, verbose bool) {
|
|
var rows [][]string
|
|
for _, database := range databases {
|
|
rows = append(rows, getDatabaseRow("", "",
|
|
clusterFlag,
|
|
database,
|
|
active,
|
|
roleSet,
|
|
verbose))
|
|
}
|
|
var t asciitable.Table
|
|
if verbose {
|
|
t = asciitable.MakeTable([]string{"Name", "Description", "Protocol", "Type", "URI", "Allowed Users", "Labels", "Connect", "Expires"}, rows...)
|
|
} else {
|
|
t = asciitable.MakeTableWithTruncatedColumn([]string{"Name", "Description", "Allowed Users", "Labels", "Connect"}, rows, "Labels")
|
|
}
|
|
fmt.Fprintln(w, t.AsBuffer().String())
|
|
}
|
|
|
|
func printDatabasesWithClusters(clusterFlag string, dbListings []databaseListing, active []tlsca.RouteToDatabase, verbose bool) {
|
|
var rows [][]string
|
|
for _, listing := range dbListings {
|
|
rows = append(rows, getDatabaseRow(
|
|
listing.Proxy,
|
|
listing.Cluster,
|
|
clusterFlag,
|
|
listing.Database,
|
|
active,
|
|
listing.roleSet,
|
|
verbose))
|
|
}
|
|
var t asciitable.Table
|
|
if verbose {
|
|
t = asciitable.MakeTable([]string{"Proxy", "Cluster", "Name", "Description", "Protocol", "Type", "URI", "Allowed Users", "Labels", "Connect", "Expires"}, rows...)
|
|
} else {
|
|
t = asciitable.MakeTableWithTruncatedColumn(
|
|
[]string{"Proxy", "Cluster", "Name", "Description", "Allowed Users", "Labels", "Connect"},
|
|
rows,
|
|
"Labels",
|
|
)
|
|
}
|
|
fmt.Println(t.AsBuffer().String())
|
|
}
|
|
|
|
func formatDatabaseLabels(database types.Database) string {
|
|
labels := database.GetAllLabels()
|
|
// Hide the origin label unless printing verbose table.
|
|
delete(labels, types.OriginLabel)
|
|
return sortedLabels(labels)
|
|
}
|
|
|
|
func formatActiveDB(active tlsca.RouteToDatabase) string {
|
|
switch {
|
|
case active.Username != "" && active.Database != "":
|
|
return fmt.Sprintf("> %v (user: %v, db: %v)", active.ServiceName, active.Username, active.Database)
|
|
case active.Username != "":
|
|
return fmt.Sprintf("> %v (user: %v)", active.ServiceName, active.Username)
|
|
case active.Database != "":
|
|
return fmt.Sprintf("> %v (db: %v)", active.ServiceName, active.Database)
|
|
}
|
|
return fmt.Sprintf("> %v", active.ServiceName)
|
|
}
|
|
|
|
// onListClusters executes 'tsh clusters' command
|
|
func onListClusters(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
var rootClusterName string
|
|
var leafClusters []types.RemoteCluster
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
proxyClient, err := tc.ConnectToProxy(cf.Context)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer proxyClient.Close()
|
|
|
|
var rootErr, leafErr error
|
|
rootClusterName, rootErr = proxyClient.RootClusterName(cf.Context)
|
|
leafClusters, leafErr = proxyClient.GetLeafClusters(cf.Context)
|
|
return trace.NewAggregate(rootErr, leafErr)
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
profile, err := cf.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
isSelected := func(clusterName string) bool {
|
|
return profile != nil && clusterName == profile.Cluster
|
|
}
|
|
showSelected := func(clusterName string) string {
|
|
if isSelected(clusterName) {
|
|
return "*"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
var t asciitable.Table
|
|
if cf.Quiet {
|
|
t = asciitable.MakeHeadlessTable(4)
|
|
} else {
|
|
t = asciitable.MakeTable([]string{"Cluster Name", "Status", "Cluster Type", "Labels", "Selected"})
|
|
}
|
|
|
|
t.AddRow([]string{
|
|
rootClusterName, teleport.RemoteClusterStatusOnline, "root", "", showSelected(rootClusterName),
|
|
})
|
|
for _, cluster := range leafClusters {
|
|
labels := sortedLabels(cluster.GetMetadata().Labels)
|
|
t.AddRow([]string{
|
|
cluster.GetName(), cluster.GetConnectionStatus(), "leaf", labels, showSelected(cluster.GetName()),
|
|
})
|
|
}
|
|
fmt.Println(t.AsBuffer().String())
|
|
case teleport.JSON, teleport.YAML:
|
|
rootClusterInfo := clusterInfo{
|
|
ClusterName: rootClusterName,
|
|
Status: teleport.RemoteClusterStatusOnline,
|
|
ClusterType: "root",
|
|
Selected: isSelected(rootClusterName),
|
|
}
|
|
leafClusterInfo := make([]clusterInfo, 0, len(leafClusters))
|
|
for _, leaf := range leafClusters {
|
|
leafClusterInfo = append(leafClusterInfo, clusterInfo{
|
|
ClusterName: leaf.GetName(),
|
|
Status: leaf.GetConnectionStatus(),
|
|
ClusterType: "leaf",
|
|
Labels: leaf.GetMetadata().Labels,
|
|
Selected: isSelected(leaf.GetName()),
|
|
})
|
|
}
|
|
out, err := serializeClusters(rootClusterInfo, leafClusterInfo, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", cf.Format)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type clusterInfo struct {
|
|
ClusterName string `json:"cluster_name"`
|
|
Status string `json:"status"`
|
|
ClusterType string `json:"cluster_type"`
|
|
Labels map[string]string `json:"labels"`
|
|
Selected bool `json:"selected"`
|
|
}
|
|
|
|
func serializeClusters(rootCluster clusterInfo, leafClusters []clusterInfo, format string) (string, error) {
|
|
clusters := []clusterInfo{rootCluster}
|
|
clusters = append(clusters, leafClusters...)
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(clusters, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(clusters)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
// accessRequestForSSH attempts to create a resource access request for the case
|
|
// where "tsh ssh" was attempted and access was denied
|
|
func accessRequestForSSH(ctx context.Context, tc *client.TeleportClient) (types.AccessRequest, error) {
|
|
proxyClient, err := tc.ConnectToProxy(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
defer proxyClient.Close()
|
|
|
|
// Match on hostname or host ID, user could have given either
|
|
expr := fmt.Sprintf(hostnameOrIDPredicateTemplate, tc.Host)
|
|
filter := proto.ListResourcesRequest{
|
|
UseSearchAsRoles: true,
|
|
PredicateExpression: expr,
|
|
}
|
|
nodes, err := proxyClient.FindNodesByFilters(ctx, filter)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if len(nodes) > 1 {
|
|
// Ambiguous hostname matches should have been handled by onSSH and
|
|
// would not make it here, this is a sanity check. Ambiguous host ID
|
|
// matches should be impossible.
|
|
return nil, trace.NotFound("hostname %q is ambiguous and matches multiple nodes, unable to request access", tc.Host)
|
|
}
|
|
if len(nodes) == 0 {
|
|
// Did not find any nodes by hostname or ID.
|
|
return nil, trace.NotFound("node %q not found, unable to request access", tc.Host)
|
|
}
|
|
|
|
// At this point we have exactly 1 node.
|
|
node := nodes[0]
|
|
requestResourceIDs := []types.ResourceID{{
|
|
ClusterName: tc.SiteName,
|
|
Kind: types.KindNode,
|
|
Name: node.GetName(),
|
|
}}
|
|
|
|
// Roles to request will be automatically determined on the backend.
|
|
req, err := services.NewAccessRequestWithResources(tc.Username, nil /* roles */, requestResourceIDs)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
req.SetLoginHint(tc.HostLogin)
|
|
|
|
// Set the DryRun flag and send the request to auth for full validation. If
|
|
// the user has no search_as_roles or is not allowed to SSH to the host with
|
|
// the requested login, we will get an error here.
|
|
req.SetDryRun(true)
|
|
req.SetRequestReason("Dry run, this request will not be created. If you see this, there is a bug.")
|
|
if err := tc.WithRootClusterClient(ctx, func(clt auth.ClientI) error {
|
|
return trace.Wrap(clt.CreateAccessRequest(ctx, req))
|
|
}); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
req.SetDryRun(false)
|
|
req.SetRequestReason("")
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func retryWithAccessRequest(cf *CLIConf, tc *client.TeleportClient, fn func() error) error {
|
|
origErr := fn()
|
|
if cf.disableAccessRequest || !trace.IsAccessDenied(origErr) || tc.Host == "" {
|
|
// Return if --disable-access-request was specified.
|
|
// Return the original error if it's not AccessDenied.
|
|
// Quit now if we don't have a hostname.
|
|
return trace.Wrap(origErr)
|
|
}
|
|
|
|
// Try to construct an access request for this node.
|
|
req, err := accessRequestForSSH(cf.Context, tc)
|
|
if err != nil {
|
|
// We can't request access to the node or we couldn't query the ID. Log
|
|
// a short debug message in case this is unexpected, but return the
|
|
// original AccessDenied error from the ssh attempt which is likely to
|
|
// be far more relevant to the user.
|
|
log.Debugf("Not attempting to automatically request access, reason: %v", err)
|
|
return trace.Wrap(origErr)
|
|
}
|
|
cf.RequestID = req.GetName()
|
|
|
|
// Print and log the original AccessDenied error.
|
|
fmt.Fprintln(os.Stderr, utils.UserMessageFromError(origErr))
|
|
fmt.Fprintf(os.Stdout, "You do not currently have access to %s@%s, attempting to request access.\n\n", tc.HostLogin, tc.Host)
|
|
|
|
requestReason := cf.RequestReason
|
|
if requestReason == "" {
|
|
// Prompt for a request reason.
|
|
requestReason, err = prompt.Input(cf.Context, os.Stdout, prompt.Stdin(), "Enter request reason")
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
req.SetRequestReason(requestReason)
|
|
|
|
// Watch for resolution events on the given request. Start watcher and wait
|
|
// for it to be ready before creating the request to avoid a potential race.
|
|
requestWatcher := newAccessRequestWatcher(req)
|
|
defer requestWatcher.Close()
|
|
if err := requestWatcher.initialize(cf.Context, tc); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
fmt.Fprint(os.Stdout, "Creating request...\n")
|
|
// Always create access request against the root cluster.
|
|
if err := tc.WithRootClusterClient(cf.Context, func(clt auth.ClientI) error {
|
|
return trace.Wrap(clt.CreateAccessRequest(cf.Context, req))
|
|
}); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if cf.Username == "" {
|
|
cf.Username = tc.Username
|
|
}
|
|
// re-fetch the request to display it with roles populated.
|
|
onRequestShow(cf)
|
|
fmt.Println("")
|
|
|
|
// Wait for the request to be resolved.
|
|
fmt.Fprintf(os.Stdout, "Waiting for request approval...\n")
|
|
resolvedReq, err := requestWatcher.awaitResolution()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := requestWatcher.Close(); err != nil {
|
|
// This was deferred above to catch all other error cases, here we
|
|
// actually handle any errors from requestWatcher.Close().
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Handle resolution and update client certs if approved.
|
|
if err := onRequestResolution(cf, tc, resolvedReq); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Retry now that request has been approved and certs updated.
|
|
// Clear the original exit status.
|
|
tc.ExitStatus = 0
|
|
return trace.Wrap(fn())
|
|
}
|
|
|
|
// onSSH executes 'tsh ssh' command
|
|
func onSSH(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
tc.AllowHeadless = true
|
|
|
|
tc.Stdin = os.Stdin
|
|
err = retryWithAccessRequest(cf, tc, func() error {
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
return tc.SSH(cf.Context, cf.RemoteCommand, cf.LocalExec)
|
|
})
|
|
if err != nil {
|
|
if strings.Contains(utils.UserMessageFromError(err), teleport.NodeIsAmbiguous) {
|
|
// Match on hostname or host ID, user could have given either
|
|
expr := fmt.Sprintf(hostnameOrIDPredicateTemplate, tc.Host)
|
|
tc.PredicateExpression = expr
|
|
nodes, err := tc.ListNodesWithFilters(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Fprintf(cf.Stderr(), "error: ambiguous host could match multiple nodes\n\n")
|
|
printNodesAsText(cf.Stderr(), nodes, true)
|
|
fmt.Fprintf(cf.Stderr(), "Hint: try addressing the node by unique id (ex: tsh ssh user@node-id)\n")
|
|
fmt.Fprintf(cf.Stderr(), "Hint: use 'tsh ls -v' to list all nodes with their unique ids\n")
|
|
fmt.Fprintf(cf.Stderr(), "\n")
|
|
return trace.Wrap(&common.ExitCodeError{Code: 1})
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
})
|
|
// Exit with the same exit status as the failed command.
|
|
if tc.ExitStatus != 0 {
|
|
var exitErr *common.ExitCodeError
|
|
if errors.As(err, &exitErr) {
|
|
// Already have an exitCodeError, return that.
|
|
return trace.Wrap(err)
|
|
}
|
|
if err != nil {
|
|
// Print the error here so we don't lose it when returning the exitCodeError.
|
|
fmt.Fprintln(os.Stderr, utils.UserMessageFromError(err))
|
|
}
|
|
err = &common.ExitCodeError{Code: tc.ExitStatus}
|
|
return trace.Wrap(err)
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// onBenchmark executes benchmark
|
|
func onBenchmark(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
cnf := benchmark.Config{
|
|
Command: cf.RemoteCommand,
|
|
MinimumWindow: cf.BenchDuration,
|
|
Rate: cf.BenchRate,
|
|
}
|
|
result, err := cnf.Benchmark(cf.Context, tc)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, utils.UserMessageFromError(err))
|
|
return trace.Wrap(&common.ExitCodeError{Code: 255})
|
|
}
|
|
fmt.Printf("\n")
|
|
fmt.Printf("* Requests originated: %v\n", result.RequestsOriginated)
|
|
fmt.Printf("* Requests failed: %v\n", result.RequestsFailed)
|
|
if result.LastError != nil {
|
|
fmt.Printf("* Last error: %v\n", result.LastError)
|
|
}
|
|
fmt.Printf("\nHistogram\n\n")
|
|
t := asciitable.MakeTable([]string{"Percentile", "Response Duration"})
|
|
for _, quantile := range []float64{25, 50, 75, 90, 95, 99, 100} {
|
|
t.AddRow([]string{
|
|
fmt.Sprintf("%v", quantile),
|
|
fmt.Sprintf("%v ms", result.Histogram.ValueAtQuantile(quantile)),
|
|
})
|
|
}
|
|
if _, err := io.Copy(os.Stdout, t.AsBuffer()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Printf("\n")
|
|
if cf.BenchExport {
|
|
path, err := benchmark.ExportLatencyProfile(cf.BenchExportPath, result.Histogram, cf.BenchTicks, cf.BenchValueScale)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed exporting latency profile: %s\n", utils.UserMessageFromError(err))
|
|
} else {
|
|
fmt.Printf("latency profile saved: %v\n", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onJoin executes 'ssh join' command
|
|
func onJoin(cf *CLIConf) error {
|
|
if err := validateParticipantMode(types.SessionParticipantMode(cf.JoinMode)); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
cf.NodeLogin = teleport.SSHSessionJoinPrincipal
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
sid, err := session.ParseID(cf.SessionID)
|
|
if err != nil {
|
|
return trace.BadParameter("'%v' is not a valid session ID (must be GUID)", cf.SessionID)
|
|
}
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
return tc.Join(context.TODO(), types.SessionParticipantMode(cf.JoinMode), cf.Namespace, *sid, nil)
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onSCP executes 'tsh scp' command
|
|
func onSCP(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
tc.AllowHeadless = true
|
|
|
|
// allow the file transfer to be gracefully stopped if the user wishes
|
|
ctx, cancel := signal.NotifyContext(cf.Context, os.Interrupt)
|
|
cf.Context = ctx
|
|
defer cancel()
|
|
|
|
opts := sftp.Options{
|
|
Recursive: cf.RecursiveCopy,
|
|
PreserveAttrs: cf.PreserveAttrs,
|
|
}
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
return tc.SFTP(cf.Context, cf.CopySpec, int(cf.NodePort), opts, cf.Quiet)
|
|
})
|
|
// don't print context canceled errors to the user
|
|
if err == nil || (err != nil && errors.Is(err, context.Canceled)) {
|
|
return nil
|
|
}
|
|
fmt.Fprintln(os.Stderr, utils.UserMessageFromError(err))
|
|
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// makeClient takes the command-line configuration and constructs & returns
|
|
// a fully configured TeleportClient object
|
|
func makeClient(cf *CLIConf, useProfileLogin bool) (*client.TeleportClient, error) {
|
|
tc, err := makeClientForProxy(cf, cf.Proxy, useProfileLogin)
|
|
return tc, trace.Wrap(err)
|
|
}
|
|
|
|
// makeClient takes the command-line configuration and a proxy address and constructs & returns
|
|
// a fully configured TeleportClient object
|
|
func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*client.TeleportClient, error) {
|
|
// Parse OpenSSH style options.
|
|
options, err := parseOptions(cf.Options)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// apply defaults
|
|
if cf.MinsToLive == 0 {
|
|
cf.MinsToLive = int32(apidefaults.CertDuration / time.Minute)
|
|
}
|
|
|
|
// split login & host
|
|
hostLogin := cf.NodeLogin
|
|
hostUser := cf.UserHost
|
|
var labels map[string]string
|
|
if hostUser != "" {
|
|
parts := strings.Split(hostUser, "@")
|
|
partsLength := len(parts)
|
|
if partsLength > 1 {
|
|
hostLogin = strings.Join(parts[:partsLength-1], "@")
|
|
hostUser = parts[partsLength-1]
|
|
}
|
|
// see if remote host is specified as a set of labels
|
|
if strings.Contains(hostUser, "=") {
|
|
labels, err = client.ParseLabelSpec(hostUser)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
} else if cf.CopySpec != nil {
|
|
for _, location := range cf.CopySpec {
|
|
// Extract username and host from "username@host:file/path"
|
|
parts := strings.Split(location, ":")
|
|
parts = strings.Split(parts[0], "@")
|
|
partsLength := len(parts)
|
|
if partsLength > 1 {
|
|
hostLogin = strings.Join(parts[:partsLength-1], "@")
|
|
hostUser = parts[partsLength-1]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
fPorts, err := client.ParsePortForwardSpec(cf.LocalForwardPorts)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
dPorts, err := client.ParseDynamicPortForwardSpec(cf.DynamicForwardedPorts)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// 1: start with the defaults
|
|
c := client.MakeDefaultConfig()
|
|
c.Host = hostUser
|
|
if cf.TracingProvider == nil {
|
|
cf.TracingProvider = tracing.NoopProvider()
|
|
}
|
|
c.Tracer = cf.TracingProvider.Tracer(teleport.ComponentTSH)
|
|
cf.tracer = c.Tracer
|
|
ctx, span := c.Tracer.Start(cf.Context, "makeClientForProxy/init")
|
|
defer span.End()
|
|
|
|
// ProxyJump is an alias of Proxy flag
|
|
if cf.ProxyJump != "" {
|
|
proxyJump := cf.ProxyJump
|
|
if strings.Contains(cf.ProxyJump, "{{proxy}}") {
|
|
proxy, host, matched := cf.TshConfig.ProxyTemplates.Apply(c.Host)
|
|
if !matched {
|
|
return nil, trace.BadParameter("proxy jump contains {{proxy}} variable but did not match any of the templates in tsh config")
|
|
}
|
|
proxyJump = strings.ReplaceAll(proxyJump, "{{proxy}}", proxy)
|
|
c.Host = host
|
|
log.Debugf("Will connect to proxy %q and host %q according to proxy templates.", proxyJump, host)
|
|
}
|
|
hosts, err := utils.ParseProxyJump(proxyJump)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
c.JumpHosts = hosts
|
|
}
|
|
|
|
// --headless is shorthand for --auth=headless
|
|
if cf.Headless {
|
|
if cf.AuthConnector != "" && cf.AuthConnector != constants.HeadlessConnector {
|
|
return nil, trace.BadParameter("either --headless or --auth can be specified, not both")
|
|
}
|
|
cf.AuthConnector = constants.HeadlessConnector
|
|
}
|
|
|
|
c.ClientStore, err = initClientStore(cf, proxy)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// load profile. if no --proxy is given the currently active profile is used, otherwise
|
|
// fetch profile for exact proxy we are trying to connect to.
|
|
profileErr := c.LoadProfile(c.ClientStore, proxy)
|
|
if profileErr != nil && !trace.IsNotFound(profileErr) {
|
|
fmt.Printf("WARNING: Failed to load tsh profile for %q: %v\n", proxy, profileErr)
|
|
}
|
|
|
|
// 3: override with the CLI flags
|
|
if cf.Namespace != "" {
|
|
c.Namespace = cf.Namespace
|
|
}
|
|
if cf.Username != "" {
|
|
c.Username = cf.Username
|
|
}
|
|
c.ExplicitUsername = cf.ExplicitUsername
|
|
// if proxy is set, and proxy is not equal to profile's
|
|
// loaded addresses, override the values
|
|
if err := setClientWebProxyAddr(ctx, cf, c); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if c.ExtraProxyHeaders == nil {
|
|
c.ExtraProxyHeaders = map[string]string{}
|
|
}
|
|
for _, proxyHeaders := range cf.TshConfig.ExtraHeaders {
|
|
proxyGlob := utils.GlobToRegexp(proxyHeaders.Proxy)
|
|
proxyRegexp, err := regexp.Compile(proxyGlob)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err, "invalid proxy glob %q in tsh configuration file", proxyGlob)
|
|
}
|
|
if proxyRegexp.MatchString(c.WebProxyAddr) {
|
|
for k, v := range proxyHeaders.Headers {
|
|
c.ExtraProxyHeaders[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(fPorts) > 0 {
|
|
c.LocalForwardPorts = fPorts
|
|
}
|
|
if len(dPorts) > 0 {
|
|
c.DynamicForwardedPorts = dPorts
|
|
}
|
|
profileSiteName := c.SiteName
|
|
if cf.SiteName != "" {
|
|
c.SiteName = cf.SiteName
|
|
}
|
|
if cf.KubernetesCluster != "" {
|
|
c.KubernetesCluster = cf.KubernetesCluster
|
|
}
|
|
if cf.DatabaseService != "" {
|
|
c.DatabaseService = cf.DatabaseService
|
|
}
|
|
// if host logins stored in profiles must be ignored...
|
|
if !useProfileLogin {
|
|
c.HostLogin = ""
|
|
}
|
|
if hostLogin != "" {
|
|
c.HostLogin = hostLogin
|
|
}
|
|
c.HostPort = int(cf.NodePort)
|
|
c.Labels = labels
|
|
c.KeyTTL = time.Minute * time.Duration(cf.MinsToLive)
|
|
c.InsecureSkipVerify = cf.InsecureSkipVerify
|
|
c.PredicateExpression = cf.PredicateExpression
|
|
|
|
if cf.SearchKeywords != "" {
|
|
c.SearchKeywords = client.ParseSearchKeywords(cf.SearchKeywords, ',')
|
|
}
|
|
|
|
// If a TTY was requested, make sure to allocate it. Note this applies to
|
|
// "exec" command because a shell always has a TTY allocated.
|
|
if cf.Interactive || options.RequestTTY {
|
|
c.InteractiveCommand = true
|
|
}
|
|
|
|
if !cf.NoCache {
|
|
c.CachePolicy = &client.CachePolicy{}
|
|
}
|
|
|
|
// check version compatibility of the server and client
|
|
c.CheckVersions = !cf.SkipVersionCheck
|
|
|
|
// parse compatibility parameter
|
|
certificateFormat, err := parseCertificateCompatibilityFlag(cf.Compatibility, cf.CertificateFormat)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
c.CertificateFormat = certificateFormat
|
|
|
|
// copy the authentication connector over
|
|
if cf.AuthConnector != "" {
|
|
c.AuthConnector = cf.AuthConnector
|
|
}
|
|
mfaOpts, err := parseMFAMode(cf.MFAMode)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
c.AuthenticatorAttachment = mfaOpts.AuthenticatorAttachment
|
|
c.PreferOTP = mfaOpts.PreferOTP
|
|
|
|
// If agent forwarding was specified on the command line enable it.
|
|
c.ForwardAgent = options.ForwardAgent
|
|
if cf.ForwardAgent {
|
|
c.ForwardAgent = client.ForwardAgentYes
|
|
}
|
|
|
|
if err := setX11Config(c, cf, options, os.Getenv); err != nil {
|
|
log.WithError(err).Info("X11 forwarding is not properly configured, continuing without it.")
|
|
}
|
|
|
|
// If the caller does not want to check host keys, pass in a insecure host
|
|
// key checker.
|
|
if !options.StrictHostKeyChecking {
|
|
c.HostKeyCallback = client.InsecureSkipHostKeyChecking
|
|
}
|
|
c.BindAddr = cf.BindAddr
|
|
|
|
// Don't execute remote command, used when port forwarding.
|
|
c.NoRemoteExec = cf.NoRemoteExec
|
|
|
|
// Allow the default browser used to open tsh login links to be overridden
|
|
// (not currently implemented) or set to 'none' to suppress browser opening entirely.
|
|
c.Browser = cf.Browser
|
|
|
|
c.AddKeysToAgent = cf.AddKeysToAgent
|
|
if !cf.UseLocalSSHAgent {
|
|
c.AddKeysToAgent = client.AddKeysToAgentNo
|
|
}
|
|
|
|
// avoid adding keys to agent when using an identity file.
|
|
if (cf.IdentityFileOut != "" || cf.IdentityFileIn != "") && c.AddKeysToAgent == client.AddKeysToAgentAuto {
|
|
c.AddKeysToAgent = client.AddKeysToAgentNo
|
|
}
|
|
|
|
c.EnableEscapeSequences = cf.EnableEscapeSequences
|
|
|
|
// pass along mock sso login if provided (only used in tests)
|
|
c.MockSSOLogin = cf.mockSSOLogin
|
|
|
|
// Set tsh home directory
|
|
c.HomePath = cf.HomePath
|
|
|
|
if c.KeysDir == "" {
|
|
c.KeysDir = c.HomePath
|
|
}
|
|
|
|
if cf.IdentityFileIn != "" {
|
|
c.NonInteractive = true
|
|
}
|
|
|
|
tc, err := client.NewClient(c)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Load SSH key for the cluster indicated in the profile.
|
|
// Handle gracefully if the profile is empty, the key cannot
|
|
// be found, or the key isn't supported as an agent key.
|
|
if profileSiteName != "" {
|
|
if err := tc.LoadKeyForCluster(ctx, profileSiteName); err != nil {
|
|
if !trace.IsNotFound(err) && !trace.IsConnectionProblem(err) {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
log.WithError(err).Infof("Could not load key for %s into the local agent.", profileSiteName)
|
|
}
|
|
}
|
|
|
|
// If we are missing client profile information, ping the webproxy
|
|
// for proxy info and load it into the client config.
|
|
if profileErr != nil || cf.IdentityFileIn != "" {
|
|
log.Debug("Pinging the proxy to fetch listening addresses for non-web ports.")
|
|
_, err := tc.Ping(cf.Context)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Identityfile uses a placeholder profile. Save missing profile info.
|
|
if cf.IdentityFileIn != "" {
|
|
if err := tc.SaveProfile(true); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
tc.Config.Stderr = cf.Stderr()
|
|
tc.Config.Stdout = cf.Stdout()
|
|
|
|
tc.Config.Reason = cf.Reason
|
|
tc.Config.Invited = cf.Invited
|
|
tc.Config.DisplayParticipantRequirements = cf.displayParticipantRequirements
|
|
return tc, nil
|
|
}
|
|
|
|
func initClientStore(cf *CLIConf, proxy string) (*client.Store, error) {
|
|
switch {
|
|
case cf.IdentityFileIn != "":
|
|
// Import identity file keys to in-memory client store.
|
|
clientStore, err := identityfile.NewClientStoreFromIdentityFile(cf.IdentityFileIn, proxy, cf.SiteName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return clientStore, nil
|
|
|
|
case cf.IdentityFileOut != "", cf.AuthConnector == constants.HeadlessConnector:
|
|
// Store client keys in memory, where they can be exported to non-standard
|
|
// FS formats (e.g. identity file) or used for a single client call in memory.
|
|
return client.NewMemClientStore(), nil
|
|
|
|
case cf.AddKeysToAgent == client.AddKeysToAgentOnly:
|
|
// Store client keys in memory, but save trusted certs and profile to disk.
|
|
clientStore := client.NewFSClientStore(cf.HomePath)
|
|
clientStore.KeyStore = client.NewMemKeyStore()
|
|
return clientStore, nil
|
|
|
|
default:
|
|
return client.NewFSClientStore(cf.HomePath), nil
|
|
}
|
|
}
|
|
|
|
func (c *CLIConf) ProfileStatus() (*client.ProfileStatus, error) {
|
|
clientStore, err := initClientStore(c, c.Proxy)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
profile, err := clientStore.ReadProfileStatus(c.Proxy)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
func (c *CLIConf) FullProfileStatus() (*client.ProfileStatus, []*client.ProfileStatus, error) {
|
|
clientStore, err := initClientStore(c, c.Proxy)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
currentProfile, profiles, err := clientStore.FullProfileStatus()
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
return currentProfile, profiles, nil
|
|
}
|
|
|
|
// ListProfiles returns a list of profiles the current user has
|
|
// credentials for.
|
|
func (c *CLIConf) ListProfiles() ([]*client.ProfileStatus, error) {
|
|
clientStore, err := initClientStore(c, c.Proxy)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
profileNames, err := clientStore.ListProfiles()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
profiles := make([]*client.ProfileStatus, 0, len(profileNames))
|
|
for _, profileName := range profileNames {
|
|
status, err := clientStore.ReadProfileStatus(profileName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
profiles = append(profiles, status)
|
|
}
|
|
|
|
return profiles, nil
|
|
}
|
|
|
|
type mfaModeOpts struct {
|
|
AuthenticatorAttachment wancli.AuthenticatorAttachment
|
|
PreferOTP bool
|
|
}
|
|
|
|
func parseMFAMode(mode string) (*mfaModeOpts, error) {
|
|
opts := &mfaModeOpts{}
|
|
switch mode {
|
|
case "", mfaModeAuto:
|
|
case mfaModeCrossPlatform:
|
|
opts.AuthenticatorAttachment = wancli.AttachmentCrossPlatform
|
|
case mfaModePlatform:
|
|
opts.AuthenticatorAttachment = wancli.AttachmentPlatform
|
|
case mfaModeOTP:
|
|
opts.PreferOTP = true
|
|
default:
|
|
return nil, fmt.Errorf("invalid MFA mode: %q", mode)
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
// setX11Config sets X11 config using CLI and SSH option flags.
|
|
func setX11Config(c *client.Config, cf *CLIConf, o Options, fn envGetter) error {
|
|
// X11 forwarding can be enabled with -X, -Y, or -oForwardX11=yes
|
|
c.EnableX11Forwarding = cf.X11ForwardingUntrusted || cf.X11ForwardingTrusted || o.ForwardX11
|
|
|
|
if c.EnableX11Forwarding && fn(x11.DisplayEnv) == "" {
|
|
c.EnableX11Forwarding = false
|
|
return trace.BadParameter("$DISPLAY must be set for X11 forwarding")
|
|
}
|
|
|
|
c.X11ForwardingTrusted = cf.X11ForwardingTrusted
|
|
if o.ForwardX11Trusted != nil && *o.ForwardX11Trusted {
|
|
c.X11ForwardingTrusted = true
|
|
}
|
|
|
|
// Set X11 forwarding timeout, prioritizing the SSH option if set.
|
|
c.X11ForwardingTimeout = o.ForwardX11Timeout
|
|
if c.X11ForwardingTimeout == 0 {
|
|
c.X11ForwardingTimeout = cf.X11ForwardingTimeout
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// defaultWebProxyPorts is the order of default proxy ports to try, in order that
|
|
// they will be tried.
|
|
var defaultWebProxyPorts = []int{
|
|
defaults.HTTPListenPort, teleport.StandardHTTPSPort,
|
|
}
|
|
|
|
// proxyHostsErrorMsgDefault returns the error message from attempting hosts at
|
|
// different ports for the Web Proxy.
|
|
func proxyHostsErrorMsgDefault(proxyAddress string, ports []int) string {
|
|
buf := &bytes.Buffer{}
|
|
buf.WriteString("Teleport proxy not available at proxy address ")
|
|
|
|
for i, port := range ports {
|
|
if i > 0 {
|
|
buf.WriteString(" or ")
|
|
}
|
|
buf.WriteString(proxyAddress)
|
|
buf.WriteString(":")
|
|
buf.WriteString(strconv.Itoa(port))
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
// setClientWebProxyAddr configures the client WebProxyAddr and SSHProxyAddr
|
|
// configuration values. Values that are not fully specified via configuration
|
|
// or command-line options will be deduced if necessary.
|
|
//
|
|
// If successful, setClientWebProxyAddr will modify the client Config in-place.
|
|
func setClientWebProxyAddr(ctx context.Context, cf *CLIConf, c *client.Config) error {
|
|
ctx, span := cf.tracer.Start(ctx, "makeClientForProxy/setClientWebProxyAddr")
|
|
defer span.End()
|
|
// If the user has specified a proxy on the command line, and one has not
|
|
// already been specified from configuration...
|
|
|
|
if cf.Proxy != "" && c.WebProxyAddr == "" {
|
|
parsedAddrs, err := client.ParseProxyHost(cf.Proxy)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
proxyAddress := parsedAddrs.WebProxyAddr
|
|
if parsedAddrs.UsingDefaultWebProxyPort {
|
|
log.Debug("Web proxy port was not set. Attempting to detect port number to use.")
|
|
timeout, cancel := context.WithTimeout(ctx, proxyDefaultResolutionTimeout)
|
|
defer cancel()
|
|
|
|
proxyAddress, err = pickDefaultAddr(
|
|
timeout, cf.InsecureSkipVerify, parsedAddrs.Host, defaultWebProxyPorts)
|
|
if err != nil {
|
|
return trace.Wrap(err, proxyHostsErrorMsgDefault(parsedAddrs.Host, defaultWebProxyPorts))
|
|
}
|
|
}
|
|
|
|
c.WebProxyAddr = proxyAddress
|
|
c.SSHProxyAddr = parsedAddrs.SSHProxyAddr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseCertificateCompatibilityFlag(compatibility string, certificateFormat string) (string, error) {
|
|
switch {
|
|
// if nothing is passed in, the role will decide
|
|
case compatibility == "" && certificateFormat == "":
|
|
return teleport.CertificateFormatUnspecified, nil
|
|
// supporting the old --compat format for backward compatibility
|
|
case compatibility != "" && certificateFormat == "":
|
|
return utils.CheckCertificateFormatFlag(compatibility)
|
|
// new documented flag --cert-format
|
|
case compatibility == "" && certificateFormat != "":
|
|
return utils.CheckCertificateFormatFlag(certificateFormat)
|
|
// can not use both
|
|
default:
|
|
return "", trace.BadParameter("--compat or --cert-format must be specified")
|
|
}
|
|
}
|
|
|
|
// refuseArgs helper makes sure that 'args' (list of CLI arguments)
|
|
// does not contain anything other than command
|
|
func refuseArgs(command string, args []string) error {
|
|
for _, arg := range args {
|
|
if arg == command || strings.HasPrefix(arg, "-") {
|
|
continue
|
|
} else {
|
|
return trace.BadParameter("unexpected argument: %s", arg)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onShow reads an identity file (a public SSH key or a cert) and dumps it to stdout
|
|
func onShow(cf *CLIConf) error {
|
|
key, err := identityfile.KeyFromIdentityFile(cf.IdentityFileIn, cf.Proxy, cf.SiteName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// unmarshal certificate bytes into a ssh.PublicKey
|
|
cert, _, _, _, err := ssh.ParseAuthorizedKey(key.Cert)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
fmt.Printf("Cert: %#v\nPriv: %#v\nPub: %#v\n", cert, key.Signer, key.MarshalSSHPublicKey())
|
|
fmt.Printf("Fingerprint: %s\n", ssh.FingerprintSHA256(key.SSHPublicKey()))
|
|
return nil
|
|
}
|
|
|
|
// printStatus prints the status of the profile.
|
|
func printStatus(debug bool, p *profileInfo, env map[string]string, isActive bool) {
|
|
var prefix string
|
|
duration := time.Until(p.ValidUntil)
|
|
humanDuration := "EXPIRED"
|
|
if duration.Nanoseconds() > 0 {
|
|
humanDuration = fmt.Sprintf("valid for %v", duration.Round(time.Minute))
|
|
}
|
|
|
|
proxyURL := p.getProxyURLLine(isActive, env)
|
|
cluster := p.getClusterLine(isActive, env)
|
|
kubeCluster := p.getKubeClusterLine(isActive, env, cluster)
|
|
if isActive {
|
|
prefix = "> "
|
|
} else {
|
|
prefix = " "
|
|
}
|
|
|
|
fmt.Printf("%vProfile URL: %v\n", prefix, proxyURL)
|
|
fmt.Printf(" Logged in as: %v\n", p.Username)
|
|
if len(p.ActiveRequests) != 0 {
|
|
fmt.Printf(" Active requests: %v\n", strings.Join(p.ActiveRequests, ", "))
|
|
}
|
|
|
|
if cluster != "" {
|
|
fmt.Printf(" Cluster: %v\n", cluster)
|
|
}
|
|
fmt.Printf(" Roles: %v\n", strings.Join(p.Roles, ", "))
|
|
if debug {
|
|
var count int
|
|
for k, v := range p.Traits {
|
|
if count == 0 {
|
|
fmt.Printf(" Traits: %v: %v\n", k, v)
|
|
} else {
|
|
fmt.Printf(" %v: %v\n", k, v)
|
|
}
|
|
count = count + 1
|
|
}
|
|
}
|
|
fmt.Printf(" Logins: %v\n", strings.Join(p.Logins, ", "))
|
|
if p.KubernetesEnabled {
|
|
fmt.Printf(" Kubernetes: enabled\n")
|
|
if kubeCluster != "" {
|
|
fmt.Printf(" Kubernetes cluster: %q\n", kubeCluster)
|
|
}
|
|
if len(p.KubernetesUsers) > 0 {
|
|
fmt.Printf(" Kubernetes users: %v\n", strings.Join(p.KubernetesUsers, ", "))
|
|
}
|
|
if len(p.KubernetesGroups) > 0 {
|
|
fmt.Printf(" Kubernetes groups: %v\n", strings.Join(p.KubernetesGroups, ", "))
|
|
}
|
|
} else {
|
|
fmt.Printf(" Kubernetes: disabled\n")
|
|
}
|
|
if len(p.Databases) != 0 {
|
|
fmt.Printf(" Databases: %v\n", strings.Join(p.Databases, ", "))
|
|
}
|
|
if len(p.AllowedResourceIDs) > 0 {
|
|
allowedResourcesStr, err := types.ResourceIDsToString(p.AllowedResourceIDs)
|
|
if err != nil {
|
|
log.Warnf("failed to marshal allowed resource IDs to string: %v", err)
|
|
} else {
|
|
fmt.Printf(" Allowed Resources: %s\n", allowedResourcesStr)
|
|
}
|
|
}
|
|
fmt.Printf(" Valid until: %v [%v]\n", p.ValidUntil, humanDuration)
|
|
fmt.Printf(" Extensions: %v\n", strings.Join(p.Extensions, ", "))
|
|
|
|
if debug {
|
|
first := true
|
|
for k, v := range p.CriticalOptions {
|
|
if first {
|
|
fmt.Printf(" Critical options: %v: %v\n", k, v)
|
|
} else {
|
|
fmt.Printf(" %v: %v\n", k, v)
|
|
}
|
|
first = false
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n")
|
|
}
|
|
|
|
// onStatus command shows which proxy the user is logged into and metadata
|
|
// about the certificate.
|
|
func onStatus(cf *CLIConf) error {
|
|
// Get the status of the active profile as well as the status
|
|
// of any other proxies the user is logged into.
|
|
//
|
|
// Return error if not logged in, no active profile, or expired.
|
|
profile, profiles, err := cf.FullProfileStatus()
|
|
if err != nil {
|
|
if trace.IsNotFound(err) {
|
|
return trace.NotFound("Not logged in.")
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
env := getTshEnv()
|
|
active, others := makeAllProfileInfo(profile, profiles, env)
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeProfiles(active, others, env, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
printProfiles(cf.Debug, active, others, env, cf.Verbose)
|
|
}
|
|
|
|
if profile == nil {
|
|
return trace.NotFound("Not logged in.")
|
|
}
|
|
|
|
duration := time.Until(profile.ValidUntil)
|
|
if !profile.ValidUntil.IsZero() && duration.Nanoseconds() <= 0 {
|
|
return trace.NotFound("Active profile expired.")
|
|
}
|
|
|
|
tc, err := makeClient(cf, true)
|
|
if err != nil {
|
|
log.WithError(err).Warn("Failed to make client for retrieving cluster alerts.")
|
|
return nil
|
|
}
|
|
|
|
if tc.PrivateKeyPolicy == keys.PrivateKeyPolicyHardwareKeyTouch {
|
|
log.Debug("Skipping cluster alerts due to Hardware Key Touch requirement.")
|
|
} else {
|
|
if err := common.ShowClusterAlerts(cf.Context, tc, os.Stderr, nil,
|
|
types.AlertSeverity_HIGH, types.AlertSeverity_HIGH); err != nil {
|
|
log.WithError(err).Warn("Failed to display cluster alerts.")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type profileInfo struct {
|
|
ProxyURL string `json:"profile_url"`
|
|
Username string `json:"username"`
|
|
ActiveRequests []string `json:"active_requests,omitempty"`
|
|
Cluster string `json:"cluster"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
Traits wrappers.Traits `json:"traits,omitempty"`
|
|
Logins []string `json:"logins,omitempty"`
|
|
KubernetesEnabled bool `json:"kubernetes_enabled"`
|
|
KubernetesCluster string `json:"kubernetes_cluster,omitempty"`
|
|
KubernetesUsers []string `json:"kubernetes_users,omitempty"`
|
|
KubernetesGroups []string `json:"kubernetes_groups,omitempty"`
|
|
Databases []string `json:"databases,omitempty"`
|
|
ValidUntil time.Time `json:"valid_until"`
|
|
Extensions []string `json:"extensions,omitempty"`
|
|
CriticalOptions map[string]string `json:"critical_options,omitempty"`
|
|
AllowedResourceIDs []types.ResourceID `json:"allowed_resources,omitempty"`
|
|
}
|
|
|
|
func makeAllProfileInfo(active *client.ProfileStatus, others []*client.ProfileStatus, env map[string]string) (*profileInfo, []*profileInfo) {
|
|
activeInfo := makeProfileInfo(active, env, true)
|
|
var othersInfo []*profileInfo
|
|
for _, p := range others {
|
|
othersInfo = append(othersInfo, makeProfileInfo(p, env, false))
|
|
}
|
|
return activeInfo, othersInfo
|
|
}
|
|
|
|
func makeProfileInfo(p *client.ProfileStatus, env map[string]string, isActive bool) *profileInfo {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
out := &profileInfo{
|
|
ProxyURL: p.ProxyURL.String(),
|
|
Username: p.Username,
|
|
ActiveRequests: p.ActiveRequests.AccessRequests,
|
|
Cluster: p.Cluster,
|
|
Roles: p.Roles,
|
|
Traits: p.Traits,
|
|
Logins: p.Logins,
|
|
KubernetesEnabled: p.KubeEnabled,
|
|
KubernetesCluster: selectedKubeCluster(p.Cluster),
|
|
KubernetesUsers: p.KubeUsers,
|
|
KubernetesGroups: p.KubeGroups,
|
|
Databases: p.DatabaseServices(),
|
|
ValidUntil: p.ValidUntil,
|
|
Extensions: p.Extensions,
|
|
CriticalOptions: p.CriticalOptions,
|
|
AllowedResourceIDs: p.AllowedResourceIDs,
|
|
}
|
|
|
|
// update active profile info from env
|
|
if isActive {
|
|
if proxy, ok := env[proxyEnvVar]; ok {
|
|
proxyURL := url.URL{
|
|
Scheme: "https",
|
|
Host: proxy,
|
|
}
|
|
out.ProxyURL = proxyURL.String()
|
|
}
|
|
|
|
if cluster, ok := env[clusterEnvVar]; ok {
|
|
out.Cluster = cluster
|
|
} else if siteName, ok := env[siteEnvVar]; ok {
|
|
out.Cluster = siteName
|
|
}
|
|
|
|
if kubeCluster, ok := env[kubeClusterEnvVar]; ok {
|
|
out.KubernetesCluster = kubeCluster
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *profileInfo) getProxyURLLine(isActive bool, env map[string]string) string {
|
|
// indicate if active profile proxy url is shadowed by env vars.
|
|
if isActive {
|
|
if _, ok := env[proxyEnvVar]; ok {
|
|
return fmt.Sprintf("%v (%v)", p.ProxyURL, proxyEnvVar)
|
|
}
|
|
}
|
|
return p.ProxyURL
|
|
}
|
|
|
|
func (p *profileInfo) getClusterLine(isActive bool, env map[string]string) string {
|
|
// indicate if active profile cluster is shadowed by env vars.
|
|
if isActive {
|
|
if _, ok := env[clusterEnvVar]; ok {
|
|
return fmt.Sprintf("%v (%v)", p.Cluster, clusterEnvVar)
|
|
} else if _, ok := env[siteEnvVar]; ok {
|
|
return fmt.Sprintf("%v (%v)", p.Cluster, siteEnvVar)
|
|
}
|
|
}
|
|
return p.Cluster
|
|
}
|
|
|
|
func (p *profileInfo) getKubeClusterLine(isActive bool, env map[string]string, cluster string) string {
|
|
// indicate if active profile kube cluster is shadowed by env vars.
|
|
if isActive {
|
|
// check if kube cluster env var is set and no cluster was selected by kube config
|
|
if _, ok := env[kubeClusterEnvVar]; ok {
|
|
return fmt.Sprintf("%v (%v)", p.KubernetesCluster, kubeClusterEnvVar)
|
|
}
|
|
}
|
|
return p.KubernetesCluster
|
|
}
|
|
|
|
func serializeProfiles(profile *profileInfo, profiles []*profileInfo, env map[string]string, format string) (string, error) {
|
|
profileData := struct {
|
|
Active *profileInfo `json:"active,omitempty"`
|
|
Profiles []*profileInfo `json:"profiles"`
|
|
Env map[string]string `json:"environment,omitempty"`
|
|
}{profile, []*profileInfo{}, env}
|
|
profileData.Profiles = append(profileData.Profiles, profiles...)
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(profileData, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(profileData)
|
|
}
|
|
if err != nil {
|
|
return "", trace.Wrap(err)
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
func getTshEnv() map[string]string {
|
|
env := map[string]string{}
|
|
for _, envVar := range tshStatusEnvVars {
|
|
if envVal, isSet := os.LookupEnv(envVar); isSet {
|
|
env[envVar] = envVal
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
func printProfiles(debug bool, profile *profileInfo, profiles []*profileInfo, env map[string]string, verbose bool) {
|
|
if profile == nil && len(profiles) == 0 {
|
|
return
|
|
}
|
|
|
|
// Print the active profile.
|
|
if profile != nil {
|
|
printStatus(debug, profile, env, true)
|
|
}
|
|
|
|
// Print all other profiles.
|
|
for _, p := range profiles {
|
|
printStatus(debug, p, env, false)
|
|
}
|
|
|
|
// Print relevant active env vars, if they are set.
|
|
if verbose {
|
|
if len(env) > 0 {
|
|
fmt.Println("Active Environment:")
|
|
}
|
|
for k, v := range env {
|
|
fmt.Printf("\t%s=%s\n", k, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// host is a utility function that extracts
|
|
// host from the host:port pair, in case of any error
|
|
// returns the original value
|
|
func host(in string) string {
|
|
out, err := utils.Host(in)
|
|
if err != nil {
|
|
return in
|
|
}
|
|
return out
|
|
}
|
|
|
|
// accessRequestWatcher is a helper to wait for an access request to be resolved.
|
|
type accessRequestWatcher struct {
|
|
req types.AccessRequest
|
|
watcher types.Watcher
|
|
closers []io.Closer
|
|
sync.RWMutex
|
|
}
|
|
|
|
// newAccessRequestWatcher returns a new accessRequestWatcher. Callers should
|
|
// always defer (*accessRequestWatcher).Close().
|
|
func newAccessRequestWatcher(req types.AccessRequest) *accessRequestWatcher {
|
|
return &accessRequestWatcher{
|
|
req: req,
|
|
}
|
|
}
|
|
|
|
// initialize sets up the underlying event watcher, when this returns without
|
|
// error the watcher is guaranteed to be in a ready state. Call this before
|
|
// creating the request to prevent a race.
|
|
func (w *accessRequestWatcher) initialize(ctx context.Context, tc *client.TeleportClient) error {
|
|
w.Lock()
|
|
defer w.Unlock()
|
|
|
|
if w.watcher != nil {
|
|
return trace.BadParameter("cannot re-initialize accessRequestWatcher")
|
|
}
|
|
|
|
proxyClient, err := tc.ConnectToProxy(ctx)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
w.closers = append(w.closers, proxyClient)
|
|
|
|
rootClient, err := proxyClient.ConnectToRootCluster(ctx)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
w.closers = append(w.closers, rootClient)
|
|
|
|
filter := types.AccessRequestFilter{
|
|
User: w.req.GetUser(),
|
|
ID: w.req.GetName(),
|
|
}
|
|
w.watcher, err = rootClient.NewWatcher(ctx, types.Watch{
|
|
Name: "await-request-approval",
|
|
Kinds: []types.WatchKind{{
|
|
Kind: types.KindAccessRequest,
|
|
Filter: filter.IntoMap(),
|
|
}},
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
w.closers = append(w.closers, w.watcher)
|
|
|
|
// Wait for OpInit event so that returned watcher is ready.
|
|
select {
|
|
case event := <-w.watcher.Events():
|
|
if event.Type != types.OpInit {
|
|
return trace.BadParameter("failed to watch for access requests: received an unexpected event while waiting for the initial OpInit")
|
|
}
|
|
case <-w.watcher.Done():
|
|
return trace.Wrap(w.watcher.Error())
|
|
case <-ctx.Done():
|
|
// This should be the same as w.watcher.Done(), including for completeness.
|
|
return trace.Wrap(ctx.Err())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// awaitResolution waits for the request to be resolved (state != PENDING).
|
|
func (w *accessRequestWatcher) awaitResolution() (types.AccessRequest, error) {
|
|
w.RLock()
|
|
defer w.RUnlock()
|
|
|
|
if w.watcher == nil {
|
|
return nil, trace.BadParameter("must initialize accessRequestWatcher before calling awaitResolution()")
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case event := <-w.watcher.Events():
|
|
switch event.Type {
|
|
case types.OpPut:
|
|
r, ok := event.Resource.(*types.AccessRequestV3)
|
|
if !ok {
|
|
return nil, trace.BadParameter("unexpected resource type %T", event.Resource)
|
|
}
|
|
if !r.GetState().IsPending() {
|
|
return r, nil
|
|
}
|
|
case types.OpDelete:
|
|
return nil, trace.Errorf("request %s has expired or been deleted...", event.Resource.GetName())
|
|
default:
|
|
log.Warnf("Skipping unknown event type %s", event.Type)
|
|
}
|
|
case <-w.watcher.Done():
|
|
return nil, trace.Wrap(w.watcher.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close closes the clients held by the watcher.
|
|
func (w *accessRequestWatcher) Close() error {
|
|
var errs []error
|
|
// Close in reverse order, like defer.
|
|
w.RLock()
|
|
for i := len(w.closers) - 1; i >= 0; i-- {
|
|
errs = append(errs, w.closers[i].Close())
|
|
}
|
|
w.RUnlock()
|
|
|
|
// Closed the watcher above, awaitResolution should now terminate and we can
|
|
// grab the lock.
|
|
w.Lock()
|
|
w.closers = nil
|
|
w.Unlock()
|
|
|
|
return trace.NewAggregate(errs...)
|
|
}
|
|
|
|
func onRequestResolution(cf *CLIConf, tc *client.TeleportClient, req types.AccessRequest) error {
|
|
if !req.GetState().IsApproved() {
|
|
msg := fmt.Sprintf("request %s has been set to %s", req.GetName(), req.GetState().String())
|
|
if reason := req.GetResolveReason(); reason != "" {
|
|
msg = fmt.Sprintf("%s, reason=%q", msg, reason)
|
|
}
|
|
return trace.Errorf(msg)
|
|
}
|
|
|
|
msg := "\nApproval received, getting updated certificates...\n\n"
|
|
if reason := req.GetResolveReason(); reason != "" {
|
|
msg = fmt.Sprintf("\nApproval received, reason=%q\nGetting updated certificates...\n\n", reason)
|
|
}
|
|
fmt.Fprint(os.Stderr, msg)
|
|
|
|
err := reissueWithRequests(cf, tc, []string{req.GetName()}, nil /*dropRequests*/)
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// reissueWithRequests handles a certificate reissue, applying new requests by ID,
|
|
// and saving the updated profile.
|
|
func reissueWithRequests(cf *CLIConf, tc *client.TeleportClient, newRequests []string, dropRequests []string) error {
|
|
profile, err := tc.ClientStore.ReadProfileStatus(cf.Proxy)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if profile.IsVirtual {
|
|
return trace.BadParameter("cannot reissue certificates while using an identity file (-i)")
|
|
}
|
|
params := client.ReissueParams{
|
|
AccessRequests: newRequests,
|
|
DropAccessRequests: dropRequests,
|
|
RouteToCluster: cf.SiteName,
|
|
}
|
|
// If the certificate already had active requests, add them to our inputs parameters.
|
|
for _, reqID := range profile.ActiveRequests.AccessRequests {
|
|
if !slices.Contains(dropRequests, reqID) {
|
|
params.AccessRequests = append(params.AccessRequests, reqID)
|
|
}
|
|
}
|
|
if params.RouteToCluster == "" {
|
|
params.RouteToCluster = profile.Cluster
|
|
}
|
|
if err := tc.ReissueUserCerts(cf.Context, client.CertCacheDrop, params); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := tc.SaveProfile(true); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := updateKubeConfigOnLogin(cf, tc); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func onApps(cf *CLIConf) error {
|
|
if cf.ListAll {
|
|
return trace.Wrap(listAppsAllClusters(cf))
|
|
}
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Get a list of all applications.
|
|
var apps []types.Application
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
apps, err = tc.ListApps(cf.Context, nil /* custom filter */)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Retrieve profile to be able to show which apps user is logged into.
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Sort by app name.
|
|
sort.Slice(apps, func(i, j int) bool {
|
|
return apps[i].GetName() < apps[j].GetName()
|
|
})
|
|
|
|
return trace.Wrap(showApps(apps, profile.Apps, cf.Format, cf.Verbose))
|
|
}
|
|
|
|
type appListing struct {
|
|
Proxy string `json:"proxy"`
|
|
Cluster string `json:"cluster"`
|
|
App types.Application `json:"app"`
|
|
}
|
|
|
|
type appListings []appListing
|
|
|
|
func (l appListings) Len() int {
|
|
return len(l)
|
|
}
|
|
|
|
func (l appListings) Less(i, j int) bool {
|
|
if l[i].Proxy != l[j].Proxy {
|
|
return l[i].Proxy < l[j].Proxy
|
|
}
|
|
if l[i].Cluster != l[j].Cluster {
|
|
return l[i].Cluster < l[j].Cluster
|
|
}
|
|
return l[i].App.GetName() < l[j].App.GetName()
|
|
}
|
|
|
|
func (l appListings) Swap(i, j int) {
|
|
l[i], l[j] = l[j], l[i]
|
|
}
|
|
|
|
func listAppsAllClusters(cf *CLIConf) error {
|
|
var listings appListings
|
|
err := forEachProfile(cf, func(tc *client.TeleportClient, profile *client.ProfileStatus) error {
|
|
result, err := tc.ListAppsAllClusters(cf.Context, nil /* custom filter */)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
for clusterName, apps := range result {
|
|
for _, app := range apps {
|
|
listings = append(listings, appListing{
|
|
Proxy: profile.ProxyURL.Host,
|
|
Cluster: clusterName,
|
|
App: app,
|
|
})
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
sort.Sort(listings)
|
|
|
|
profile, err := cf.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
var active []tlsca.RouteToApp
|
|
if profile != nil {
|
|
active = profile.Apps
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
printAppsWithClusters(listings, active, cf.Verbose)
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeAppsWithClusters(listings, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", format)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func printAppsWithClusters(apps []appListing, active []tlsca.RouteToApp, verbose bool) {
|
|
var rows [][]string
|
|
for _, app := range apps {
|
|
rows = append(rows, getAppRow(app.Proxy, app.Cluster, app.App, active, verbose))
|
|
}
|
|
// In verbose mode, print everything on a single line and include host UUID.
|
|
// In normal mode, chunk the labels, print two per line and allow multiple
|
|
// lines per node.
|
|
var t asciitable.Table
|
|
if verbose {
|
|
t = asciitable.MakeTable([]string{"Proxy", "Cluster", "Application", "Description", "Type", "Public Address", "URI", "Labels"}, rows...)
|
|
} else {
|
|
t = asciitable.MakeTableWithTruncatedColumn(
|
|
[]string{"Proxy", "Cluster", "Application", "Description", "Type", "Public Address", "Labels"}, rows, "Labels")
|
|
}
|
|
fmt.Println(t.AsBuffer().String())
|
|
}
|
|
|
|
func serializeAppsWithClusters(apps []appListing, format string) (string, error) {
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(apps, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(apps)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
func onRecordings(cf *CLIConf) error {
|
|
fromUTC, toUTC, err := defaults.SearchSessionRange(clockwork.NewRealClock(), cf.FromUTC, cf.ToUTC, cf.recordingsSince)
|
|
if err != nil {
|
|
return trace.Errorf("cannot request recordings: %v", err)
|
|
}
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// Max number of days is limited to prevent too many requests being sent if dynamo is used as a backend.
|
|
if days := toUTC.Sub(fromUTC).Hours() / 24; days > defaults.TshTctlSessionDayLimit {
|
|
return trace.Errorf("date range for recordings listing too large: %v days specified: limit %v days",
|
|
days, defaults.TshTctlSessionDayLimit)
|
|
}
|
|
var sessions []apievents.AuditEvent
|
|
if err := client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
sessions, err = tc.SearchSessionEvents(cf.Context,
|
|
fromUTC, toUTC, apidefaults.DefaultChunkSize,
|
|
types.EventOrderDescending, cf.maxRecordingsToShow)
|
|
return err
|
|
}); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if err := common.ShowSessions(sessions, cf.Format, os.Stdout); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onEnvironment handles "tsh env" command.
|
|
func onEnvironment(cf *CLIConf) error {
|
|
profile, err := cf.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
// Print shell built-in commands to set (or unset) environment.
|
|
switch {
|
|
case cf.unsetEnvironment:
|
|
fmt.Printf("unset %v\n", proxyEnvVar)
|
|
fmt.Printf("unset %v\n", clusterEnvVar)
|
|
fmt.Printf("unset %v\n", kubeClusterEnvVar)
|
|
fmt.Printf("unset %v\n", teleport.EnvKubeConfig)
|
|
case !cf.unsetEnvironment:
|
|
kubeName := selectedKubeCluster(profile.Cluster)
|
|
fmt.Printf("export %v=%v\n", proxyEnvVar, profile.ProxyURL.Host)
|
|
fmt.Printf("export %v=%v\n", clusterEnvVar, profile.Cluster)
|
|
if kubeName != "" {
|
|
fmt.Printf("export %v=%v\n", kubeClusterEnvVar, kubeName)
|
|
fmt.Printf("# set %v to a standalone kubeconfig for the selected kube cluster\n", teleport.EnvKubeConfig)
|
|
fmt.Printf("export %v=%v\n", teleport.EnvKubeConfig, profile.KubeConfigPath(kubeName))
|
|
}
|
|
}
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeEnvironment(profile, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func serializeEnvironment(profile *client.ProfileStatus, format string) (string, error) {
|
|
env := map[string]string{
|
|
proxyEnvVar: profile.ProxyURL.Host,
|
|
clusterEnvVar: profile.Cluster,
|
|
}
|
|
kubeName := selectedKubeCluster(profile.Cluster)
|
|
if kubeName != "" {
|
|
env[kubeClusterEnvVar] = kubeName
|
|
env[teleport.EnvKubeConfig] = profile.KubeConfigPath(kubeName)
|
|
}
|
|
var out []byte
|
|
var err error
|
|
if format == teleport.JSON {
|
|
out, err = utils.FastMarshalIndent(env, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(env)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
// envGetter is used to read in the environment. In production "os.Getenv"
|
|
// is used.
|
|
type envGetter func(string) string
|
|
|
|
// setEnvFlags sets flags that can be set via environment variables.
|
|
func setEnvFlags(cf *CLIConf, fn envGetter) {
|
|
// prioritize CLI input
|
|
if cf.SiteName == "" {
|
|
setSiteNameFromEnv(cf, fn)
|
|
}
|
|
// prioritize CLI input
|
|
if cf.KubernetesCluster == "" {
|
|
setKubernetesClusterFromEnv(cf, fn)
|
|
}
|
|
|
|
// these can only be set with env vars.
|
|
setTeleportHomeFromEnv(cf, fn)
|
|
setGlobalTshConfigPathFromEnv(cf, fn)
|
|
}
|
|
|
|
// setSiteNameFromEnv sets teleport site name from environment if configured.
|
|
// First try reading TELEPORT_CLUSTER, then the legacy term TELEPORT_SITE.
|
|
func setSiteNameFromEnv(cf *CLIConf, fn envGetter) {
|
|
if clusterName := fn(siteEnvVar); clusterName != "" {
|
|
cf.SiteName = clusterName
|
|
}
|
|
if clusterName := fn(clusterEnvVar); clusterName != "" {
|
|
cf.SiteName = clusterName
|
|
}
|
|
}
|
|
|
|
// setTeleportHomeFromEnv sets home directory from environment if configured.
|
|
func setTeleportHomeFromEnv(cf *CLIConf, fn envGetter) {
|
|
if homeDir := fn(types.HomeEnvVar); homeDir != "" {
|
|
cf.HomePath = path.Clean(homeDir)
|
|
}
|
|
}
|
|
|
|
// setKubernetesClusterFromEnv sets teleport kube cluster from environment if configured.
|
|
func setKubernetesClusterFromEnv(cf *CLIConf, fn envGetter) {
|
|
if kubeName := fn(kubeClusterEnvVar); kubeName != "" {
|
|
cf.KubernetesCluster = kubeName
|
|
}
|
|
}
|
|
|
|
// setGlobalTshConfigPathFromEnv sets path to global tsh config file.
|
|
func setGlobalTshConfigPathFromEnv(cf *CLIConf, fn envGetter) {
|
|
if configPath := fn(globalTshConfigEnvVar); configPath != "" {
|
|
cf.GlobalTshConfigPath = path.Clean(configPath)
|
|
}
|
|
}
|
|
|
|
func handleUnimplementedError(ctx context.Context, perr error, cf CLIConf) error {
|
|
const (
|
|
errMsgFormat = "This server does not implement this feature yet. Likely the client version you are using is newer than the server. The server version: %v, the client version: %v. Please upgrade the server."
|
|
unknownServerVersion = "unknown"
|
|
)
|
|
tc, err := makeClient(&cf, false)
|
|
if err != nil {
|
|
log.WithError(err).Warning("Failed to create client.")
|
|
return trace.WrapWithMessage(perr, errMsgFormat, unknownServerVersion, teleport.Version)
|
|
}
|
|
pr, err := tc.Ping(ctx)
|
|
if err != nil {
|
|
log.WithError(err).Warning("Failed to call ping.")
|
|
return trace.WrapWithMessage(perr, errMsgFormat, unknownServerVersion, teleport.Version)
|
|
}
|
|
return trace.WrapWithMessage(perr, errMsgFormat, pr.ServerVersion, teleport.Version)
|
|
}
|
|
|
|
func validateParticipantMode(mode types.SessionParticipantMode) error {
|
|
switch mode {
|
|
case types.SessionPeerMode, types.SessionObserverMode, types.SessionModeratorMode:
|
|
return nil
|
|
default:
|
|
return trace.BadParameter("invalid participant mode %v", mode)
|
|
}
|
|
}
|
|
|
|
// forEachProfile performs an action for each profile a user is currently logged in to.
|
|
func forEachProfile(cf *CLIConf, fn func(tc *client.TeleportClient, profile *client.ProfileStatus) error) error {
|
|
profiles, err := cf.ListProfiles()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
clock := clockwork.NewRealClock()
|
|
errors := make([]error, 0)
|
|
for _, p := range profiles {
|
|
proxyAddr := p.ProxyURL.Host
|
|
if p.IsExpired(clock) {
|
|
fmt.Fprintf(os.Stderr, "Credentials expired for proxy %q, skipping...\n", proxyAddr)
|
|
continue
|
|
}
|
|
tc, err := makeClientForProxy(cf, proxyAddr, true)
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
continue
|
|
}
|
|
if err := fn(tc, p); err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
}
|
|
|
|
return trace.NewAggregate(errors...)
|
|
}
|
|
|
|
// forEachProfileParallel performs an action for each profile a user is currently logged in to in
|
|
// parallel.
|
|
func forEachProfileParallel(cf *CLIConf, fn func(ctx context.Context, tc *client.TeleportClient, profile *client.ProfileStatus) error) error {
|
|
group, groupCtx := errgroup.WithContext(cf.Context)
|
|
group.SetLimit(6)
|
|
|
|
profiles, err := cf.ListProfiles()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
clock := clockwork.NewRealClock()
|
|
for _, p := range profiles {
|
|
p := p
|
|
proxyAddr := p.ProxyURL.Host
|
|
if p.IsExpired(clock) {
|
|
fmt.Fprintf(os.Stderr, "Credentials expired for proxy %q, skipping...\n", proxyAddr)
|
|
continue
|
|
}
|
|
|
|
group.Go(func() error {
|
|
tc, err := makeClientForProxy(cf, proxyAddr, true)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := fn(groupCtx, tc, p); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
return trace.Wrap(group.Wait())
|
|
}
|
|
|
|
type updateKubeConfigOpt struct {
|
|
warnOnDeprecatedSNI bool
|
|
// warnSNIPrinted is a pointer to bool forwarded from outside and set internally to
|
|
// prevent printing deprecated warning server times in a single tsh flow.
|
|
// Example usage:
|
|
// var warnOnDeprecatedKubeSNIPrinted bool
|
|
// updateKubeConfigOption := withWarnOnDeprecatedSNI(&warnOnDeprecatedKubeSNIPrinted)
|
|
// then sequence call of updateKubeConfigOption like
|
|
// updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption)
|
|
// updateKubeConfigOnLogin(cf, tc, updateKubeConfigOption)
|
|
// will print the warning message only once.
|
|
warnSNIPrinted *bool
|
|
}
|
|
|
|
type updateKubeConfigOnLoginOpt func(opt *updateKubeConfigOpt)
|
|
|
|
func withWarnOnDeprecatedSNI(printed *bool) updateKubeConfigOnLoginOpt {
|
|
return func(opt *updateKubeConfigOpt) {
|
|
opt.warnOnDeprecatedSNI = true
|
|
opt.warnSNIPrinted = printed
|
|
}
|
|
}
|
|
|
|
// updateKubeConfigOnLogin checks if the `--kube-cluster` flag was provided to
|
|
// tsh login call and updates the default kubeconfig with its value,
|
|
// otherwise does nothing.
|
|
func updateKubeConfigOnLogin(cf *CLIConf, tc *client.TeleportClient, opts ...updateKubeConfigOnLoginOpt) error {
|
|
var settings updateKubeConfigOpt
|
|
for _, opt := range opts {
|
|
opt(&settings)
|
|
}
|
|
defer func() {
|
|
// DEPRECATED DELETE IN 14.0
|
|
if !settings.warnOnDeprecatedSNI {
|
|
return
|
|
}
|
|
if settings.warnSNIPrinted == nil || *settings.warnSNIPrinted {
|
|
return
|
|
}
|
|
warnOnDeprecatedKubeConfigServerName(cf, tc)
|
|
*settings.warnSNIPrinted = true
|
|
}()
|
|
|
|
if len(cf.KubernetesCluster) == 0 {
|
|
return nil
|
|
}
|
|
err := updateKubeConfig(cf, tc, "")
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// DEPRECATED DELETE IN 14.0
|
|
func warnOnDeprecatedKubeConfigServerName(cf *CLIConf, tc *client.TeleportClient) {
|
|
if !tc.TLSRoutingEnabled {
|
|
// The ServerName in KUBECONFIG is used only when the TLS Routing is enabled.
|
|
return
|
|
}
|
|
kubeConfigPath := getKubeConfigPath(cf, "")
|
|
value, err := kubeconfig.Load(kubeConfigPath)
|
|
if err != nil {
|
|
if trace.IsNotFound(err) {
|
|
return
|
|
}
|
|
log.Warnf("Failed to load KUBECONFIG file: %v", err)
|
|
return
|
|
}
|
|
|
|
var outdatedClusters []string
|
|
k8host, _ := tc.KubeProxyHostPort()
|
|
for name, cluster := range value.Clusters {
|
|
if cluster.TLSServerName == client.GetOldKubeTLSServerName(k8host) {
|
|
outdatedClusters = append(outdatedClusters, name)
|
|
}
|
|
}
|
|
|
|
if len(outdatedClusters) == 0 {
|
|
return
|
|
}
|
|
fmt.Printf("Deprecated tls-server-name value detected in %s KUBECONFIG file for [%v] clusters\n", kubeConfigPath, strings.Join(outdatedClusters, ", "))
|
|
fmt.Printf("Please re-login and update your KUBECONFIG cluster configuration by running the 'tsh kube login' command.\n\n")
|
|
}
|
|
|
|
// onHeadlessApprove executes 'tsh headless approve' command
|
|
func onHeadlessApprove(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
tc.Stdin = os.Stdin
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
return tc.HeadlessApprove(cf.Context, cf.HeadlessAuthenticationID)
|
|
})
|
|
return trace.Wrap(err)
|
|
}
|