mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
2513 lines
81 KiB
Go
2513 lines
81 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package srv
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/moby/term"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
|
|
"github.com/gravitational/teleport/api/observability/tracing"
|
|
"github.com/gravitational/teleport/api/types"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/lib/auth/moderation"
|
|
"github.com/gravitational/teleport/lib/bpf"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/events/recorder"
|
|
"github.com/gravitational/teleport/lib/observability/metrics"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
rsession "github.com/gravitational/teleport/lib/session"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
)
|
|
|
|
const sessionRecorderID = "session-recorder"
|
|
|
|
const (
|
|
PresenceVerifyInterval = time.Second * 15
|
|
PresenceMaxDifference = time.Minute
|
|
)
|
|
|
|
const (
|
|
// sessionRecordingWarningMessage is sent when the session recording is
|
|
// going to be disabled.
|
|
sessionRecordingWarningMessage = "Warning: node error. This might cause some functionalities not to work correctly."
|
|
// sessionRecordingErrorMessage is sent when session recording has some
|
|
// error and the session is terminated.
|
|
sessionRecordingErrorMessage = "Session terminating due to node error."
|
|
)
|
|
|
|
var serverSessions = prometheus.NewGauge(
|
|
prometheus.GaugeOpts{
|
|
Name: teleport.MetricServerInteractiveSessions,
|
|
Help: "Number of active sessions to this host",
|
|
},
|
|
)
|
|
|
|
func MsgParticipantCtrls(w io.Writer, m types.SessionParticipantMode) error {
|
|
var modeCtrl bytes.Buffer
|
|
modeCtrl.WriteString(fmt.Sprintf("\r\nTeleport > Joining session with participant mode: %s\r\n", string(m)))
|
|
modeCtrl.WriteString("Teleport > Controls\r\n")
|
|
modeCtrl.WriteString("Teleport > - CTRL-C: Leave the session\r\n")
|
|
if m == types.SessionModeratorMode {
|
|
modeCtrl.WriteString("Teleport > - t: Forcefully terminate the session\r\n")
|
|
}
|
|
_, err := w.Write(modeCtrl.Bytes())
|
|
if err != nil {
|
|
return fmt.Errorf("could not write bytes: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SessionRegistry holds a map of all active sessions on a given
|
|
// SSH server
|
|
type SessionRegistry struct {
|
|
SessionRegistryConfig
|
|
|
|
// logger holds the structured logger
|
|
logger *slog.Logger
|
|
|
|
// sessions holds a map between session ID and the session object. Used to
|
|
// find active sessions as well as close all sessions when the registry
|
|
// is closing.
|
|
sessions map[rsession.ID]*session
|
|
sessionsMux sync.Mutex
|
|
|
|
// users is used for automatic user creation when new sessions are
|
|
// started
|
|
users HostUsers
|
|
|
|
// sudoers is used to create sudoers files at session start
|
|
sudoers HostSudoers
|
|
sessionsByUser *userSessions
|
|
}
|
|
|
|
type userSessions struct {
|
|
sessionsByUser map[string]int
|
|
m sync.Mutex
|
|
}
|
|
|
|
func (us *userSessions) add(user string) {
|
|
us.m.Lock()
|
|
defer us.m.Unlock()
|
|
count := us.sessionsByUser[user]
|
|
us.sessionsByUser[user] = count + 1
|
|
}
|
|
|
|
func (us *userSessions) del(user string) int {
|
|
us.m.Lock()
|
|
defer us.m.Unlock()
|
|
count := us.sessionsByUser[user]
|
|
count -= 1
|
|
us.sessionsByUser[user] = count
|
|
return count
|
|
}
|
|
|
|
type SessionRegistryConfig struct {
|
|
// clock is the registry's internal clock. used in testing.
|
|
clock clockwork.Clock
|
|
|
|
// srv refers to the upon which this session registry is created.
|
|
Srv Server
|
|
|
|
// sessiontrackerService is used to share session activity to
|
|
// other teleport components through the auth server.
|
|
SessionTrackerService services.SessionTrackerService
|
|
}
|
|
|
|
func (sc *SessionRegistryConfig) CheckAndSetDefaults() error {
|
|
if sc.SessionTrackerService == nil {
|
|
return trace.BadParameter("session tracker service is required")
|
|
}
|
|
|
|
if sc.Srv == nil {
|
|
return trace.BadParameter("server is required")
|
|
}
|
|
|
|
if sc.clock == nil {
|
|
sc.clock = sc.Srv.GetClock()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func NewSessionRegistry(cfg SessionRegistryConfig) (*SessionRegistry, error) {
|
|
if err := cfg.CheckAndSetDefaults(); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
err := metrics.RegisterPrometheusCollectors(serverSessions)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return &SessionRegistry{
|
|
SessionRegistryConfig: cfg,
|
|
logger: slog.With(teleport.ComponentKey, teleport.Component(teleport.ComponentSession, cfg.Srv.Component())),
|
|
sessions: make(map[rsession.ID]*session),
|
|
users: cfg.Srv.GetHostUsers(),
|
|
sudoers: cfg.Srv.GetHostSudoers(),
|
|
sessionsByUser: &userSessions{
|
|
sessionsByUser: make(map[string]int),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *SessionRegistry) addSession(sess *session) {
|
|
s.sessionsMux.Lock()
|
|
defer s.sessionsMux.Unlock()
|
|
s.sessions[sess.id] = sess
|
|
}
|
|
|
|
func (s *SessionRegistry) removeSession(sess *session) {
|
|
s.sessionsMux.Lock()
|
|
defer s.sessionsMux.Unlock()
|
|
delete(s.sessions, sess.id)
|
|
}
|
|
|
|
func (s *SessionRegistry) findSessionLocked(id rsession.ID) (*session, bool) {
|
|
sess, found := s.sessions[id]
|
|
return sess, found
|
|
}
|
|
|
|
func (s *SessionRegistry) findSession(id rsession.ID) (*session, bool) {
|
|
s.sessionsMux.Lock()
|
|
defer s.sessionsMux.Unlock()
|
|
return s.findSessionLocked(id)
|
|
}
|
|
|
|
func (s *SessionRegistry) Close() {
|
|
s.sessionsMux.Lock()
|
|
defer s.sessionsMux.Unlock()
|
|
|
|
// End all sessions and allow session cleanup
|
|
// goroutines to complete.
|
|
for _, se := range s.sessions {
|
|
se.Stop()
|
|
}
|
|
|
|
s.logger.DebugContext(s.Srv.Context(), "Closing Session Registry.")
|
|
}
|
|
|
|
type sudoersCloser struct {
|
|
username string
|
|
userSessions *userSessions
|
|
cleanup func(name string) error
|
|
}
|
|
|
|
func (sc *sudoersCloser) Close() error {
|
|
count := sc.userSessions.del(sc.username)
|
|
if count != 0 {
|
|
return nil
|
|
}
|
|
if err := sc.cleanup(sc.username); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteSudoersFile tries to write the needed sudoers entry to the sudoers
|
|
// file, if any. If the returned closer is not nil, it must be called at the
|
|
// end of the session to cleanup the sudoers file.
|
|
func (s *SessionRegistry) WriteSudoersFile(identityContext IdentityContext) (io.Closer, error) {
|
|
if identityContext.Login == teleport.SSHSessionJoinPrincipal {
|
|
return nil, nil
|
|
}
|
|
|
|
// Pulling sudoers directly from the Srv so WriteSudoersFile always
|
|
// respects the invariant that we shouldn't write sudoers on proxy servers.
|
|
// This might invalidate the cached sudoers field on SessionRegistry, so
|
|
// we may be able to remove that in a future PR
|
|
sudoWriter := s.Srv.GetHostSudoers()
|
|
if sudoWriter == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
if len(identityContext.AccessPermit.HostSudoers) == 0 {
|
|
// not an error, sudoers may not be configured.
|
|
return nil, nil
|
|
}
|
|
|
|
if err := sudoWriter.WriteSudoers(identityContext.Login, identityContext.AccessPermit.HostSudoers); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
s.sessionsByUser.add(identityContext.Login)
|
|
|
|
return &sudoersCloser{
|
|
username: identityContext.Login,
|
|
userSessions: s.sessionsByUser,
|
|
cleanup: sudoWriter.RemoveSudoers,
|
|
}, nil
|
|
}
|
|
|
|
// ObtainFallbackUIDFunc should return (uid, true, nil) if a fallback UID is
|
|
// configured, (_, false, nil) if no fallback is configured.
|
|
type ObtainFallbackUIDFunc = func(ctx context.Context, username string) (uid int32, ok bool, _ error)
|
|
|
|
var errHostUserCreationNotAuthorized error = &trace.AccessDeniedError{
|
|
Message: "host user creation not authorized for this user",
|
|
}
|
|
|
|
// UpsertHostUser attempts to create or update a local user on the host if needed.
|
|
// If the returned closer is not nil, it must be called at the end of the session to
|
|
// clean up the local user.
|
|
func (s *SessionRegistry) UpsertHostUser(identityContext IdentityContext, obtainFallbackUID ObtainFallbackUIDFunc) (bool, io.Closer, error) {
|
|
ctx := s.Srv.Context()
|
|
log := s.logger.With("host_username", identityContext.Login)
|
|
|
|
if identityContext.Login == teleport.SSHSessionJoinPrincipal {
|
|
return false, nil, nil
|
|
}
|
|
|
|
if !s.Srv.GetCreateHostUser() || s.users == nil {
|
|
log.DebugContext(ctx, "Not creating host user: node has disabled host user creation.")
|
|
return false, nil, nil // not an error to not be able to create host users
|
|
}
|
|
|
|
log.DebugContext(ctx, "Checking if user provisioning is allowed")
|
|
ui := identityContext.AccessPermit.HostUsersInfo
|
|
if ui == nil {
|
|
return false, nil, trace.Wrap(errHostUserCreationNotAuthorized)
|
|
}
|
|
|
|
if obtainFallbackUID != nil && ui.Mode == decisionpb.HostUserMode_HOST_USER_MODE_KEEP && ui.Uid == "" {
|
|
if err := s.users.UserExists(identityContext.Login); err != nil {
|
|
if !trace.IsNotFound(err) {
|
|
return false, nil, trace.Wrap(err)
|
|
}
|
|
|
|
log.DebugContext(ctx, "Host user does not exist and no UID is configured, obtaining UID from control plane")
|
|
fallbackUID, ok, err := obtainFallbackUID(ctx, identityContext.Login)
|
|
if err != nil {
|
|
log.ErrorContext(ctx, "Failed to obtain UID from control plane", "error", err)
|
|
return false, nil, trace.Wrap(err)
|
|
}
|
|
if ok {
|
|
log.DebugContext(ctx, "Obtained UID from control plane", "uid", fallbackUID)
|
|
ui.Uid = strconv.Itoa(int(fallbackUID))
|
|
if ui.Gid == "" {
|
|
ui.Gid = ui.Uid
|
|
}
|
|
} else {
|
|
log.DebugContext(ctx, "No UID configured in the cluster")
|
|
}
|
|
}
|
|
}
|
|
|
|
log.DebugContext(ctx, "Attempting to upsert host user")
|
|
userCloser, err := s.users.UpsertUser(identityContext.Login, ui)
|
|
if err != nil {
|
|
log.DebugContext(ctx, "Error creating user", "error", err)
|
|
|
|
if errors.Is(err, errUnmanagedUser) {
|
|
log.WarnContext(ctx, "User is not managed by teleport. Either manually delete the user from this machine or update the host_groups defined in their role to include 'teleport-keep'. https://goteleport.com/docs/enroll-resources/server-access/guides/host-user-creation/#migrating-unmanaged-users")
|
|
return false, nil, nil
|
|
}
|
|
|
|
if !trace.IsAlreadyExists(err) {
|
|
return false, nil, trace.Wrap(err)
|
|
}
|
|
log.DebugContext(ctx, "Host user already exists")
|
|
}
|
|
|
|
return true, userCloser, nil
|
|
}
|
|
|
|
// OpenSession either starts a new session.
|
|
func (s *SessionRegistry) OpenSession(ctx context.Context, ch ssh.Channel, scx *ServerContext) error {
|
|
if scx.JoinOnly {
|
|
return trace.AccessDenied("join-only mode was used to create this connection but attempted to create a new session.")
|
|
}
|
|
|
|
// This logic allows concurrent request to create a new session
|
|
// to fail, what is ok because we should never have this condition
|
|
sess, p, err := newSession(ctx, s, scx, ch, sessionTypeInteractive)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
scx.setSession(ctx, sess)
|
|
s.addSession(sess)
|
|
scx.Logger.InfoContext(ctx, "Creating interactive session", "session_id", sess.id)
|
|
|
|
// Start an interactive session (TTY attached). Close the session if an error
|
|
// occurs, otherwise it will be closed by the callee.
|
|
if err := sess.startInteractive(ctx, scx, p); err != nil {
|
|
sess.Close()
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// JoinSession joins an existing interactive session.
|
|
func (s *SessionRegistry) JoinSession(ctx context.Context, ch ssh.Channel, scx *ServerContext, sid string, mode types.SessionParticipantMode) error {
|
|
// make sure whatever session is requested is a valid session
|
|
id, err := rsession.ParseID(sid)
|
|
if err != nil {
|
|
return trace.BadParameter("invalid session ID %s", sid)
|
|
}
|
|
|
|
session, ok := s.findSession(*id)
|
|
if !ok || session.isStopped() {
|
|
return trace.NotFound("session not found or no longer running")
|
|
}
|
|
|
|
scx.Logger.InfoContext(ctx, "Joining existing session", "session_id", session.id)
|
|
|
|
switch mode {
|
|
case types.SessionModeratorMode, types.SessionObserverMode, types.SessionPeerMode:
|
|
default:
|
|
return trace.BadParameter("Unrecognized session participant mode: %q", mode)
|
|
}
|
|
|
|
scx.setSession(ctx, session)
|
|
|
|
// Update the in-memory data structure that a party member has joined.
|
|
if err := session.join(ch, scx, mode); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// OpenExecSession opens a non-interactive exec session.
|
|
func (s *SessionRegistry) OpenExecSession(ctx context.Context, channel ssh.Channel, scx *ServerContext) error {
|
|
// This logic allows concurrent request to create a new session
|
|
// to fail, what is ok because we should never have this condition.
|
|
sess, _, err := newSession(ctx, s, scx, channel, sessionTypeNonInteractive)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
scx.Logger.InfoContext(ctx, "Creating exec session", "session_id", sess.id)
|
|
|
|
approved, err := s.isApprovedFileTransfer(scx)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
sess.mu.Lock()
|
|
canStart, _, err := sess.checkIfStartUnderLock()
|
|
sess.mu.Unlock()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// canStart will be true for non-moderated sessions. If canStart is false, check to
|
|
// see if the request has been approved through a moderated session next.
|
|
if !canStart && !approved {
|
|
return errCannotStartUnattendedSession
|
|
}
|
|
|
|
// Start a non-interactive session (TTY attached). Close the session if an error
|
|
// occurs, otherwise it will be closed by the callee.
|
|
scx.setSession(ctx, sess)
|
|
|
|
err = sess.startExec(ctx, channel, scx)
|
|
if err != nil {
|
|
sess.Close()
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionRegistry) ForceTerminate(ctx *ServerContext) error {
|
|
sess := ctx.getSession()
|
|
if sess == nil {
|
|
s.logger.DebugContext(s.Srv.Context(), "Unable to terminate session, no session found in context.")
|
|
return nil
|
|
}
|
|
|
|
sess.BroadcastMessage("Forcefully terminating session...")
|
|
|
|
// Stop session, it will be cleaned up in the background to ensure
|
|
// the session recording is uploaded.
|
|
sess.Stop()
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetTerminalSize fetches the terminal size of an active SSH session.
|
|
func (s *SessionRegistry) GetTerminalSize(sessionID string) (*term.Winsize, error) {
|
|
s.sessionsMux.Lock()
|
|
defer s.sessionsMux.Unlock()
|
|
|
|
sess := s.sessions[rsession.ID(sessionID)]
|
|
if sess == nil {
|
|
return nil, trace.NotFound("No session found in context.")
|
|
}
|
|
|
|
return sess.term.GetWinSize()
|
|
}
|
|
|
|
func (s *SessionRegistry) isApprovedFileTransfer(scx *ServerContext) (bool, error) {
|
|
// If the ModeratedSessionID param was not provided, return not approved
|
|
// and no error. This means the file transfer came from a non-moderated session.
|
|
// sessionID will be passed after a moderated session approval process has completed.
|
|
sessID := scx.GetSessionParams().ModeratedSessionID
|
|
if sessID == "" {
|
|
return false, nil
|
|
}
|
|
|
|
// fetch session from registry with sessionID
|
|
s.sessionsMux.Lock()
|
|
sess := s.sessions[rsession.ID(sessID)]
|
|
s.sessionsMux.Unlock()
|
|
if sess == nil {
|
|
// If they sent a sessionID and it wasn't found, send an actual error
|
|
return false, trace.NotFound("Session not found")
|
|
}
|
|
|
|
// acquire the session mutex lock so sess.fileTransferReq doesn't get
|
|
// written while we're reading it
|
|
sess.mu.Lock()
|
|
defer sess.mu.Unlock()
|
|
|
|
if sess.fileTransferReq == nil {
|
|
return false, trace.NotFound("Session does not have a pending file transfer request")
|
|
}
|
|
if sess.fileTransferReq.Requester != scx.Identity.TeleportUser {
|
|
// to be safe deny and remove the pending request if the user
|
|
// doesn't match what we expect
|
|
req := sess.fileTransferReq
|
|
sess.fileTransferReq = nil
|
|
|
|
sess.BroadcastMessage("file transfer request %s denied due to %s attempting to transfer files", req.ID, scx.Identity.TeleportUser)
|
|
_ = s.notifyFileTransferRequestUnderLock(req, FileTransferDenied, scx)
|
|
|
|
return false, trace.AccessDenied("Teleport user does not match original requester")
|
|
}
|
|
|
|
approved, err := sess.checkIfFileTransferApproved(sess.fileTransferReq)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
if approved {
|
|
scx.setApprovedFileTransferRequest(sess.fileTransferReq)
|
|
sess.fileTransferReq = nil
|
|
}
|
|
|
|
return approved, nil
|
|
}
|
|
|
|
// FileTransferRequestEvent is an event used to Notify party members during File Transfer Request approval process
|
|
type FileTransferRequestEvent string
|
|
|
|
const (
|
|
// FileTransferUpdate is used when a file transfer request is created or updated.
|
|
// An update will happen if a file transfer request was approved but the policy still isn't fulfilled
|
|
FileTransferUpdate FileTransferRequestEvent = "file_transfer_request"
|
|
// FileTransferApproved is used when a file transfer request has received an approval decision
|
|
// and the policy is fulfilled. This lets the client know that the file transfer is ready to download/upload
|
|
// and be removed from any pending state.
|
|
FileTransferApproved FileTransferRequestEvent = "file_transfer_request_approve"
|
|
// FileTransferDenied is used when a file transfer request is denied. This lets the client know to remove
|
|
// this file transfer from any pending state.
|
|
FileTransferDenied FileTransferRequestEvent = "file_transfer_request_deny"
|
|
)
|
|
|
|
// notifyFileTransferRequestUnderLock is called to notify all members of a party that a file transfer request has been created/approved/denied.
|
|
// The notification is a global ssh request and requires the client to update its UI state accordingly.
|
|
func (s *SessionRegistry) notifyFileTransferRequestUnderLock(req *FileTransferRequest, res FileTransferRequestEvent, scx *ServerContext) error {
|
|
session := scx.getSession()
|
|
if session == nil {
|
|
s.logger.DebugContext(
|
|
s.Srv.Context(), "Unable to notify event, no session found in context.",
|
|
"event", res,
|
|
)
|
|
return trace.NotFound("no session found in context")
|
|
}
|
|
|
|
fileTransferEvent := &apievents.FileTransferRequestEvent{
|
|
Metadata: apievents.Metadata{
|
|
Type: string(res),
|
|
ClusterName: scx.ClusterName,
|
|
},
|
|
SessionMetadata: session.scx.GetSessionMetadata(),
|
|
RequestID: req.ID,
|
|
Requester: req.Requester,
|
|
Location: req.Location,
|
|
Filename: req.Filename,
|
|
Download: req.Download,
|
|
Approvers: make([]string, 0),
|
|
}
|
|
|
|
for _, approver := range req.approvers {
|
|
fileTransferEvent.Approvers = append(fileTransferEvent.Approvers, approver.user)
|
|
}
|
|
|
|
eventPayload, err := json.Marshal(fileTransferEvent)
|
|
if err != nil {
|
|
s.logger.WarnContext(
|
|
s.Srv.Context(), "Unable to marshal event.",
|
|
"event", res,
|
|
"error", err,
|
|
)
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
for _, p := range session.parties {
|
|
// Send the message as a global request.
|
|
_, _, err = p.sconn.SendRequest(teleport.SessionEvent, false, eventPayload)
|
|
if err != nil {
|
|
s.logger.WarnContext(
|
|
s.Srv.Context(), "Unable to send event to session party.",
|
|
"event", res,
|
|
"error", err,
|
|
"party", p,
|
|
)
|
|
continue
|
|
}
|
|
s.logger.DebugContext(
|
|
s.Srv.Context(), "Sent event to session party.",
|
|
"event", res,
|
|
"party", p,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NotifyWinChange is called to notify all members in the party that the PTY
|
|
// size has changed. The notification is sent as a global SSH request and it
|
|
// is the responsibility of the client to update it's window size upon receipt.
|
|
func (s *SessionRegistry) NotifyWinChange(ctx context.Context, params rsession.TerminalParams, scx *ServerContext) error {
|
|
session := scx.getSession()
|
|
if session == nil {
|
|
sessParams := scx.GetSessionParams()
|
|
if sessParams.JoinSessionID == "" {
|
|
s.logger.DebugContext(ctx, "Unable to update window size, no session found in context.")
|
|
return nil
|
|
}
|
|
|
|
id, err := rsession.ParseID(sessParams.JoinSessionID)
|
|
if err != nil {
|
|
return trace.BadParameter("invalid session ID %s", sessParams.JoinSessionID)
|
|
}
|
|
|
|
var ok bool
|
|
session, ok = s.findSession(*id)
|
|
if !ok {
|
|
s.logger.DebugContext(ctx, "Unable to update window size, no session found in context.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Build the resize event.
|
|
resizeEvent, err := session.Recorder().PrepareSessionEvent(&apievents.Resize{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.ResizeEvent,
|
|
Code: events.TerminalResizeCode,
|
|
ClusterName: scx.ClusterName,
|
|
},
|
|
ServerMetadata: session.serverMeta,
|
|
SessionMetadata: session.scx.GetSessionMetadata(),
|
|
UserMetadata: scx.Identity.GetUserMetadata(),
|
|
TerminalSize: params.Serialize(),
|
|
})
|
|
if err == nil {
|
|
// Report the updated window size to the session stream (this is so the sessions
|
|
// can be replayed correctly).
|
|
if err := session.recordEvent(s.Srv.Context(), resizeEvent); err != nil {
|
|
s.logger.WarnContext(ctx, "Failed to record resize session event.", "error", err)
|
|
}
|
|
} else {
|
|
s.logger.WarnContext(ctx, "Failed to set up resize session event - event will not be recorded.", "error", err)
|
|
}
|
|
|
|
// Update the size of the server side PTY.
|
|
err = session.term.SetWinSize(ctx, params)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// If sessions are being recorded at the proxy, sessions can not be shared.
|
|
// In that situation, PTY size information does not need to be propagated
|
|
// back to all clients and we can return right away.
|
|
if services.IsRecordAtProxy(scx.SessionRecordingConfig.GetMode()) {
|
|
return nil
|
|
}
|
|
|
|
// Notify all members of the party (except originator) that the size of the
|
|
// window has changed so the client can update it's own local PTY. Note that
|
|
// OpenSSH clients will ignore this and not update their own local PTY.
|
|
for _, p := range session.getParties() {
|
|
// Don't send the window change notification back to the originator.
|
|
if p.ctx.ID() == scx.ID() {
|
|
continue
|
|
}
|
|
|
|
eventPayload, err := json.Marshal(resizeEvent.GetAuditEvent())
|
|
if err != nil {
|
|
s.logger.WarnContext(ctx, "Unable to marshal resize event for session party.", "error", err, "party", p)
|
|
continue
|
|
}
|
|
|
|
// Send the message as a global request.
|
|
_, _, err = p.sconn.SendRequest(teleport.SessionEvent, false, eventPayload)
|
|
if err != nil {
|
|
s.logger.WarnContext(ctx, "Unable to send resize event to session party.", "error", err, "party", p)
|
|
continue
|
|
}
|
|
s.logger.DebugContext(ctx, "Sent resize event to session party.", "event", params, "party", p)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionRegistry) broadcastResult(sid rsession.ID, r ExecResult) error {
|
|
s.sessionsMux.Lock()
|
|
defer s.sessionsMux.Unlock()
|
|
|
|
sess, found := s.findSessionLocked(sid)
|
|
if !found {
|
|
return trace.NotFound("session %v not found", sid)
|
|
}
|
|
sess.broadcastResult(r)
|
|
return nil
|
|
}
|
|
|
|
// SessionAccessEvaluator is the interface that defines criteria needed to be met
|
|
// in order to start and join sessions.
|
|
type SessionAccessEvaluator interface {
|
|
IsModerated() bool
|
|
FulfilledFor(participants []moderation.SessionAccessContext) (bool, moderation.PolicyOptions, error)
|
|
PrettyRequirementsList() string
|
|
CanJoin(user moderation.SessionAccessContext) []types.SessionParticipantMode
|
|
}
|
|
|
|
type sessionInitiatorInfo struct {
|
|
user string
|
|
cluster string
|
|
}
|
|
|
|
// session struct describes an active (in progress) SSH session. These sessions
|
|
// are managed by 'SessionRegistry' containers which are attached to SSH servers.
|
|
type session struct {
|
|
mu sync.RWMutex
|
|
|
|
// logger holds the logger for this session.
|
|
logger *slog.Logger
|
|
|
|
// session ID. unique GUID, this is what people use to "join" sessions
|
|
id rsession.ID
|
|
|
|
// parent session container
|
|
registry *SessionRegistry
|
|
|
|
// parties is the set of current connected clients/users. This map may grow
|
|
// and shrink as members join and leave the session.
|
|
parties map[rsession.ID]*party
|
|
|
|
// participants is the set of users that have joined this session. Users are
|
|
// never removed from this map as it's used to report the full list of
|
|
// participants at the end of a session.
|
|
participants map[rsession.ID]*party
|
|
|
|
// fileTransferReq a pending file transfer request for this session.
|
|
// If the request is denied or approved it should be set to nil to
|
|
// prevent its reuse.
|
|
fileTransferReq *FileTransferRequest
|
|
|
|
io *TermManager
|
|
inWriter io.WriteCloser
|
|
|
|
term Terminal
|
|
|
|
// stopC channel is used to kill all goroutines owned
|
|
// by the session
|
|
stopC chan struct{}
|
|
|
|
// startTime is the time when this session was created.
|
|
startTime time.Time
|
|
|
|
// login stores the login of the initial session creator
|
|
login string
|
|
|
|
recorder events.SessionPreparerRecorder
|
|
recorderMu sync.RWMutex
|
|
|
|
emitter apievents.Emitter
|
|
|
|
// hasEnhancedRecording returns true if this session has enhanced session
|
|
// recording events associated.
|
|
hasEnhancedRecording bool
|
|
|
|
// serverCtx is used to control clean up of internal resources
|
|
serverCtx context.Context
|
|
|
|
access SessionAccessEvaluator
|
|
|
|
tracker *SessionTracker
|
|
|
|
initiator sessionInitiatorInfo
|
|
|
|
scx *ServerContext
|
|
|
|
presenceEnabled bool
|
|
|
|
doneCh chan struct{}
|
|
|
|
displayParticipantRequirements bool
|
|
|
|
// endingContext is the server context which closed this session.
|
|
endingContext *ServerContext
|
|
|
|
// lingerAndDieCancel is a context cancel func which will cancel
|
|
// an ongoing lingerAndDie goroutine. This is used by joining parties
|
|
// to cancel the goroutine and prevent the session from closing prematurely.
|
|
lingerAndDieCancel func()
|
|
|
|
// serverMeta contains metadata about the target node of this session.
|
|
serverMeta apievents.ServerMetadata
|
|
|
|
// started is true after the session start.
|
|
started atomic.Bool
|
|
}
|
|
|
|
type sessionType bool
|
|
|
|
const (
|
|
sessionTypeInteractive sessionType = true
|
|
sessionTypeNonInteractive sessionType = false
|
|
)
|
|
|
|
// newSession creates a new session with a given ID within a given context.
|
|
func newSession(ctx context.Context, r *SessionRegistry, scx *ServerContext, ch ssh.Channel, sessType sessionType) (*session, *party, error) {
|
|
var sessionRecordingMode constants.SessionRecordingMode
|
|
switch {
|
|
case scx.Identity.AccessPermit != nil:
|
|
sessionRecordingMode = constants.SessionRecordingMode(scx.Identity.AccessPermit.SessionRecordingMode)
|
|
case scx.Identity.ProxyingPermit != nil:
|
|
sessionRecordingMode = scx.Identity.ProxyingPermit.SessionRecordingMode
|
|
default:
|
|
return nil, nil, trace.BadParameter("session creation only supported in context of ssh access or proxying permit")
|
|
}
|
|
|
|
serverSessions.Inc()
|
|
startTime := time.Now().UTC()
|
|
rsess := rsession.Session{
|
|
Kind: types.SSHSessionKind,
|
|
ID: scx.GetNewSessionID(),
|
|
TerminalParams: rsession.TerminalParams{
|
|
W: teleport.DefaultTerminalWidth,
|
|
H: teleport.DefaultTerminalHeight,
|
|
},
|
|
Login: scx.Identity.Login,
|
|
Created: startTime,
|
|
LastActive: startTime,
|
|
ServerID: scx.srv.ID(),
|
|
Namespace: r.Srv.GetNamespace(),
|
|
ServerHostname: scx.srv.GetInfo().GetHostname(),
|
|
ServerAddr: scx.ServerConn.LocalAddr().String(),
|
|
ClusterName: scx.ClusterName,
|
|
}
|
|
|
|
term := scx.GetTerm()
|
|
if term != nil {
|
|
winsize, err := term.GetWinSize()
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
rsess.TerminalParams.W = int(winsize.Width)
|
|
rsess.TerminalParams.H = int(winsize.Height)
|
|
}
|
|
|
|
var policySets []*types.SessionTrackerPolicySet
|
|
if scx.Identity.UnstableSessionJoiningAccessChecker != nil {
|
|
policySets = scx.Identity.UnstableSessionJoiningAccessChecker.SessionPolicySets()
|
|
}
|
|
|
|
access := moderation.NewSessionAccessEvaluator(policySets, types.SSHSessionKind, scx.Identity.TeleportUser)
|
|
sess := &session{
|
|
logger: slog.With(
|
|
teleport.ComponentKey, teleport.Component(teleport.ComponentSession, r.Srv.Component()),
|
|
"session_id", rsess.ID,
|
|
),
|
|
id: rsess.ID,
|
|
registry: r,
|
|
parties: make(map[rsession.ID]*party),
|
|
participants: make(map[rsession.ID]*party),
|
|
login: scx.Identity.Login,
|
|
stopC: make(chan struct{}),
|
|
startTime: startTime,
|
|
emitter: scx.srv,
|
|
serverCtx: scx.srv.Context(),
|
|
access: &access,
|
|
scx: scx,
|
|
presenceEnabled: scx.Identity.UnmappedIdentity.MFAVerified != "",
|
|
io: NewTermManager(),
|
|
doneCh: make(chan struct{}),
|
|
initiator: sessionInitiatorInfo{
|
|
user: scx.Identity.TeleportUser,
|
|
cluster: scx.Identity.OriginClusterName,
|
|
},
|
|
displayParticipantRequirements: scx.GetSessionParams().DisplayParticipantRequirements,
|
|
serverMeta: scx.srv.EventMetadata(),
|
|
}
|
|
|
|
sess.io.OnWriteError = sess.onWriteErrorCallback(sessionRecordingMode)
|
|
|
|
// Nodes discard events in cases when proxies are already recording them.
|
|
if !sess.shouldHandleRecording() {
|
|
sess.emitter = events.NewDiscardAuditLog()
|
|
}
|
|
|
|
go func() {
|
|
if _, open := <-sess.io.TerminateNotifier(); open {
|
|
err := sess.registry.ForceTerminate(sess.scx)
|
|
if err != nil {
|
|
sess.logger.ErrorContext(sess.serverCtx, "Failed to terminate session.", "error", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// create a new "party" (connected client) and launch/join the session.
|
|
p := newParty(sess, types.SessionPeerMode, ch, scx)
|
|
sess.parties[p.id] = p
|
|
sess.participants[p.id] = p
|
|
|
|
var err error
|
|
if err = sess.trackSession(ctx, scx.Identity.TeleportUser, policySets, p, sessType); err != nil {
|
|
if trace.IsNotImplemented(err) {
|
|
return nil, nil, trace.NotImplemented("Attempted to use Moderated Sessions with an Auth Server below the minimum version of 9.0.0.")
|
|
}
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
sess.recorder, err = newRecorder(sess, scx, sessType)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sess, p, nil
|
|
}
|
|
|
|
// ID returns a string representation of the session ID.
|
|
func (s *session) ID() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.id.String()
|
|
}
|
|
|
|
// PID returns the PID of the Teleport process under which the shell is running.
|
|
func (s *session) PID() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.term.PID()
|
|
}
|
|
|
|
// Recorder returns a SessionRecorder which can be used to record session
|
|
// events.
|
|
func (s *session) Recorder() events.SessionPreparerRecorder {
|
|
s.recorderMu.RLock()
|
|
defer s.recorderMu.RUnlock()
|
|
return s.recorder
|
|
}
|
|
|
|
func (s *session) setRecorder(rec events.SessionPreparerRecorder) {
|
|
s.recorderMu.Lock()
|
|
defer s.recorderMu.Unlock()
|
|
s.recorder = rec
|
|
}
|
|
|
|
// Stop ends the active session and forces all clients to disconnect.
|
|
// This will trigger background goroutines to complete session cleanup.
|
|
func (s *session) Stop() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
select {
|
|
case <-s.stopC:
|
|
return
|
|
default:
|
|
close(s.stopC)
|
|
}
|
|
|
|
s.BroadcastMessage("Stopping session...")
|
|
s.logger.InfoContext(s.serverCtx, "Stopping session.")
|
|
|
|
// Close io copy loops
|
|
if s.inWriter != nil {
|
|
if err := s.inWriter.Close(); err != nil {
|
|
s.logger.DebugContext(s.serverCtx, "Failed to close session writer.", "error", err)
|
|
}
|
|
}
|
|
s.io.Close()
|
|
|
|
// Make sure that the terminal has been closed
|
|
s.haltTerminal()
|
|
|
|
// Close session tracker and mark it as terminated
|
|
if err := s.tracker.Close(s.serverCtx); err != nil {
|
|
s.logger.DebugContext(s.serverCtx, "Failed to close session tracker.", "error", err)
|
|
}
|
|
}
|
|
|
|
// haltTerminal closes the terminal. Then is tried to terminate the terminal in a graceful way
|
|
// and kill by sending SIGKILL if the graceful termination fails.
|
|
func (s *session) haltTerminal() {
|
|
if s.term == nil {
|
|
return
|
|
}
|
|
|
|
if err := s.term.Close(); err != nil {
|
|
s.logger.DebugContext(s.serverCtx, "Failed to close the shell.", "error", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := s.term.KillUnderlyingShell(ctx); err != nil {
|
|
s.logger.DebugContext(s.serverCtx, "Failed to terminate the shell.", "error", err)
|
|
} else {
|
|
// Return before we send SIGKILL to the child process, as doing that
|
|
// could interrupt the "graceful shutdown" process.
|
|
return
|
|
}
|
|
|
|
if err := s.term.Kill(context.TODO()); err != nil {
|
|
s.logger.DebugContext(s.serverCtx, "Failed to kill the shell.", "error", err)
|
|
}
|
|
}
|
|
|
|
// Close ends the active session and frees all resources. This should only be called
|
|
// by the creator of the session, other closers should use Stop instead. Calling this
|
|
// prematurely can result in missing audit events, session recordings, and other
|
|
// unexpected errors.
|
|
func (s *session) Close() error {
|
|
s.BroadcastMessage("Closing session...")
|
|
s.logger.InfoContext(s.serverCtx, "Closing session.")
|
|
|
|
// Remove session parties and close client connections. Since terminals
|
|
// might await for all the parties to be released, we must close them first.
|
|
// Closing the parties will cause their SSH channel to be closed, meaning
|
|
// any goroutine reading from it will be released.
|
|
for _, p := range s.getParties() {
|
|
p.Close()
|
|
}
|
|
|
|
s.Stop()
|
|
serverSessions.Dec()
|
|
s.registry.removeSession(s)
|
|
|
|
// Complete the session recording
|
|
if recorder := s.Recorder(); recorder != nil {
|
|
if err := recorder.Complete(s.serverCtx); err != nil {
|
|
s.logger.WarnContext(s.serverCtx, "Failed to close recorder.", "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *session) BroadcastMessage(format string, args ...any) {
|
|
if s.access.IsModerated() && !services.IsRecordAtProxy(s.scx.SessionRecordingConfig.GetMode()) {
|
|
s.io.BroadcastMessage(fmt.Sprintf(format, args...))
|
|
}
|
|
}
|
|
|
|
// BroadcastSystemMessage sends a message to all parties.
|
|
func (s *session) BroadcastSystemMessage(format string, args ...any) {
|
|
s.io.BroadcastMessage(fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// emitSessionStartEvent emits a session start event.
|
|
func (s *session) emitSessionStartEvent(ctx *ServerContext) {
|
|
var initialCommand []string
|
|
if execRequest, err := ctx.GetExecRequest(); err == nil {
|
|
initialCommand = []string{execRequest.GetCommand()}
|
|
}
|
|
|
|
sessionStartEvent := &apievents.SessionStart{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.SessionStartEvent,
|
|
Code: events.SessionStartCode,
|
|
ClusterName: ctx.ClusterName,
|
|
ID: uuid.New().String(),
|
|
},
|
|
ServerMetadata: s.serverMeta,
|
|
SessionMetadata: s.scx.GetSessionMetadata(),
|
|
UserMetadata: ctx.Identity.GetUserMetadata(),
|
|
ConnectionMetadata: apievents.ConnectionMetadata{
|
|
RemoteAddr: ctx.ServerConn.RemoteAddr().String(),
|
|
Protocol: events.EventProtocolSSH,
|
|
},
|
|
SessionRecording: s.sessionRecordingLocation(),
|
|
InitialCommand: initialCommand,
|
|
Reason: s.scx.GetSessionParams().Reason,
|
|
Invited: s.scx.GetSessionParams().Invited,
|
|
}
|
|
|
|
if s.term != nil {
|
|
params := s.term.GetTerminalParams()
|
|
sessionStartEvent.TerminalSize = params.Serialize()
|
|
}
|
|
|
|
// Local address only makes sense for non-tunnel nodes.
|
|
if !ctx.srv.UseTunnel() {
|
|
sessionStartEvent.ConnectionMetadata.LocalAddr = ctx.ServerConn.LocalAddr().String()
|
|
}
|
|
|
|
preparedEvent, err := s.Recorder().PrepareSessionEvent(sessionStartEvent)
|
|
if err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to set up session start event - event will not be recorded", "error", err)
|
|
return
|
|
}
|
|
if err := s.recordEvent(ctx.srv.Context(), preparedEvent); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to record session start event.", "error", err)
|
|
}
|
|
if err := s.emitAuditEvent(ctx.srv.Context(), preparedEvent.GetAuditEvent()); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to emit session start event.", "error", err)
|
|
}
|
|
}
|
|
|
|
// emitSessionJoinEvent emits a session join event to both the Audit Log as
|
|
// well as sending a "x-teleport-event" global request on the SSH connection.
|
|
// Must be called under session Lock.
|
|
func (s *session) emitSessionJoinEvent(ctx *ServerContext) {
|
|
sessionJoinEvent := &apievents.SessionJoin{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.SessionJoinEvent,
|
|
Code: events.SessionJoinCode,
|
|
ClusterName: ctx.ClusterName,
|
|
},
|
|
ServerMetadata: s.serverMeta,
|
|
SessionMetadata: ctx.GetSessionMetadata(),
|
|
UserMetadata: ctx.Identity.GetUserMetadata(),
|
|
ConnectionMetadata: apievents.ConnectionMetadata{
|
|
RemoteAddr: ctx.ServerConn.RemoteAddr().String(),
|
|
},
|
|
}
|
|
// Local address only makes sense for non-tunnel nodes.
|
|
if !ctx.srv.UseTunnel() {
|
|
sessionJoinEvent.ConnectionMetadata.LocalAddr = ctx.ServerConn.LocalAddr().String()
|
|
}
|
|
|
|
var notifyPartyPayload []byte
|
|
preparedEvent, err := s.Recorder().PrepareSessionEvent(sessionJoinEvent)
|
|
if err == nil {
|
|
// Try marshaling the event prior to emitting it to prevent races since
|
|
// the audit/recording machinery might try to set some fields while the
|
|
// marshal is underway.
|
|
if eventPayload, err := json.Marshal(preparedEvent); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Unable to marshal session join event.", "error", err)
|
|
} else {
|
|
notifyPartyPayload = eventPayload
|
|
}
|
|
|
|
if err := s.recordEvent(ctx.srv.Context(), preparedEvent); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to record session join event.", "error", err)
|
|
}
|
|
if err := s.emitAuditEvent(ctx.srv.Context(), preparedEvent.GetAuditEvent()); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to emit session join event.", "error", err)
|
|
}
|
|
} else {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to set up session join event - event will not be recorded", "error", err)
|
|
}
|
|
|
|
// Notify all members of the party that a new member has joined over the
|
|
// "x-teleport-event" channel.
|
|
for _, p := range s.getParties() {
|
|
if len(notifyPartyPayload) == 0 {
|
|
s.logger.WarnContext(ctx.srv.Context(), "No session join event to send to party.", "party", p)
|
|
continue
|
|
}
|
|
|
|
payload := make([]byte, len(notifyPartyPayload))
|
|
copy(payload, notifyPartyPayload)
|
|
|
|
_, _, err = p.sconn.SendRequest(teleport.SessionEvent, false, payload)
|
|
if err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Unable to send session join event to party.", "party", p, "error", err)
|
|
continue
|
|
}
|
|
s.logger.DebugContext(ctx.srv.Context(), "Sent session join event to party.", "party", p)
|
|
}
|
|
}
|
|
|
|
// emitSessionLeaveEventUnderLock emits a session leave event to both the Audit Log as
|
|
// well as sending a "x-teleport-event" global request on the SSH connection.
|
|
// Must be called under session Lock.
|
|
func (s *session) emitSessionLeaveEventUnderLock(ctx *ServerContext) {
|
|
sessionLeaveEvent := &apievents.SessionLeave{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.SessionLeaveEvent,
|
|
Code: events.SessionLeaveCode,
|
|
ClusterName: ctx.ClusterName,
|
|
},
|
|
ServerMetadata: s.serverMeta,
|
|
SessionMetadata: s.scx.GetSessionMetadata(),
|
|
UserMetadata: ctx.Identity.GetUserMetadata(),
|
|
}
|
|
preparedEvent, prepareErr := s.Recorder().PrepareSessionEvent(sessionLeaveEvent)
|
|
if prepareErr != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to set up session leave event - event will not be recorded.", "error", prepareErr)
|
|
}
|
|
// Notify all members of the party that a new member has left over the
|
|
// "x-teleport-event" channel. This has to be done after preparing the event
|
|
// (to capture any changes by PrepareSessionEvent) but before emitting the
|
|
// event (to prevent a race on sessionLeaveEvent).
|
|
for _, p := range s.parties {
|
|
eventPayload, err := utils.FastMarshal(sessionLeaveEvent)
|
|
if err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Unable to marshal session leave event for party.", "error", err, "party", p)
|
|
continue
|
|
}
|
|
_, _, err = p.sconn.SendRequest(teleport.SessionEvent, false, eventPayload)
|
|
if err != nil {
|
|
// The party's connection may already be closed, in which case we expect an EOF
|
|
if !errors.Is(err, io.EOF) {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Unable to send session leave event to party.", "party", p, "error", err)
|
|
}
|
|
continue
|
|
}
|
|
s.logger.DebugContext(ctx.srv.Context(), "Sent session leave event to party.", "party", p)
|
|
}
|
|
if prepareErr == nil {
|
|
if err := s.recordEvent(ctx.srv.Context(), preparedEvent); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to record session leave event.", "error", err)
|
|
}
|
|
if err := s.emitAuditEvent(ctx.srv.Context(), preparedEvent.GetAuditEvent()); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to emit session leave event.", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// emitSessionEndEvent emits a session end event.
|
|
func (s *session) emitSessionEndEvent() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ctx := s.scx
|
|
if s.endingContext != nil {
|
|
ctx = s.endingContext
|
|
}
|
|
|
|
start, end := s.startTime, time.Now().UTC()
|
|
sessionEndEvent := &apievents.SessionEnd{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.SessionEndEvent,
|
|
Code: events.SessionEndCode,
|
|
ClusterName: ctx.ClusterName,
|
|
},
|
|
ServerMetadata: s.serverMeta,
|
|
SessionMetadata: s.scx.GetSessionMetadata(),
|
|
UserMetadata: ctx.Identity.GetUserMetadata(),
|
|
ConnectionMetadata: apievents.ConnectionMetadata{
|
|
RemoteAddr: ctx.ServerConn.RemoteAddr().String(),
|
|
Protocol: events.EventProtocolSSH,
|
|
},
|
|
EnhancedRecording: s.hasEnhancedRecording,
|
|
Interactive: s.term != nil,
|
|
StartTime: start,
|
|
EndTime: end,
|
|
SessionRecording: s.sessionRecordingLocation(),
|
|
}
|
|
|
|
for _, p := range s.participants {
|
|
username := services.UsernameForCluster(
|
|
services.UsernameForClusterConfig{
|
|
User: p.user,
|
|
OriginClusterName: p.originCluster,
|
|
LocalClusterName: ctx.ClusterName,
|
|
},
|
|
)
|
|
sessionEndEvent.Participants = append(sessionEndEvent.Participants, username)
|
|
}
|
|
|
|
// If there are 0 participants, this is an exec session.
|
|
// Use the user from the session context.
|
|
if len(s.participants) == 0 {
|
|
username := services.UsernameForCluster(
|
|
services.UsernameForClusterConfig{
|
|
User: s.scx.Identity.TeleportUser,
|
|
OriginClusterName: s.scx.Identity.OriginClusterName,
|
|
LocalClusterName: ctx.ClusterName,
|
|
},
|
|
)
|
|
sessionEndEvent.Participants = []string{username}
|
|
}
|
|
|
|
preparedEvent, err := s.Recorder().PrepareSessionEvent(sessionEndEvent)
|
|
if err == nil {
|
|
if err := s.recordEvent(ctx.srv.Context(), preparedEvent); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to record session end event.", "error", err)
|
|
}
|
|
if err := s.emitAuditEvent(ctx.srv.Context(), preparedEvent.GetAuditEvent()); err != nil {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to emit session end event.", "error", err)
|
|
}
|
|
} else {
|
|
s.logger.WarnContext(ctx.srv.Context(), "Failed to set up session end event - event will not be recorded.", "error", err)
|
|
}
|
|
}
|
|
|
|
func (s *session) shouldHandleRecording() bool {
|
|
return s.scx.ShouldHandleSessionRecording()
|
|
}
|
|
|
|
func (s *session) sessionRecordingLocation() string {
|
|
sessionRecMode := s.scx.SessionRecordingConfig.GetMode()
|
|
subKind := s.serverMeta.ServerSubKind
|
|
|
|
// agentless connections always record the session at the proxy
|
|
if !services.IsRecordAtProxy(sessionRecMode) && types.IsOpenSSHNodeSubKind(subKind) {
|
|
if services.IsRecordSync(sessionRecMode) {
|
|
sessionRecMode = types.RecordAtProxySync
|
|
} else {
|
|
sessionRecMode = types.RecordAtProxy
|
|
}
|
|
}
|
|
|
|
return sessionRecMode
|
|
}
|
|
|
|
func (s *session) setEndingContext(ctx *ServerContext) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.endingContext = ctx
|
|
}
|
|
|
|
func (s *session) setHasEnhancedRecording(val bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.hasEnhancedRecording = val
|
|
}
|
|
|
|
// launch launches the session.
|
|
// Must be called under session Lock.
|
|
func (s *session) launch() {
|
|
// Mark the session as started here, as we want to avoid double initialization.
|
|
if s.started.Swap(true) {
|
|
s.logger.DebugContext(s.serverCtx, "Session has already started.")
|
|
return
|
|
}
|
|
|
|
s.logger.DebugContext(s.serverCtx, "Launching session.")
|
|
s.BroadcastMessage("Connecting to %v over SSH", s.serverMeta.ServerHostname)
|
|
|
|
s.io.On()
|
|
|
|
if err := s.tracker.UpdateState(s.serverCtx, types.SessionState_SessionStateRunning); err != nil {
|
|
s.logger.WarnContext(
|
|
s.serverCtx, "Failed to set tracker state.",
|
|
"error", err,
|
|
"state", types.SessionState_SessionStateRunning,
|
|
)
|
|
}
|
|
|
|
// If the identity is verified with an MFA device, we enabled MFA-based presence for the session.
|
|
if s.presenceEnabled {
|
|
go func() {
|
|
ticker := s.registry.clock.NewTicker(PresenceVerifyInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.Chan():
|
|
err := s.checkPresence(s.serverCtx)
|
|
if err != nil {
|
|
s.logger.ErrorContext(
|
|
s.serverCtx, "Failed to check presence, terminating session as a security measure.",
|
|
)
|
|
s.Stop()
|
|
}
|
|
case <-s.stopC:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// copy everything from the pty to the writer. this lets us capture all input
|
|
// and output of the session (because input is echoed to stdout in the pty).
|
|
// the writer contains multiple writers: the session logger and a direct
|
|
// connection to members of the "party" (other people in the session).
|
|
s.term.AddParty(1)
|
|
go func() {
|
|
defer s.term.AddParty(-1)
|
|
|
|
// once everything has been copied, notify the goroutine below. if this code
|
|
// is running in a teleport node, when the exec.Cmd is done it will close
|
|
// the PTY, allowing io.Copy to return. if this is a teleport forwarding
|
|
// node, when the remote side closes the channel (which is what s.term.PTY()
|
|
// returns) io.Copy will return.
|
|
defer close(s.doneCh)
|
|
|
|
_, err := io.Copy(s.io, s.term.PTY())
|
|
s.logger.DebugContext(
|
|
s.serverCtx, "Copying from PTY to writer completed",
|
|
"error", err,
|
|
)
|
|
}()
|
|
|
|
s.term.AddParty(1)
|
|
go func() {
|
|
defer s.term.AddParty(-1)
|
|
|
|
_, err := io.Copy(s.term.PTY(), s.io)
|
|
s.logger.DebugContext(
|
|
s.serverCtx, "Copying from reader to PTY completed",
|
|
"error", err,
|
|
)
|
|
}()
|
|
}
|
|
|
|
// startInteractive starts a new interactive process (or a shell) in the
|
|
// current session.
|
|
func (s *session) startInteractive(ctx context.Context, scx *ServerContext, p *party) error {
|
|
s.mu.Lock()
|
|
canStart, _, err := s.checkIfStartUnderLock()
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if !canStart && services.IsRecordAtProxy(p.ctx.SessionRecordingConfig.GetMode()) {
|
|
go s.Stop()
|
|
return trace.AccessDenied("session requires additional moderation but is in proxy-record mode")
|
|
}
|
|
|
|
inReader, inWriter := io.Pipe()
|
|
|
|
s.mu.Lock()
|
|
s.inWriter = inWriter
|
|
s.mu.Unlock()
|
|
|
|
s.io.AddReader("reader", inReader)
|
|
s.io.AddWriter(sessionRecorderID, utils.WriteCloserWithContext(scx.srv.Context(), s.Recorder()))
|
|
s.BroadcastMessage("Creating session with ID: %v", s.id)
|
|
|
|
if err := s.startTerminal(ctx, scx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Emit a session.start event for the interactive session.
|
|
s.emitSessionStartEvent(scx)
|
|
|
|
if err := s.addParty(p, types.SessionPeerMode); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
var eventsMap map[string]struct{}
|
|
if scx.Identity.AccessPermit != nil {
|
|
eventsMap = eventsMapFromSSHAccessPermit(scx.Identity.AccessPermit)
|
|
} else if scx.srv.GetBPF().Enabled() {
|
|
// in theory this should never happen, as this method should only ever be called either on a
|
|
// standard ssh agent (in which case we will always have an access permit) or a recording
|
|
// proxy (in which case we will never have bpf enabled).
|
|
return trace.BadParameter("cannot start an interactive session with BPF enabled without an ssh access permit (this is a bug)")
|
|
}
|
|
|
|
// Open a BPF recording session. If BPF was not configured, not available,
|
|
// or running in a recording proxy, OpenSession is a NOP.
|
|
sessionContext := &bpf.SessionContext{
|
|
Context: scx.srv.Context(),
|
|
PID: s.term.PID(),
|
|
Emitter: s.emitter,
|
|
Namespace: scx.srv.GetNamespace(),
|
|
SessionID: s.id.String(),
|
|
ServerID: scx.srv.ID(),
|
|
ServerHostname: scx.srv.GetInfo().GetHostname(),
|
|
Login: scx.Identity.Login,
|
|
User: scx.Identity.TeleportUser,
|
|
UserOriginClusterName: scx.Identity.OriginClusterName,
|
|
UserRoles: scx.Identity.MappedRoles,
|
|
UserTraits: scx.Identity.Traits,
|
|
Events: eventsMap,
|
|
}
|
|
|
|
bpfService := scx.srv.GetBPF()
|
|
|
|
// Only wait for the child to be "ready" if BPF is enabled. This is required
|
|
// because PAM might inadvertently move the child process to another cgroup
|
|
// by invoking systemd. If this happens, then the cgroup filter used by BPF
|
|
// will be looking for events in the wrong cgroup and no events will be captured.
|
|
// However, unconditionally waiting for the child to be ready results in PAM
|
|
// deadlocking because stdin/stdout/stderr which it uses to relay details from
|
|
// PAM auth modules are not properly copied until _after_ the shell request is
|
|
// replied to.
|
|
if bpfService.Enabled() {
|
|
if err := s.term.WaitForChild(); err != nil {
|
|
s.logger.ErrorContext(ctx, "Child process never became ready.", "error", err)
|
|
return trace.Wrap(err)
|
|
}
|
|
} else {
|
|
// Clean up the read half of the pipe, and set it to nil so that when the
|
|
// ServerContext is closed it doesn't attempt to a second close.
|
|
if err := s.scx.readyr.Close(); err != nil {
|
|
s.logger.ErrorContext(ctx, "Failed closing child ready pipe", "error", err)
|
|
}
|
|
s.scx.readyr = nil
|
|
}
|
|
|
|
if cgroupID, err := bpfService.OpenSession(sessionContext); err != nil {
|
|
s.logger.ErrorContext(ctx, "Failed to open enhanced recording (interactive) session.", "error", err)
|
|
return trace.Wrap(err)
|
|
} else if cgroupID > 0 {
|
|
// If a cgroup ID was assigned then enhanced session recording was enabled.
|
|
s.setHasEnhancedRecording(true)
|
|
go func() {
|
|
// Close the BPF recording session once the session is closed
|
|
<-s.stopC
|
|
if err := bpfService.CloseSession(sessionContext); err != nil {
|
|
s.logger.ErrorContext(ctx, "Failed to close enhanced recording (interactive) session.", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
s.logger.DebugContext(ctx, "Waiting for continue signal.")
|
|
|
|
// Process has been placed in a cgroup, continue execution.
|
|
s.term.Continue()
|
|
|
|
s.logger.DebugContext(ctx, "Got continue signal.")
|
|
|
|
// wait for exec.Cmd (or receipt of "exit-status" for a forwarding node),
|
|
// once it is received wait for the io.Copy above to finish, then broadcast
|
|
// the "exit-status" to the client.
|
|
go func() {
|
|
result, err := s.term.Wait()
|
|
if err != nil {
|
|
s.logger.ErrorContext(ctx, "Received error waiting for the interactive session to finish.", "error", err)
|
|
}
|
|
|
|
// wait for copying from the pty to be complete or a timeout before
|
|
// broadcasting the result (which will close the pty) if it has not been
|
|
// closed already.
|
|
select {
|
|
case <-time.After(defaults.WaitCopyTimeout):
|
|
s.logger.DebugContext(ctx, "Timed out waiting for PTY copy to finish, session data may be missing.")
|
|
case <-s.doneCh:
|
|
}
|
|
|
|
if result != nil {
|
|
if err := s.registry.broadcastResult(s.id, *result); err != nil {
|
|
s.logger.WarnContext(ctx, "Failed to broadcast session result.", "error", err)
|
|
}
|
|
}
|
|
|
|
if execRequest, err := scx.GetExecRequest(); err == nil && execRequest.GetCommand() != "" {
|
|
emitExecAuditEvent(scx, execRequest.GetCommand(), err)
|
|
}
|
|
|
|
s.emitSessionEndEvent()
|
|
if err := s.Close(); err != nil {
|
|
s.logger.WarnContext(ctx, "Failed to close session.", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *session) startTerminal(ctx context.Context, scx *ServerContext) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// allocate a terminal or take the one previously allocated via a
|
|
// separate "allocate TTY" SSH request
|
|
if s.term = scx.GetTerm(); s.term != nil {
|
|
scx.SetTerm(nil)
|
|
} else if term, err := NewTerminal(scx); err != nil {
|
|
s.logger.InfoContext(ctx, "Unable to allocate new terminal.", "error", err)
|
|
return trace.Wrap(err)
|
|
} else {
|
|
s.term = term
|
|
}
|
|
|
|
if err := s.term.Run(ctx); err != nil {
|
|
s.logger.ErrorContext(ctx, "Unable to run shell command.", "error", err)
|
|
return trace.ConvertSystemError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// newRecorder creates a new [events.SessionPreparerRecorder] to be used as the recorder
|
|
// of the passed in session.
|
|
func newRecorder(s *session, ctx *ServerContext, sessType sessionType) (events.SessionPreparerRecorder, error) {
|
|
// determine session recording mode. in theory we could choose to only do this in the unhappy
|
|
// path since thats the only place we use it, but we do it here to ensure that any test coverage
|
|
// we have for this code will always enforce its permit requirements.
|
|
var sessionRecordingMode constants.SessionRecordingMode
|
|
switch {
|
|
case ctx.Identity.AccessPermit != nil:
|
|
sessionRecordingMode = constants.SessionRecordingMode(ctx.Identity.AccessPermit.SessionRecordingMode)
|
|
case ctx.Identity.ProxyingPermit != nil:
|
|
sessionRecordingMode = ctx.Identity.ProxyingPermit.SessionRecordingMode
|
|
default:
|
|
return nil, trace.BadParameter("session recorder creation only supported in context of ssh access or proxying permit")
|
|
}
|
|
|
|
// Don't record on the Node when the proxy forwarding node is already recording.
|
|
if !s.shouldHandleRecording() {
|
|
s.logger.DebugContext(s.serverCtx, "Session will be recorded at proxy.")
|
|
return events.WithNoOpPreparer(events.NewDiscardRecorder()), nil
|
|
}
|
|
|
|
// Don't record non-interactive sessions when enhanced recording is disabled.
|
|
if sessType == sessionTypeNonInteractive && !ctx.srv.GetBPF().Enabled() {
|
|
return events.WithNoOpPreparer(events.NewDiscardRecorder()), nil
|
|
}
|
|
|
|
rec, err := recorder.New(recorder.Config{
|
|
SessionID: s.id,
|
|
ServerID: s.serverMeta.ServerID,
|
|
Namespace: s.serverMeta.ServerNamespace,
|
|
Clock: s.registry.clock,
|
|
ClusterName: ctx.ClusterName,
|
|
RecordingCfg: ctx.SessionRecordingConfig,
|
|
SyncStreamer: ctx.srv,
|
|
DataDir: ctx.srv.GetDataDir(),
|
|
Component: teleport.Component(teleport.ComponentSession, ctx.srv.Component()),
|
|
// Session stream is using server context, not session context,
|
|
// to make sure that session is uploaded even after it is closed
|
|
Context: ctx.srv.Context(),
|
|
})
|
|
if err != nil {
|
|
switch sessionRecordingMode {
|
|
case constants.SessionRecordingModeBestEffort:
|
|
s.logger.WarnContext(
|
|
s.serverCtx, "Failed to initialize session recording, disabling it for this session.",
|
|
"error", err,
|
|
)
|
|
|
|
s.BroadcastSystemMessage(sessionRecordingWarningMessage)
|
|
return events.WithNoOpPreparer(events.NewDiscardRecorder()), nil
|
|
}
|
|
|
|
return nil, trace.ConnectionProblem(err, sessionRecordingErrorMessage)
|
|
}
|
|
|
|
return rec, nil
|
|
}
|
|
|
|
func (s *session) startExec(ctx context.Context, channel ssh.Channel, scx *ServerContext) error {
|
|
// Emit a session.start event for the exec session.
|
|
s.emitSessionStartEvent(scx)
|
|
|
|
execRequest, err := scx.GetExecRequest()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Start execution. If the program failed to start, send that result back.
|
|
// Note this is a partial start. Teleport will have re-exec'ed itself and
|
|
// wait until it's been placed in a cgroup and told to continue.
|
|
result, err := execRequest.Start(ctx, channel)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if result != nil {
|
|
s.logger.DebugContext(
|
|
ctx, "Exec request completed.",
|
|
"request", execRequest,
|
|
"result", result,
|
|
)
|
|
scx.SendExecResult(ctx, *result)
|
|
}
|
|
|
|
var eventsMap map[string]struct{}
|
|
if scx.Identity.AccessPermit != nil {
|
|
eventsMap = eventsMapFromSSHAccessPermit(scx.Identity.AccessPermit)
|
|
} else if scx.srv.GetBPF().Enabled() {
|
|
// in theory this should never happen, as this method should only ever be called either on a
|
|
// standard ssh agent (in which case we will always have an access permit) or a recording
|
|
// proxy (in which case we will never have bpf enabled).
|
|
return trace.BadParameter("cannot start exec with BPF enabled without an ssh access permit (this is a bug)")
|
|
}
|
|
|
|
// Open a BPF recording session. If BPF was not configured, not available,
|
|
// or running in a recording proxy, OpenSession is a NOP.
|
|
sessionContext := &bpf.SessionContext{
|
|
Context: scx.srv.Context(),
|
|
PID: scx.execRequest.PID(),
|
|
Emitter: s.emitter,
|
|
Namespace: scx.srv.GetNamespace(),
|
|
SessionID: string(s.id),
|
|
ServerID: scx.srv.ID(),
|
|
ServerHostname: scx.srv.GetInfo().GetHostname(),
|
|
Login: scx.Identity.Login,
|
|
User: scx.Identity.TeleportUser,
|
|
UserOriginClusterName: scx.Identity.OriginClusterName,
|
|
Events: eventsMap,
|
|
}
|
|
|
|
if err := execRequest.WaitForChild(); err != nil {
|
|
s.logger.ErrorContext(ctx, "Child process never became ready.", "error", err)
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
cgroupID, err := scx.srv.GetBPF().OpenSession(sessionContext)
|
|
if err != nil {
|
|
s.logger.ErrorContext(
|
|
ctx, "Failed to open enhanced recording (exec) session.",
|
|
"command", execRequest.GetCommand(),
|
|
"error", err,
|
|
)
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// If a cgroup ID was assigned then enhanced session recording was enabled.
|
|
if cgroupID > 0 {
|
|
s.setHasEnhancedRecording(true)
|
|
}
|
|
|
|
// Process has been placed in a cgroup, continue execution.
|
|
execRequest.Continue()
|
|
|
|
// Process is running, wait for it to stop.
|
|
go func() {
|
|
result = execRequest.Wait()
|
|
if result != nil {
|
|
scx.SendExecResult(ctx, *result)
|
|
}
|
|
|
|
// Wait a little bit to let all events filter through before closing the
|
|
// BPF session so everything can be recorded.
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// Close the BPF recording session. If BPF was not configured, not available,
|
|
// or running in a recording proxy, this is simply a NOP.
|
|
err = scx.srv.GetBPF().CloseSession(sessionContext)
|
|
if err != nil {
|
|
s.logger.ErrorContext(ctx, "Failed to close enhanced recording (exec) session.", "error", err)
|
|
}
|
|
|
|
s.emitSessionEndEvent()
|
|
s.Close()
|
|
|
|
s.io.Close()
|
|
close(s.doneCh)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *session) broadcastResult(r ExecResult) {
|
|
payload := ssh.Marshal(struct{ C uint32 }{C: uint32(r.Code)})
|
|
for _, p := range s.getParties() {
|
|
if _, err := p.ch.SendRequest("exit-status", false, payload); err != nil {
|
|
s.logger.InfoContext(
|
|
s.serverCtx, "Failed to send exit status",
|
|
"command", r.Command,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) String() string {
|
|
return fmt.Sprintf("session(id=%v, parties=%v)", s.id, len(s.getParties()))
|
|
}
|
|
|
|
// removePartyUnderLock removes the party from the in-memory map that holds all party members
|
|
// and closes their underlying ssh channels. This may also trigger the session to end
|
|
// if the party is the last in the session or has policies that dictate it to end.
|
|
// Must be called under session Lock.
|
|
func (s *session) removePartyUnderLock(p *party) error {
|
|
s.logger.InfoContext(s.serverCtx, "Removing party from session.", "party", p)
|
|
|
|
// Remove participant from in-memory map of party members.
|
|
delete(s.parties, p.id)
|
|
|
|
s.BroadcastMessage("User %v left the session.", p.user)
|
|
|
|
// Update session tracker
|
|
s.logger.DebugContext(s.serverCtx, "Removing participant from tracker.", "party", p)
|
|
if err := s.tracker.RemoveParticipant(s.serverCtx, p.id.String()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Remove party for the term writer
|
|
s.io.DeleteWriter(string(p.id))
|
|
|
|
// Emit session leave event to both the Audit Log and over the
|
|
// "x-teleport-event" channel in the SSH connection.
|
|
s.emitSessionLeaveEventUnderLock(p.ctx)
|
|
|
|
canRun, policyOptions, err := s.checkIfStartUnderLock()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if !canRun {
|
|
if policyOptions.OnLeaveAction == types.OnSessionLeaveTerminate {
|
|
// Force termination in goroutine to avoid deadlock
|
|
go s.registry.ForceTerminate(s.scx)
|
|
return nil
|
|
}
|
|
|
|
// pause session and wait for another party to resume
|
|
s.io.Off()
|
|
s.BroadcastMessage("Session paused, Waiting for required participants...")
|
|
if err := s.tracker.UpdateState(s.serverCtx, types.SessionState_SessionStatePending); err != nil {
|
|
s.logger.WarnContext(
|
|
s.serverCtx, "Failed to set tracker state.",
|
|
"error", err,
|
|
"state", types.SessionState_SessionStatePending,
|
|
)
|
|
}
|
|
|
|
go func() {
|
|
if state := s.tracker.WaitForStateUpdate(types.SessionState_SessionStatePending); state == types.SessionState_SessionStateRunning {
|
|
s.BroadcastMessage("Resuming session...")
|
|
s.io.On()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// If the leaving party was the last one in the session, start the lingerAndDie
|
|
// goroutine. Parties that join during the linger duration will cancel the
|
|
// goroutine to prevent the session from ending with active parties.
|
|
if len(s.parties) == 0 && !s.isStopped() {
|
|
ctx, cancel := context.WithCancel(s.serverCtx)
|
|
s.lingerAndDieCancel = cancel
|
|
go s.lingerAndDie(ctx, p)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isStopped does not need to be called under sessionLock
|
|
func (s *session) isStopped() bool {
|
|
select {
|
|
case <-s.stopC:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// lingerAndDie will let the party-less session linger for a short
|
|
// duration, and then die if no parties have joined.
|
|
func (s *session) lingerAndDie(ctx context.Context, party *party) {
|
|
s.logger.DebugContext(ctx, "Session has no active party members.")
|
|
|
|
select {
|
|
case <-s.registry.clock.After(defaults.SessionIdlePeriod):
|
|
s.logger.InfoContext(ctx, "Session will be garbage collected.")
|
|
|
|
// set closing context to the leaving party to show who ended the session.
|
|
s.setEndingContext(party.ctx)
|
|
|
|
// Stop the session, and let the background processes
|
|
// complete cleanup and close the session.
|
|
s.Stop()
|
|
case <-ctx.Done():
|
|
s.logger.InfoContext(ctx, "Session has become active again.")
|
|
return
|
|
case <-s.stopC:
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *session) checkPresence(ctx context.Context) error {
|
|
// We cannot check presence on the local tracker as that will not
|
|
// be updated in response to parties performing their presence
|
|
// checks. To prevent the stale version of the session tracker from
|
|
// terminating a session we must get the session tracker from Auth.
|
|
tracker, err := s.registry.SessionTrackerService.GetSessionTracker(ctx, s.tracker.tracker.GetSessionID())
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, participant := range tracker.GetParticipants() {
|
|
if participant.User == s.initiator.user && participant.Cluster == s.initiator.cluster {
|
|
continue
|
|
}
|
|
|
|
if participant.Mode == string(types.SessionModeratorMode) && s.registry.clock.Now().UTC().After(participant.LastActive.Add(PresenceMaxDifference)) {
|
|
s.logger.WarnContext(
|
|
ctx, "Participant is not active, kicking.",
|
|
"participant", participant.ID,
|
|
)
|
|
if party := s.parties[rsession.ID(participant.ID)]; party != nil {
|
|
if err := party.closeUnderSessionLock(); err != nil {
|
|
s.logger.ErrorContext(
|
|
ctx, "Failed to remove party.",
|
|
"party", party, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FileTransferRequest is a request to upload or download a file from a node.
|
|
type FileTransferRequest struct {
|
|
// ID is a UUID that uniquely identifies a file transfer request
|
|
// and is unlikely to collide with another file transfer request
|
|
ID string
|
|
// Requester is the Teleport User that requested the file transfer
|
|
Requester string
|
|
// Download is true if the request is a download, false if its an upload
|
|
Download bool
|
|
// Filename is the name of the file to upload.
|
|
Filename string
|
|
// Location of the requested download or where a file will be uploaded
|
|
Location string
|
|
// approvers is a list of participants of moderator or peer type that have approved the request
|
|
approvers map[string]*party
|
|
}
|
|
|
|
func (s *session) checkIfFileTransferApproved(req *FileTransferRequest) (bool, error) {
|
|
var participants []moderation.SessionAccessContext
|
|
|
|
for _, party := range req.approvers {
|
|
if party.ctx.Identity.TeleportUser == s.initiator.user && party.ctx.Identity.OriginClusterName == s.initiator.cluster {
|
|
continue
|
|
}
|
|
|
|
var roles []types.Role
|
|
if party.ctx.Identity.UnstableSessionJoiningAccessChecker != nil {
|
|
roles = party.ctx.Identity.UnstableSessionJoiningAccessChecker.Roles()
|
|
}
|
|
|
|
participants = append(participants, moderation.SessionAccessContext{
|
|
Username: party.ctx.Identity.TeleportUser,
|
|
Roles: roles,
|
|
Mode: party.mode,
|
|
})
|
|
}
|
|
|
|
isApproved, _, err := s.access.FulfilledFor(participants)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
|
|
return isApproved, nil
|
|
}
|
|
|
|
// newFileTransferRequest takes FileTransferParams and creates a new fileTransferRequest struct
|
|
func (s *session) newFileTransferRequest(params *rsession.FileTransferRequestParams) (*FileTransferRequest, error) {
|
|
location, err := s.expandFileTransferRequestPath(params.Location)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
req := FileTransferRequest{
|
|
ID: uuid.New().String(),
|
|
Requester: params.Requester,
|
|
Location: location,
|
|
Filename: params.Filename,
|
|
Download: params.Download,
|
|
approvers: make(map[string]*party),
|
|
}
|
|
|
|
return &req, nil
|
|
}
|
|
|
|
func (s *session) expandFileTransferRequestPath(p string) (string, error) {
|
|
expanded := filepath.Clean(p)
|
|
dir := filepath.Dir(expanded)
|
|
|
|
tildePrefixed := dir == "~"
|
|
noBaseDir := dir == "."
|
|
if tildePrefixed || noBaseDir {
|
|
localUser, err := user.Lookup(s.login)
|
|
if err != nil {
|
|
return "", trace.Wrap(err)
|
|
}
|
|
|
|
exists, err := CheckHomeDir(localUser)
|
|
if err != nil {
|
|
return "", trace.Wrap(err)
|
|
}
|
|
homeDir := localUser.HomeDir
|
|
if !exists {
|
|
homeDir = string(os.PathSeparator)
|
|
}
|
|
|
|
if tildePrefixed {
|
|
// expand home dir to make an absolute path
|
|
expanded = filepath.Join(homeDir, expanded[2:])
|
|
} else {
|
|
// if no directories are specified SFTP will assume the file
|
|
// to be in the user's home dir
|
|
expanded = filepath.Join(homeDir, expanded)
|
|
}
|
|
}
|
|
|
|
return expanded, nil
|
|
}
|
|
|
|
// addFileTransferRequest will create a new file transfer request and add it to the current session's fileTransferRequests map
|
|
// and broadcast the appropriate string to the session.
|
|
func (s *session) addFileTransferRequest(params *rsession.FileTransferRequestParams, scx *ServerContext) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.fileTransferReq != nil {
|
|
return trace.AlreadyExists("a file transfer request already exists for this session")
|
|
}
|
|
if !params.Download && params.Filename == "" {
|
|
return trace.BadParameter("no source file is set for the upload")
|
|
}
|
|
|
|
req, err := s.newFileTransferRequest(params)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
s.fileTransferReq = req
|
|
|
|
if params.Download {
|
|
s.BroadcastMessage("User %s would like to download: %s", params.Requester, params.Location)
|
|
} else {
|
|
s.BroadcastMessage("User %s would like to upload %s to: %s", params.Requester, params.Filename, params.Location)
|
|
}
|
|
err = s.registry.notifyFileTransferRequestUnderLock(s.fileTransferReq, FileTransferUpdate, scx)
|
|
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// approveFileTransferRequest will add the approver to the approvers map of a file transfer request and notify the members
|
|
// of the session if the updated approvers map would fulfill the moderated policy.
|
|
func (s *session) approveFileTransferRequest(params *rsession.FileTransferDecisionParams, scx *ServerContext) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.fileTransferReq == nil {
|
|
return trace.NotFound("File Transfer Request %s not found", params.RequestID)
|
|
}
|
|
if s.fileTransferReq.ID != params.RequestID {
|
|
return trace.BadParameter("current file transfer request is not %s", params.RequestID)
|
|
}
|
|
|
|
var approver *party
|
|
for _, p := range s.parties {
|
|
if p.ctx.ID() == scx.ID() {
|
|
approver = p
|
|
}
|
|
}
|
|
if approver == nil {
|
|
return trace.AccessDenied("cannot approve file transfer requests if not in the current moderated session")
|
|
}
|
|
|
|
s.fileTransferReq.approvers[approver.user] = approver
|
|
s.BroadcastMessage("%s approved file transfer request %s", scx.Identity.TeleportUser, s.fileTransferReq.ID)
|
|
|
|
// check if policy is fulfilled
|
|
approved, err := s.checkIfFileTransferApproved(s.fileTransferReq)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
var eventType FileTransferRequestEvent
|
|
if approved {
|
|
eventType = FileTransferApproved
|
|
} else {
|
|
eventType = FileTransferUpdate
|
|
}
|
|
err = s.registry.notifyFileTransferRequestUnderLock(s.fileTransferReq, eventType, scx)
|
|
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// denyFileTransferRequest will deny a file transfer request and remove it from the current session's file transfer requests map.
|
|
// A file transfer request does not persist after deny, so there is no "denied" state. Deny in this case is synonymous with delete
|
|
// with the addition of checking for a valid denier.
|
|
func (s *session) denyFileTransferRequest(params *rsession.FileTransferDecisionParams, scx *ServerContext) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.fileTransferReq == nil {
|
|
return trace.NotFound("file transfer request %s not found", params.RequestID)
|
|
}
|
|
if s.fileTransferReq.ID != params.RequestID {
|
|
return trace.BadParameter("current file transfer request is not %s", params.RequestID)
|
|
}
|
|
|
|
var denier *party
|
|
for _, p := range s.parties {
|
|
if p.ctx.ID() == scx.ID() {
|
|
denier = p
|
|
}
|
|
}
|
|
if denier == nil {
|
|
return trace.AccessDenied("cannot deny file transfer requests if not in the current moderated session")
|
|
}
|
|
|
|
req := s.fileTransferReq
|
|
s.fileTransferReq = nil
|
|
|
|
s.BroadcastMessage("%s denied file transfer request %s", scx.Identity.TeleportUser, req.ID)
|
|
err := s.registry.notifyFileTransferRequestUnderLock(req, FileTransferDenied, scx)
|
|
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// checkIfStartUnderLock determines if any moderation policies associated with
|
|
// the session are satisfied.
|
|
// Must be called under session Lock.
|
|
func (s *session) checkIfStartUnderLock() (bool, moderation.PolicyOptions, error) {
|
|
var participants []moderation.SessionAccessContext
|
|
|
|
for _, party := range s.parties {
|
|
if party.ctx.Identity.TeleportUser == s.initiator.user &&
|
|
party.ctx.Identity.OriginClusterName == s.initiator.cluster {
|
|
continue
|
|
}
|
|
|
|
var roles []types.Role
|
|
if party.ctx.Identity.UnstableSessionJoiningAccessChecker != nil {
|
|
roles = party.ctx.Identity.UnstableSessionJoiningAccessChecker.Roles()
|
|
}
|
|
|
|
participants = append(participants, moderation.SessionAccessContext{
|
|
Username: party.ctx.Identity.TeleportUser,
|
|
Roles: roles,
|
|
Mode: party.mode,
|
|
})
|
|
}
|
|
|
|
shouldStart, policyOptions, err := s.access.FulfilledFor(participants)
|
|
if err != nil {
|
|
return false, moderation.PolicyOptions{}, trace.Wrap(err)
|
|
}
|
|
|
|
return shouldStart, policyOptions, nil
|
|
}
|
|
|
|
// addParty is called when a new party joins the session.
|
|
func (s *session) addParty(p *party, mode types.SessionParticipantMode) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.login != p.login && p.login != teleport.SSHSessionJoinPrincipal {
|
|
return trace.AccessDenied(
|
|
"can't switch users from %v to %v for session %v",
|
|
s.login, p.login, s.id)
|
|
}
|
|
|
|
if s.tracker.GetState() == types.SessionState_SessionStateTerminated {
|
|
return trace.AccessDenied("The requested session is not active")
|
|
}
|
|
|
|
if len(s.parties) == 0 {
|
|
canStart, _, err := s.checkIfStartUnderLock()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if !canStart && services.IsRecordAtProxy(p.ctx.SessionRecordingConfig.GetMode()) {
|
|
go s.Stop()
|
|
return trace.AccessDenied("session requires additional moderation but is in proxy-record mode")
|
|
}
|
|
}
|
|
|
|
// Cancel lingerAndDie goroutine if one is running.
|
|
if s.lingerAndDieCancel != nil {
|
|
s.lingerAndDieCancel()
|
|
s.lingerAndDieCancel = nil
|
|
}
|
|
|
|
// Adds participant to in-memory map of party members.
|
|
s.parties[p.id] = p
|
|
s.participants[p.id] = p
|
|
p.ctx.AddCloser(p)
|
|
|
|
// Write last chunk (so the newly joined parties won't stare at a blank screen).
|
|
if _, err := p.Write(s.io.GetRecentHistory()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
s.BroadcastMessage("User %v joined the session with participant mode: %v.", p.user, p.mode)
|
|
|
|
// Register this party as one of the session writers (output will go to it).
|
|
s.io.AddWriter(string(p.id), p)
|
|
|
|
// Send the participant mode and controls to the additional participant
|
|
if s.login != p.login {
|
|
err := MsgParticipantCtrls(p.ch, mode)
|
|
if err != nil {
|
|
s.logger.ErrorContext(
|
|
s.serverCtx, "Could not send into message to participant",
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
s.logger.InfoContext(
|
|
s.serverCtx, "New party has joined the session with a participant mode",
|
|
"participant", p.String(),
|
|
"mode", p.mode,
|
|
)
|
|
|
|
if mode == types.SessionPeerMode {
|
|
s.term.AddParty(1)
|
|
|
|
// This goroutine keeps pumping party's input into the session.
|
|
go func() {
|
|
defer s.term.AddParty(-1)
|
|
_, err := io.Copy(s.inWriter, p)
|
|
s.logger.DebugContext(
|
|
s.serverCtx, "Copying from Party to session writer completed with error.",
|
|
"party", p,
|
|
"error", err,
|
|
)
|
|
}()
|
|
}
|
|
|
|
if s.tracker.GetState() == types.SessionState_SessionStatePending {
|
|
canStart, _, err := s.checkIfStartUnderLock()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
switch {
|
|
case canStart && !s.started.Load():
|
|
s.launch()
|
|
|
|
return nil
|
|
case canStart:
|
|
// If the session is already running, but the party is a moderator that leaved
|
|
// a session with onLeave=pause and then rejoined, we need to unpause the session.
|
|
// When the moderator leaved the session, the session was paused, and we spawn
|
|
// a goroutine to wait for the moderator to rejoin. If the moderator rejoins
|
|
// before the session ends, we need to unpause the session by updating its state and
|
|
// the goroutine will unblock the s.io terminal.
|
|
// types.SessionState_SessionStatePending marks a session that is waiting for
|
|
// a moderator to rejoin.
|
|
if err := s.tracker.UpdateState(s.serverCtx, types.SessionState_SessionStateRunning); err != nil {
|
|
s.logger.WarnContext(
|
|
s.serverCtx, "Failed to set tracker state.",
|
|
"state", types.SessionState_SessionStateRunning,
|
|
)
|
|
}
|
|
default:
|
|
const base = "Waiting for required participants..."
|
|
if s.displayParticipantRequirements {
|
|
s.BroadcastMessage(base+"\r\n%v", s.access.PrettyRequirementsList())
|
|
} else {
|
|
s.BroadcastMessage(base)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *session) join(ch ssh.Channel, scx *ServerContext, mode types.SessionParticipantMode) error {
|
|
if scx.Identity.TeleportUser != s.initiator.user || scx.Identity.OriginClusterName != s.initiator.cluster {
|
|
var roles []types.Role
|
|
if scx.Identity.UnstableSessionJoiningAccessChecker != nil {
|
|
roles = scx.Identity.UnstableSessionJoiningAccessChecker.Roles()
|
|
}
|
|
|
|
accessContext := moderation.SessionAccessContext{
|
|
Username: scx.Identity.TeleportUser,
|
|
Roles: roles,
|
|
}
|
|
|
|
modes := s.access.CanJoin(accessContext)
|
|
if !slices.Contains(modes, mode) {
|
|
return trace.AccessDenied("insufficient permissions to join session %v", s.id)
|
|
}
|
|
|
|
if s.presenceEnabled {
|
|
_, _, err := scx.ServerConn.SendRequest(teleport.MFAPresenceRequest, false, nil)
|
|
if err != nil {
|
|
return trace.WrapWithMessage(err, "failed to send MFA presence request")
|
|
}
|
|
}
|
|
}
|
|
|
|
// create a new "party" (connected client) and launch/join the session.
|
|
p := newParty(s, mode, ch, scx)
|
|
if err := s.addParty(p, mode); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
s.logger.DebugContext(s.serverCtx, "Tracking participant.", "party", p)
|
|
participant := &types.Participant{
|
|
ID: p.id.String(),
|
|
User: p.user,
|
|
Cluster: p.originCluster,
|
|
Mode: string(p.mode),
|
|
LastActive: time.Now().UTC(),
|
|
}
|
|
if err := s.tracker.AddParticipant(s.serverCtx, participant); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Emit session join event to both the Audit Log as well as over the
|
|
// "x-teleport-event" channel in the SSH connection.
|
|
s.emitSessionJoinEvent(p.ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *session) getParties() (parties []*party) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, p := range s.parties {
|
|
parties = append(parties, p)
|
|
}
|
|
return parties
|
|
}
|
|
|
|
type party struct {
|
|
sync.Mutex
|
|
|
|
log *slog.Logger
|
|
login string
|
|
user string
|
|
originCluster string
|
|
serverID string
|
|
site string
|
|
id rsession.ID
|
|
s *session
|
|
sconn *ssh.ServerConn
|
|
ch ssh.Channel
|
|
ctx *ServerContext
|
|
lastActive time.Time
|
|
mode types.SessionParticipantMode
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func newParty(s *session, mode types.SessionParticipantMode, ch ssh.Channel, ctx *ServerContext) *party {
|
|
pid := rsession.NewID()
|
|
return &party{
|
|
log: slog.With(
|
|
teleport.ComponentKey, teleport.Component(teleport.ComponentSession, ctx.srv.Component()),
|
|
"party_id", pid,
|
|
),
|
|
user: ctx.Identity.TeleportUser,
|
|
originCluster: ctx.Identity.OriginClusterName,
|
|
login: ctx.Identity.Login,
|
|
serverID: s.registry.Srv.ID(),
|
|
site: ctx.ServerConn.RemoteAddr().String(),
|
|
id: rsession.NewID(),
|
|
ch: ch,
|
|
ctx: ctx,
|
|
s: s,
|
|
sconn: ctx.ServerConn,
|
|
mode: mode,
|
|
}
|
|
}
|
|
|
|
func (p *party) updateActivity() {
|
|
p.Lock()
|
|
defer p.Unlock()
|
|
p.lastActive = time.Now()
|
|
}
|
|
|
|
func (p *party) Read(bytes []byte) (int, error) {
|
|
p.updateActivity()
|
|
return p.ch.Read(bytes)
|
|
}
|
|
|
|
func (p *party) Write(bytes []byte) (int, error) {
|
|
return p.ch.Write(bytes)
|
|
}
|
|
|
|
func (p *party) String() string {
|
|
return fmt.Sprintf("%v party(id=%v)", p.ctx, p.id)
|
|
}
|
|
|
|
func (p *party) LogValue() slog.Value {
|
|
return slog.GroupValue(
|
|
slog.String("id", p.id.String()),
|
|
slog.String("remote_addr", p.sconn.RemoteAddr().String()),
|
|
)
|
|
}
|
|
|
|
// Close is called when the party's session ctx is closed.
|
|
func (p *party) Close() error {
|
|
p.s.mu.Lock()
|
|
defer p.s.mu.Unlock()
|
|
return p.closeUnderSessionLock()
|
|
}
|
|
|
|
// closeUnderSessionLock closes the party, and removes it from its session.
|
|
// Must be called under session Lock.
|
|
func (p *party) closeUnderSessionLock() error {
|
|
var err error
|
|
p.closeOnce.Do(func() {
|
|
p.log.InfoContext(p.ctx.cancelContext, "Closing party")
|
|
// Remove party from its session
|
|
err = trace.NewAggregate(p.s.removePartyUnderLock(p), p.ch.Close())
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
// trackSession creates a new session tracker for the ssh session.
|
|
// While ctx is open, the session tracker's expiration will be extended
|
|
// on an interval until the session tracker is closed.
|
|
func (s *session) trackSession(ctx context.Context, teleportUser string, policySet []*types.SessionTrackerPolicySet, p *party, sessType sessionType) error {
|
|
s.logger.DebugContext(ctx, "Tracking participant.", "party", p)
|
|
var initialCommand []string
|
|
if execRequest, err := s.scx.GetExecRequest(); err == nil {
|
|
initialCommand = []string{execRequest.GetCommand()}
|
|
}
|
|
trackerSpec := types.SessionTrackerSpecV1{
|
|
SessionID: s.id.String(),
|
|
Kind: string(types.SSHSessionKind),
|
|
State: types.SessionState_SessionStatePending,
|
|
Hostname: s.serverMeta.ServerHostname,
|
|
Address: s.scx.srv.ID(),
|
|
ClusterName: s.scx.ClusterName,
|
|
Login: s.login,
|
|
HostUser: teleportUser,
|
|
HostPolicies: policySet,
|
|
Created: s.registry.clock.Now().UTC(),
|
|
Participants: []types.Participant{
|
|
{
|
|
ID: p.id.String(),
|
|
User: p.user,
|
|
Cluster: p.originCluster,
|
|
Mode: string(p.mode),
|
|
LastActive: s.registry.clock.Now().UTC(),
|
|
},
|
|
},
|
|
HostID: s.registry.Srv.HostUUID(),
|
|
TargetSubKind: s.serverMeta.ServerSubKind,
|
|
InitialCommand: initialCommand,
|
|
Reason: s.scx.GetSessionParams().Reason,
|
|
Invited: s.scx.GetSessionParams().Invited,
|
|
}
|
|
|
|
// Don't propagate the session tracker to the backend if:
|
|
// - this is a Teleport Node and proxy recording mode is turned on (tracking is handled by the proxy forwarding node)
|
|
// - this is a non-interactive session
|
|
// - the session was initiated by a bot
|
|
svc := s.registry.SessionTrackerService
|
|
if !s.shouldHandleRecording() || sessType == sessionTypeNonInteractive || s.scx.Identity.BotName != "" {
|
|
svc = nil
|
|
}
|
|
|
|
s.logger.DebugContext(ctx, "Attempting to create session tracker.")
|
|
tracker, err := NewSessionTracker(ctx, trackerSpec, svc)
|
|
switch {
|
|
// there was an error creating the tracker for a moderated session - terminate the session
|
|
case err != nil && svc != nil && s.access.IsModerated():
|
|
s.logger.WarnContext(ctx, "Failed to create session tracker, unable to proceed for moderated session", "error", err)
|
|
return trace.Wrap(err)
|
|
// there was an error creating the tracker for a non-moderated session - permit the session with a local tracker
|
|
case err != nil && svc != nil && !s.access.IsModerated():
|
|
s.logger.WarnContext(ctx, "Failed to create session tracker, proceeding with local session tracker for non-moderated session", "error", err)
|
|
|
|
localTracker, err := NewSessionTracker(ctx, trackerSpec, nil)
|
|
// this error means there are problems with the trackerSpec, we need to return it
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
s.tracker = localTracker
|
|
// there was an error even though the tracker wasn't being propagated - return it
|
|
case err != nil && svc == nil:
|
|
return trace.Wrap(err)
|
|
// the tracker was created successfully
|
|
case err == nil:
|
|
s.tracker = tracker
|
|
}
|
|
|
|
go func() {
|
|
ctx, span := tracing.DefaultProvider().Tracer("session").Start(
|
|
s.serverCtx,
|
|
"session/UpdateExpirationLoop",
|
|
oteltrace.WithLinks(oteltrace.LinkFromContext(ctx)),
|
|
oteltrace.WithAttributes(
|
|
attribute.String("session_id", s.id.String()),
|
|
attribute.String("kind", string(types.SSHSessionKind)),
|
|
),
|
|
)
|
|
defer span.End()
|
|
|
|
if err := s.tracker.UpdateExpirationLoop(ctx, s.registry.clock); err != nil {
|
|
s.logger.WarnContext(ctx, "Failed to update session tracker expiration.", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// emitAuditEvent emits audit events.
|
|
func (s *session) emitAuditEvent(ctx context.Context, event apievents.AuditEvent) error {
|
|
return s.emitter.EmitAuditEvent(ctx, event)
|
|
}
|
|
|
|
func (s *session) recordEvent(ctx context.Context, event apievents.PreparedSessionEvent) error {
|
|
rec := s.Recorder()
|
|
select {
|
|
case <-rec.Done():
|
|
s.setRecorder(events.WithNoOpPreparer(events.NewDiscardRecorder()))
|
|
return nil
|
|
default:
|
|
return trace.Wrap(rec.RecordEvent(ctx, event))
|
|
}
|
|
}
|
|
|
|
// onWriteErrorCallback builds the `OnWriteError` `TermManager` callback.
|
|
func (s *session) onWriteErrorCallback(sessionRecordingMode constants.SessionRecordingMode) func(idString string, err error) {
|
|
return func(idString string, err error) {
|
|
if idString == sessionRecorderID {
|
|
switch sessionRecordingMode {
|
|
case constants.SessionRecordingModeBestEffort:
|
|
s.logger.WarnContext(s.serverCtx, "Failed to write to session recorder, disabling session recording.")
|
|
// Send inside a goroutine since the callback is called from inside
|
|
// the writer.
|
|
go s.BroadcastSystemMessage(sessionRecordingWarningMessage)
|
|
default:
|
|
s.logger.ErrorContext(s.serverCtx, "Failed to write to session recorder, stopping session.")
|
|
// stop in goroutine to avoid deadlock
|
|
go func() {
|
|
s.BroadcastSystemMessage(sessionRecordingErrorMessage)
|
|
s.Stop()
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func eventsMapFromSSHAccessPermit(permit *decisionpb.SSHAccessPermit) map[string]struct{} {
|
|
eventsMap := make(map[string]struct{}, len(permit.BpfEvents))
|
|
for _, event := range permit.BpfEvents {
|
|
eventsMap[event] = struct{}{}
|
|
}
|
|
|
|
return eventsMap
|
|
}
|