Files
tailscale/ssh/tailssh/session.go
T
James Tucker a1e51f4942 tsnet: authorize ListenSSH peers, fix PTY emulation and session queue bound
ListenSSH now documents its session trust model and takes an
authorization callback. A session is accepted from any peer that the
tailnet ACL's TCP rules allow and that resolves with WhoIs. The server
performs no SSH client authentication, does not map the peer to a local
user, and does not record the session. The application is responsible
for what each session does. Set Server.SSHAuthCallback to reject
unwanted sessions before they reach Accept. A rejected session is
refused at open with exit status 1 and is never yielded to Accept. The
callback is plumbed through LocalBackend.ListenSSH and HookListenSSH.
With no callback, behavior is unchanged.

The listener's SSH server runs session channels only, with no port
forwarding. Closing the listener closes the accepted connections.

- DisablePTYEmulation runs on every session, so a PTY session passes
  data through unchanged instead of translating "\n" to "\r\n". The
  translation corrupted binary data read through the net.Conn
  interface. Window changes are drained for every session, even when
  the application never calls Pty, because gliderssh writes window
  changes from the session request loop and an undrained channel
  blocks the session's requests. Pty drops window changes when the
  application is not reading them.

- The handshake is bounded by TS_SSH_LOGIN_GRACE_TIME, default 120
  seconds, the same as sshd's LoginGraceTime.

- TS_SSH_IDLE_TIMEOUT bounds connections with no SSH traffic. It is 0
  by default, and zero applies no bound, so quiet but live sessions
  are not closed by default. Both timeout knobs accept a Go duration
  such as "90s" or a bare number of seconds.

- Accepted sessions wait for Accept in a bounded queue. When the queue
  is full, further sessions are refused, so an application that stops
  calling Accept parks only a bounded number of sessions.

Updates tailscale/corp#50380

Signed-off-by: James Tucker <james@tailscale.com>
2026-10-25 11:31:39 -07:00

225 lines
6.1 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
package tailssh
import (
"context"
"errors"
"io"
"maps"
"net"
"sync"
"time"
gliderssh "github.com/tailscale/gliderssh"
"tailscale.com/tailcfg"
)
var errNoDeadline = errors.New("tailssh.Session: deadlines not supported")
// Signal represents an SSH signal (e.g. "INT", "TERM").
type Signal = gliderssh.Signal
// Pty represents a PTY request and configuration.
type Pty struct {
// Term is the TERM environment variable value.
Term string
// Window is the initial window size.
Window Window
// Modes are the RFC 4254 terminal modes as opcode/value pairs.
Modes map[uint8]uint32
}
// Window represents the size of a PTY window.
type Window struct {
Width int // columns
Height int // rows
WidthPixels int // width in pixels
HeightPixels int // height in pixels
}
// peerIdentity contains the Tailscale identity of the connecting SSH peer.
type peerIdentity struct {
Node tailcfg.NodeView // node connecting
UserProfile tailcfg.UserProfile
}
// Session wraps a gliderlabs gliderssh.Session with Tailscale peer identity
// information. It implements net.Conn so callers that only need Read/Write/Close
// can use it directly. Callers that need SSH-specific functionality can
// type-assert from the net.Conn returned by the listener's Accept.
type Session struct {
// sess is the underlying gliderlabs SSH session.
sess gliderssh.Session
// peer is the Tailscale identity of the remote peer.
peer peerIdentity
// pty and winCh are set by newSession when the client requested
// a PTY. Pty returns them.
pty *Pty
winCh chan Window
// done is closed when the session handler should return,
// unblocking the gliderlabs handler goroutine.
doneOnce sync.Once // guards close(done)
done chan struct{}
}
// newSession creates a new Session wrapping the given gliderlabs session and
// peer identity. The done channel is closed by the session consumer to signal
// that the handler goroutine may return.
func newSession(sess gliderssh.Session, peer peerIdentity, done chan struct{}) *Session {
s := &Session{
sess: sess,
peer: peer,
done: done,
}
// Start draining window changes now, even when the application
// never calls Pty. gliderssh writes window changes from the session
// request loop, so an undrained channel blocks the session's
// requests. Pty returns winCh. Window changes are dropped when the
// application is not reading it.
if gPty, gWinCh, ok := sess.Pty(); ok {
s.pty = &Pty{
Term: gPty.Term,
Window: Window{
Width: gPty.Window.Width,
Height: gPty.Window.Height,
WidthPixels: gPty.Window.WidthPixels,
HeightPixels: gPty.Window.HeightPixels,
},
}
if gPty.Modes != nil {
s.pty.Modes = make(map[uint8]uint32, len(gPty.Modes))
maps.Copy(s.pty.Modes, gPty.Modes)
}
s.winCh = make(chan Window, 1)
go func() {
defer close(s.winCh)
for gw := range gWinCh {
w := Window{
Width: gw.Width,
Height: gw.Height,
WidthPixels: gw.WidthPixels,
HeightPixels: gw.HeightPixels,
}
select {
case s.winCh <- w:
default:
}
}
}()
}
return s
}
// Read reads from the SSH channel (stdin from the client).
func (s *Session) Read(p []byte) (int, error) {
return s.sess.Read(p)
}
// Write writes to the SSH channel (stdout to the client).
func (s *Session) Write(p []byte) (int, error) {
return s.sess.Write(p)
}
// Close signals the session handler to return and closes the underlying channel.
func (s *Session) Close() error {
s.doneOnce.Do(func() { close(s.done) })
return nil
}
// RemoteAddr returns the net.Addr of the client side of the connection.
func (s *Session) RemoteAddr() net.Addr {
return s.sess.RemoteAddr()
}
// LocalAddr returns the net.Addr of the server side of the connection.
func (s *Session) LocalAddr() net.Addr {
return s.sess.LocalAddr()
}
// SetDeadline is not supported and returns an error.
func (s *Session) SetDeadline(t time.Time) error {
return errNoDeadline
}
// SetReadDeadline is not supported and returns an error.
func (s *Session) SetReadDeadline(t time.Time) error {
return errNoDeadline
}
// SetWriteDeadline is not supported and returns an error.
func (s *Session) SetWriteDeadline(t time.Time) error {
return errNoDeadline
}
// User returns the SSH username.
func (s *Session) User() string {
return s.sess.User()
}
// Peer returns the Tailscale identity of the remote node.
func (s *Session) Peer() tailcfg.NodeView { return s.peer.Node }
// UserProfile returns the Tailscale user profile of the remote node.
//
// For tagged nodes, this is same sort of UserProfile that is returned by the
// LocalAPI WhoIs API.
func (s *Session) UserProfile() tailcfg.UserProfile { return s.peer.UserProfile }
// Environ returns a copy of the environment variables set by the client.
func (s *Session) Environ() []string {
return s.sess.Environ()
}
// RawCommand returns the exact command string provided by the client.
func (s *Session) RawCommand() string {
return s.sess.RawCommand()
}
// Subsystem returns the subsystem requested by the client.
func (s *Session) Subsystem() string {
return s.sess.Subsystem()
}
// Pty returns PTY information, a channel of window size changes, and
// whether a PTY was requested. Window changes are dropped when the
// returned channel is not being read.
func (s *Session) Pty() (_ Pty, _ <-chan Window, ok bool) {
if s.pty == nil {
return Pty{}, nil, false
}
return *s.pty, s.winCh, true
}
// Signals registers a channel to receive signals from the client.
// Pass nil to unregister.
func (s *Session) Signals(c chan<- Signal) {
s.sess.Signals(c)
}
// Exit sends an exit status to the client and closes the session.
func (s *Session) Exit(code int) error {
err := s.sess.Exit(code)
s.Close()
return err
}
// Stderr returns an io.Writer for the SSH stderr channel.
func (s *Session) Stderr() io.Writer {
return s.sess.Stderr()
}
// Context returns the session's context, which is canceled when the client
// disconnects.
func (s *Session) Context() context.Context {
return s.sess.Context()
}