Help us shape the future of Go! Take the 2026 Go Developer Survey

attach

package
v0.39.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 22, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package attach is the local terminal's side of a session: an SSH client of the session's attach socket. upterm host, upterm attach and the functional tests all use it, so the local terminal is just another client in the code, not a special case beside it.

Index

Constants

This section is empty.

Variables

View Source
var ErrHostKeyMismatch = errors.New("host key is not the daemon's")

ErrHostKeyMismatch is what Run's error wraps when the door presented a key that is not the daemon's. It is exported so a caller can tell this apart from every other reason an attachment did not happen: those are worth retrying, and this one is worth stopping for — whatever answered the socket is not the session, and the terminal must not be handed to it. x/crypto returns the callback's error as it is and wraps it with %w, so errors.Is finds it through the handshake's own message.

Functions

This section is empty.

Types

type Cause added in v0.39.0

type Cause struct {
	Kind CauseKind
	// For CauseSignaled, the daemon's name for the signal: the SSH name
	// without "SIG" ("TERM"), or the number of one with no name ("40").
	Signal string
}

Cause is why the daemon ended the session, when it said. Kind is CauseNone when it did not.

type CauseKind added in v0.39.0

type CauseKind int

CauseKind is why the daemon ended a session, as it said so itself.

const (
	// CauseNone: the daemon said nothing, as one from before the session-end
	// request does. The status is then all there is to go on.
	CauseNone CauseKind = iota
	// CauseExited: the command ended on its own, and Status is its own.
	CauseExited
	// CauseStopped: the session was stopped (upterm session stop).
	CauseStopped
	// CauseSignaled: the daemon, or the command on its own, was killed by a
	// signal; Cause.Signal says which.
	CauseSignaled
	// CauseCanceled: the daemon's run was cancelled.
	CauseCanceled
	// CauseJoinTimeout: no guest joined within the join timeout. The status
	// is 0, which is why a cause is needed to tell this from an ordinary exit.
	CauseJoinTimeout
	// CauseUnknown: the daemon said why, but not in words this client knows:
	// a cause from a later daemon, or a payload this client can't decode. No
	// reason is guessed, and the status isn't taken for the command's either,
	// since the cause may be one that says it isn't.
	CauseUnknown
)

type Client

type Client struct {
	Socket  string
	Stdin   io.Reader
	Stdout  io.Writer
	Pty     *Pty
	Resizes <-chan termsize.Size
	Escape  byte
	Logger  *slog.Logger

	// HostKeys is the daemon's public keys, from the session record: a record
	// from a current daemon carries exactly one entry, the session host key
	// it generated for that run; a record written by an older daemon may
	// carry several, which is why this is a slice. Run refuses to attach
	// without at least one: a client that accepted any key would hand its
	// terminal to whatever bound the socket.
	HostKeys []ssh.PublicKey

	// Suspend is the Terminal's: see Terminal.Suspend.
	Suspend func() (size termsize.Size, owned bool)

	// Status is the Terminal's: see Terminal.Status.
	Status func(ctx context.Context) []string
	// contains filtered or unexported fields
}

Client attaches a pair of streams to a running session.

func (*Client) Run

func (c *Client) Run(ctx context.Context) (Result, error)

Run attaches and returns when the attachment ends. An error means the client never attached; once attached, every ending is a Result.

type EscapeAction

type EscapeAction int

EscapeAction is what an escape sequence asked for.

const (
	// EscapeNone: nothing completed in this read.
	EscapeNone EscapeAction = iota
	// EscapeDetach: the caller should leave. Feed returns at once, so bytes
	// after the sequence are dropped — they were typed at a terminal that is
	// no longer attached to anything.
	EscapeDetach
	// EscapeSuspend: the caller should stop this process and resume the
	// attachment afterwards. Feed keeps going, so the rest of the read is
	// still the session's.
	EscapeSuspend
	// EscapeStatus: the caller should show the session's status and keys in
	// this terminal, and send the session nothing for it. Feed keeps going,
	// as for a suspend.
	EscapeStatus
)

type EscapeFilter

type EscapeFilter struct {
	// contains filtered or unexported fields
}

EscapeFilter recognises ssh's detach, suspend and help conventions in a stream of keystrokes: an escape byte at the start of a line followed by '.' detaches; followed by '^Z' (0x1a) suspends, when suspend is enabled; followed by '?' asks for the session's status, when WithStatus offers it; followed by itself sends one literal escape byte; followed by anything else sends both. "Start of a line" is the beginning of input or the byte after a CR or LF — CR because a raw-mode terminal sends Enter as CR.

Bytes after a detach are dropped: they were typed at a terminal that is no longer attached to anything. Bytes after a suspend or a status request are not: they are still the session's, queued for delivery as usual.

func NewEscapeFilter

func NewEscapeFilter(esc byte, suspend bool, opts ...EscapeOption) *EscapeFilter

NewEscapeFilter returns a filter for esc. Zero disables it: Feed passes everything through, ~? included. suspend says whether esc followed by ^Z is offered; when it is not, both bytes reach the session like any other unrecognised pair.

func (*EscapeFilter) Feed

func (f *EscapeFilter) Feed(p []byte) (out []byte, action EscapeAction)

Feed filters p, returning what should reach the session and what escape sequence, if any, completed. out may alias an internal buffer; copy it before the next call if it must be kept.

One action per read: a detach returns at once, and a suspend outranks a status request in the same read, since the suspend is the one that changes anything. Two escape sequences in one read do not come from a keyboard.

func (*EscapeFilter) Flush

func (f *EscapeFilter) Flush() []byte

Flush returns a held escape byte, for the end of input.

type EscapeOption added in v0.39.0

type EscapeOption func(*EscapeFilter)

EscapeOption is a sequence an EscapeFilter offers beyond detach and suspend.

func WithStatus added in v0.39.0

func WithStatus() EscapeOption

WithStatus offers esc followed by '?' as EscapeStatus. Without it the two bytes reach the session like any other unrecognised pair, which is what a client with no status of its own to show wants: join's filter is built without it.

type Pty

type Pty struct {
	Term string
	Size termsize.Size
}

Pty is a pty request: the terminal type and geometry the client's own terminal has. A client that requests one is eligible to be the session's primary client; one that does not is a viewer.

type Reason

type Reason int

Reason says how an attachment ended.

const (
	// Detached: the client ended it — the escape key, EOF on Stdin, or ctx.
	Detached Reason = iota
	// Exited: the session sent the command's exit status and closed.
	Exited
	// Disconnected: the session closed the connection without an exit status.
	// The daemon's log says why: a stalled or overflowed client, or a daemon
	// that went away.
	Disconnected
)

func (Reason) String

func (r Reason) String() string

type Result

type Result struct {
	Reason Reason
	Status int
	// Cause is why the daemon ended the session, ahead of the status: the
	// status alone cannot say, and for 0 it says nothing at all.
	Cause Cause

	// Released closes once no write from this session to Stdout is in
	// flight: the output copy has returned, and so has every mode write and
	// every status write ~? made. It is already closed when Terminal.Run
	// returns, except after a drain or a mode write that timed out, or with
	// a status write still parked, whose write is still in flight.
	Released <-chan struct{}
	// Unrestored is the mode restore left unwritten because the output was
	// abandoned with the terminal in the session's modes: the drain timed
	// out, a suspend could not take the output from a write parked on the
	// terminal, the write that put the modes back after a suspend did not
	// finish, or the restore could not take the output from a status write
	// parked on the terminal. Empty otherwise: the restore was written or
	// attempted, the process came back without the terminal, or there is no
	// pty and so no modes.
	Unrestored []byte
}

Result is how an attachment ended. Status and Cause are meaningful only for Exited. Released and Unrestored are Terminal.Run's; Client.Run leaves them zero. Cause is Client.Run's, which reads it from the connection's global requests; Terminal.Run leaves it zero.

type Terminal added in v0.37.0

type Terminal struct {
	Stdin   io.Reader
	Stdout  io.Writer
	Pty     *Pty
	Resizes <-chan termsize.Size
	Escape  byte

	// Suspend stops the process this client runs in and returns once it is
	// running again, with the terminal's size and whether the terminal is
	// this client's again — for a local terminal: restore the terminal,
	// SIGTSTP, re-enter raw mode if still the foreground, measure. owned is
	// false after bg: the terminal is the foreground job's, and the
	// attachment ends there, writing nothing more to it. Nil disables the ~^Z
	// sequence, and the bytes reach the session instead.
	//
	// It is the embedder's because what "suspend" means is the terminal's
	// business, not the protocol's: this package never touches termios and
	// never signals its own process.
	Suspend func() (size termsize.Size, owned bool)

	// DeclareInteractive sends upterm.AttachInteractiveEnvVar, which says
	// this client displays on a terminal and forwards its keystrokes, so it
	// can answer the terminal queries a primary is sent. Declared, because
	// the daemon cannot tell a terminal from a pipe on the far side of an SSH
	// channel — and a pty viewer that does not read its terminal would leave
	// the terminal's replies in the foreground shell as junk. Only the attach
	// socket reads it.
	DeclareInteractive bool

	// OnReady runs once, after the shell request is confirmed and before the
	// first byte of output is written to Stdout. Nil does nothing.
	OnReady func()

	// Status returns the lines ~? prints into this terminal, never into the
	// session: the session's status and the keys, each without its line
	// ending. It runs on a goroutine of its own, so the input is never held
	// up by it, and is given a context that ends with the attachment; it
	// should be bounded all the same, since lines that come late are lines
	// nobody is waiting for. nil leaves ~? as ordinary input, which is what
	// join wants. It is offered only when the terminal's modes are tracked
	// (Pty set): without them there is no telling whether text written now
	// would land inside the session's own escape sequence.
	Status func(ctx context.Context) []string

	Logger *slog.Logger
	// contains filtered or unexported fields
}

Terminal runs a terminal session on an established connection: the pty request, the shell, the I/O between the session and a pair of streams, the escapes, a suspend, and on the way out the drain and the mode restore. Client is a Terminal on the session's attach socket.

func (*Terminal) Run added in v0.37.0

func (t *Terminal) Run(ctx context.Context, client *ssh.Client, setupBy time.Time) (_ Result, err error)

Run opens a session on client and runs it until it ends. setupBy bounds every request before the shell runs: a peer that answered the handshake and then stopped answering would otherwise hold NewSession, RequestPty or Shell forever, so past it the connection is closed. A zero setupBy has long passed, so the connection is closed at once. An error means shell startup was not confirmed, and Run has closed client before returning it, whatever the cause; once startup was confirmed, every ending is a Result, and Run closes the connection as the session ends.

An error comes with the zero Result, whose Released is nil: there is no write to wait for, and a receive from it would never return.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL