mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
Before establishing a session, request the server version. If the server replies false, that means it does not support that request type and is an older server version which needs UUIDv1 format session IDs.
569 lines
15 KiB
Go
569 lines
15 KiB
Go
// +build !windows
|
|
|
|
/*
|
|
Copyright 2016 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 client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/agent"
|
|
|
|
"github.com/docker/docker/pkg/term"
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/session"
|
|
"github.com/gravitational/teleport/lib/sshutils"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
"github.com/gravitational/trace"
|
|
)
|
|
|
|
type NodeSession struct {
|
|
// namespace is a session this namespace belongs to
|
|
namespace string
|
|
|
|
// id is the Teleport session ID
|
|
id session.ID
|
|
|
|
// env is the environment variables that need to be created
|
|
// on the server for this session
|
|
env map[string]string
|
|
|
|
// nodeClient is the parent of this session: the client connected to an
|
|
// SSH node
|
|
nodeClient *NodeClient
|
|
|
|
// Standard input/outputs for this session
|
|
stdin io.Reader
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
|
|
// closer is used to simultaneously close all goroutines created by
|
|
// this session. It's also used to wait for everyone to close
|
|
closer *utils.CloseBroadcaster
|
|
|
|
ExitMsg string
|
|
}
|
|
|
|
// newSession creates a new Teleport session with the given remote node
|
|
// if 'joinSessin' is given, the session will join the existing session
|
|
// of another user
|
|
func newSession(client *NodeClient,
|
|
joinSession *session.Session,
|
|
env map[string]string,
|
|
stdin io.Reader,
|
|
stdout io.Writer,
|
|
stderr io.Writer,
|
|
legacyID bool) (*NodeSession, error) {
|
|
|
|
if stdin == nil {
|
|
stdin = os.Stdin
|
|
}
|
|
if stdout == nil {
|
|
stdout = os.Stdout
|
|
}
|
|
if stderr == nil {
|
|
stderr = os.Stderr
|
|
}
|
|
if env == nil {
|
|
env = make(map[string]string)
|
|
}
|
|
|
|
var err error
|
|
ns := &NodeSession{
|
|
env: env,
|
|
nodeClient: client,
|
|
stdin: stdin,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
namespace: client.Namespace,
|
|
closer: utils.NewCloseBroadcaster(),
|
|
}
|
|
// if we're joining an existing session, we need to assume that session's
|
|
// existing/current terminal size:
|
|
if joinSession != nil {
|
|
ns.id = joinSession.ID
|
|
ns.namespace = joinSession.Namespace
|
|
tsize := joinSession.TerminalParams.Winsize()
|
|
if ns.isTerminalAttached() {
|
|
err = term.SetWinsize(0, tsize)
|
|
if err != nil {
|
|
log.Error(err)
|
|
}
|
|
os.Stdout.Write([]byte(fmt.Sprintf("\x1b[8;%d;%dt", tsize.Height, tsize.Width)))
|
|
}
|
|
// new session!
|
|
} else {
|
|
sid, ok := ns.env[sshutils.SessionEnvVar]
|
|
if !ok {
|
|
// DELETE IN: 4.1.0.
|
|
//
|
|
// Always send UUIDv4 after 4.1.
|
|
if legacyID {
|
|
sid = string(session.NewLegacyID())
|
|
} else {
|
|
sid = string(session.NewID())
|
|
}
|
|
|
|
}
|
|
ns.id = session.ID(sid)
|
|
}
|
|
ns.env[sshutils.SessionEnvVar] = string(ns.id)
|
|
return ns, nil
|
|
}
|
|
|
|
func (ns *NodeSession) NodeClient() *NodeClient {
|
|
return ns.nodeClient
|
|
}
|
|
|
|
func (ns *NodeSession) regularSession(callback func(s *ssh.Session) error) error {
|
|
session, err := ns.createServerSession()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
session.Stdout = ns.stdout
|
|
session.Stderr = ns.stderr
|
|
session.Stdin = ns.stdin
|
|
return trace.Wrap(callback(session))
|
|
}
|
|
|
|
type interactiveCallback func(serverSession *ssh.Session, shell io.ReadWriteCloser) error
|
|
|
|
func (ns *NodeSession) createServerSession() (*ssh.Session, error) {
|
|
sess, err := ns.nodeClient.Client.NewSession()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
// pass language info into the remote session.
|
|
evarsToPass := []string{"LANG", "LANGUAGE"}
|
|
for _, evar := range evarsToPass {
|
|
if value := os.Getenv(evar); value != "" {
|
|
err = sess.Setenv(evar, value)
|
|
if err != nil {
|
|
log.Warn(err)
|
|
}
|
|
}
|
|
}
|
|
// pass environment variables set by client
|
|
for key, val := range ns.env {
|
|
err = sess.Setenv(key, val)
|
|
if err != nil {
|
|
log.Warn(err)
|
|
}
|
|
}
|
|
|
|
// if agent forwarding was requested (and we have a agent to forward),
|
|
// forward the agent to endpoint.
|
|
tc := ns.nodeClient.Proxy.teleportClient
|
|
if tc.ForwardAgent && tc.localAgent.Agent != nil {
|
|
err = agent.ForwardToAgent(ns.nodeClient.Client, tc.localAgent.Agent)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
err = agent.RequestAgentForwarding(sess)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return sess, nil
|
|
}
|
|
|
|
// interactiveSession creates an interactive session on the remote node, executes
|
|
// the given callback on it, and waits for the session to end
|
|
func (ns *NodeSession) interactiveSession(callback interactiveCallback) error {
|
|
// determine what kind of a terminal we need
|
|
termType := os.Getenv("TERM")
|
|
if termType == "" {
|
|
termType = teleport.SafeTerminalType
|
|
}
|
|
// create the server-side session:
|
|
sess, err := ns.createServerSession()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// allocate terminal on the server:
|
|
remoteTerm, err := ns.allocateTerminal(termType, sess)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer remoteTerm.Close()
|
|
|
|
// call the passed callback and give them the established
|
|
// ssh session:
|
|
if err := callback(sess, remoteTerm); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Catch term signals, but only if we're attached to a real terminal
|
|
if ns.isTerminalAttached() {
|
|
ns.watchSignals(remoteTerm)
|
|
}
|
|
|
|
// start piping input into the remote shell and pipe the output from
|
|
// the remote shell into stdout:
|
|
ns.pipeInOut(remoteTerm)
|
|
|
|
// switch the terminal to raw mode (and switch back on exit!)
|
|
if ns.isTerminalAttached() {
|
|
ts, err := term.SetRawTerminal(0)
|
|
if err != nil {
|
|
log.Warn(err)
|
|
} else {
|
|
defer term.RestoreTerminal(0, ts)
|
|
}
|
|
}
|
|
// wait for the session to end
|
|
<-ns.closer.C
|
|
return nil
|
|
}
|
|
|
|
// allocateTerminal creates (allocates) a server-side terminal for this session.
|
|
func (ns *NodeSession) allocateTerminal(termType string, s *ssh.Session) (io.ReadWriteCloser, error) {
|
|
var err error
|
|
// read the size of the terminal window:
|
|
tsize := &term.Winsize{
|
|
Width: teleport.DefaultTerminalWidth,
|
|
Height: teleport.DefaultTerminalHeight,
|
|
}
|
|
if ns.isTerminalAttached() {
|
|
tsize, err = term.GetWinsize(0)
|
|
if err != nil {
|
|
log.Error(err)
|
|
}
|
|
}
|
|
// ... and request a server-side terminal of the same size:
|
|
err = s.RequestPty(termType,
|
|
int(tsize.Height),
|
|
int(tsize.Width),
|
|
ssh.TerminalModes{})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
writer, err := s.StdinPipe()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
reader, err := s.StdoutPipe()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
stderr, err := s.StderrPipe()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if ns.isTerminalAttached() {
|
|
go ns.updateTerminalSize(s)
|
|
}
|
|
go func() {
|
|
io.Copy(os.Stderr, stderr)
|
|
}()
|
|
return utils.NewPipeNetConn(
|
|
reader,
|
|
writer,
|
|
utils.MultiCloser(writer, s, ns.closer),
|
|
&net.IPAddr{},
|
|
&net.IPAddr{},
|
|
), nil
|
|
}
|
|
|
|
func (ns *NodeSession) updateTerminalSize(s *ssh.Session) {
|
|
// SIGWINCH is sent to the process when the window size of the terminal has
|
|
// changed.
|
|
sigwinchCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigwinchCh, syscall.SIGWINCH)
|
|
|
|
lastSize, err := term.GetWinsize(0)
|
|
if err != nil {
|
|
log.Errorf("Unable to get window size: %v", err)
|
|
return
|
|
}
|
|
|
|
// Sync the local terminal with size received from the remote server every
|
|
// two seconds. If we try and do it live, synchronization jitters occur.
|
|
tickerCh := time.NewTicker(defaults.TerminalResizePeriod)
|
|
defer tickerCh.Stop()
|
|
|
|
for {
|
|
select {
|
|
// The client updated the size of the local PTY. This change needs to occur
|
|
// on the server side PTY as well.
|
|
case sigwinch := <-sigwinchCh:
|
|
if sigwinch == nil {
|
|
return
|
|
}
|
|
|
|
currSize, err := term.GetWinsize(0)
|
|
if err != nil {
|
|
log.Warnf("Unable to get window size: %v.", err)
|
|
continue
|
|
}
|
|
|
|
// Terminal size has not changed, don't do anything.
|
|
if currSize.Height == lastSize.Height && currSize.Width == lastSize.Width {
|
|
continue
|
|
}
|
|
|
|
// Send the "window-change" request over the channel.
|
|
_, err = s.SendRequest(
|
|
sshutils.WindowChangeRequest,
|
|
false,
|
|
ssh.Marshal(sshutils.WinChangeReqParams{
|
|
W: uint32(currSize.Width),
|
|
H: uint32(currSize.Height),
|
|
}))
|
|
if err != nil {
|
|
log.Warnf("Unable to send %v reqest: %v.", sshutils.WindowChangeRequest, err)
|
|
continue
|
|
}
|
|
|
|
log.Debugf("Updated window size from %v to %v due to SIGWINCH.", lastSize, currSize)
|
|
|
|
lastSize = currSize
|
|
|
|
// Extract "resize" events in the stream and store the last window size.
|
|
case event := <-ns.nodeClient.TC.EventsChannel():
|
|
// Only "resize" events are important to tsh, all others can be ignored.
|
|
if event.GetType() != events.ResizeEvent {
|
|
continue
|
|
}
|
|
|
|
terminalParams, err := session.UnmarshalTerminalParams(event.GetString(events.TerminalSize))
|
|
if err != nil {
|
|
log.Warnf("Unable to unmarshal terminal parameters: %v.", err)
|
|
continue
|
|
}
|
|
|
|
lastSize = terminalParams.Winsize()
|
|
log.Debugf("Recevied window size %v from node in session %v.", lastSize, event.GetString(events.SessionEventID))
|
|
|
|
// Update size of local terminal with the last size received from remote server.
|
|
case <-tickerCh.C:
|
|
// Get the current size of the terminal and the last size report that was
|
|
// received.
|
|
currSize, err := term.GetWinsize(0)
|
|
if err != nil {
|
|
log.Warnf("Unable to get current terminal size: %v.", err)
|
|
continue
|
|
}
|
|
|
|
// Terminal size has not changed, don't do anything.
|
|
if currSize.Width == lastSize.Width && currSize.Height == lastSize.Height {
|
|
continue
|
|
}
|
|
|
|
// This changes the size of the local PTY. This will re-draw what's within
|
|
// the window.
|
|
err = term.SetWinsize(0, lastSize)
|
|
if err != nil {
|
|
log.Warnf("Unable to update terminal size: %v.", err)
|
|
continue
|
|
}
|
|
|
|
// This is what we use to resize the physical terminal window itself.
|
|
os.Stdout.Write([]byte(fmt.Sprintf("\x1b[8;%d;%dt", lastSize.Height, lastSize.Width)))
|
|
|
|
log.Debugf("Updated window size from %v to %v due to remote window change.", currSize, lastSize)
|
|
case <-ns.closer.C:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// isTerminalAttached returns true when this session is be controlled by
|
|
// a real terminal.
|
|
// It will return False for sessions initiated by the Web client or
|
|
// for non-interactive sessions (commands)
|
|
func (ns *NodeSession) isTerminalAttached() bool {
|
|
return ns.stdin == os.Stdin && term.IsTerminal(os.Stdin.Fd())
|
|
}
|
|
|
|
// runShell executes user's shell on the remote node under an interactive session
|
|
func (ns *NodeSession) runShell(callback ShellCreatedCallback) error {
|
|
return ns.interactiveSession(func(s *ssh.Session, shell io.ReadWriteCloser) error {
|
|
// start the shell on the server:
|
|
if err := s.Shell(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// call the client-supplied callback
|
|
if callback != nil {
|
|
exit, err := callback(s, ns.NodeClient().Client, shell)
|
|
if exit {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// runCommand executes a "exec" request either in interactive mode (with a
|
|
// TTY attached) or non-intractive mode (no TTY).
|
|
func (ns *NodeSession) runCommand(ctx context.Context, cmd []string, callback ShellCreatedCallback, interactive bool) error {
|
|
// If stdin is not a terminal, refuse to allocate terminal on the server and
|
|
// fallback to non-interactive mode
|
|
if interactive && ns.stdin == os.Stdin && !term.IsTerminal(os.Stdin.Fd()) {
|
|
interactive = false
|
|
fmt.Fprintf(os.Stderr, "TTY will not be allocated on the server because stdin is not a terminal\n")
|
|
}
|
|
|
|
// Start a interactive session ("exec" request with a TTY).
|
|
//
|
|
// Note that because a TTY was allocated, the terminal is in raw mode and any
|
|
// keyboard based signals will be propogated to the TTY on the server which is
|
|
// where all signal handling will occur.
|
|
if interactive {
|
|
return ns.interactiveSession(func(s *ssh.Session, term io.ReadWriteCloser) error {
|
|
err := s.Start(strings.Join(cmd, " "))
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if callback != nil {
|
|
exit, err := callback(s, ns.NodeClient().Client, term)
|
|
if exit {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Start a non-interactive session ("exec" request without TTY).
|
|
//
|
|
// Note that for non-interactive sessions upon receipt of SIGINT the client
|
|
// should send a SSH_MSG_DISCONNECT and shut itself down as gracefully as
|
|
// possible. This is what the RFC recommends and what OpenSSH does:
|
|
//
|
|
// * https://tools.ietf.org/html/rfc4253#section-11.1
|
|
// * https://github.com/openssh/openssh-portable/blob/05046d907c211cb9b4cd21b8eff9e7a46cd6c5ab/clientloop.c#L1195-L1444
|
|
//
|
|
// Unfortunately at the moment the Go SSH library Teleport uses does not
|
|
// support sending SSH_MSG_DISCONNECT. Instead we close the SSH channel and
|
|
// SSH client, and try and exit as gracefully as possible.
|
|
return ns.regularSession(func(s *ssh.Session) error {
|
|
var err error
|
|
|
|
runContext, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
defer cancel()
|
|
err = s.Run(strings.Join(cmd, " "))
|
|
}()
|
|
|
|
select {
|
|
// Run returned a result, return that back to the caller.
|
|
case <-runContext.Done():
|
|
return trace.Wrap(err)
|
|
// The passed in context timed out. This is often due to the user hitting
|
|
// Ctrl-C.
|
|
case <-ctx.Done():
|
|
err = s.Close()
|
|
if err != nil {
|
|
log.Debugf("Unable to close SSH channel: %v", err)
|
|
}
|
|
err = ns.NodeClient().Client.Close()
|
|
if err != nil {
|
|
log.Debugf("Unable to close SSH client: %v", err)
|
|
}
|
|
return trace.ConnectionProblem(ctx.Err(), "connection canceled")
|
|
}
|
|
})
|
|
}
|
|
|
|
// watchSignals register UNIX signal handlers and properly terminates a remote shell session
|
|
// must be called as a goroutine right after a remote shell is created
|
|
func (ns *NodeSession) watchSignals(shell io.Writer) {
|
|
exitSignals := make(chan os.Signal, 1)
|
|
// catch SIGTERM
|
|
signal.Notify(exitSignals, syscall.SIGTERM)
|
|
go func() {
|
|
defer ns.closer.Close()
|
|
<-exitSignals
|
|
}()
|
|
// Catch Ctrl-C signal
|
|
ctrlCSignal := make(chan os.Signal, 1)
|
|
signal.Notify(ctrlCSignal, syscall.SIGINT)
|
|
go func() {
|
|
for {
|
|
<-ctrlCSignal
|
|
_, err := shell.Write([]byte{3})
|
|
if err != nil {
|
|
log.Errorf(err.Error())
|
|
}
|
|
}
|
|
}()
|
|
// Catch Ctrl-Z signal
|
|
ctrlZSignal := make(chan os.Signal, 1)
|
|
signal.Notify(ctrlZSignal, syscall.SIGTSTP)
|
|
go func() {
|
|
for {
|
|
<-ctrlZSignal
|
|
_, err := shell.Write([]byte{26})
|
|
if err != nil {
|
|
log.Errorf(err.Error())
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// pipeInOut launches two goroutines: one to pipe the local input into the remote shell,
|
|
// and another to pipe the output of the remote shell into the local output
|
|
func (ns *NodeSession) pipeInOut(shell io.ReadWriteCloser) {
|
|
// copy from the remote shell to the local output
|
|
go func() {
|
|
defer ns.closer.Close()
|
|
_, err := io.Copy(ns.stdout, shell)
|
|
if err != nil {
|
|
log.Errorf(err.Error())
|
|
}
|
|
}()
|
|
// copy from the local input to the remote shell:
|
|
go func() {
|
|
defer ns.closer.Close()
|
|
buf := make([]byte, 128)
|
|
for {
|
|
n, err := ns.stdin.Read(buf)
|
|
if err != nil {
|
|
fmt.Fprintln(ns.stderr, trace.Wrap(err))
|
|
return
|
|
}
|
|
if n > 0 {
|
|
_, err = shell.Write(buf[:n])
|
|
if err != nil {
|
|
ns.ExitMsg = err.Error()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (ns *NodeSession) Close() error {
|
|
if ns.closer != nil {
|
|
ns.closer.Close()
|
|
}
|
|
return nil
|
|
}
|