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

internal

package
v0.38.0 Latest Latest
Warning

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

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

Documentation

Index

Constants

View Source
const DefaultInitialClientTimeout = 10 * time.Second

DefaultInitialClientTimeout bounds how long a server told to await its initial client waits before giving up on the session.

View Source
const DefaultStopGrace = 5 * time.Second

DefaultStopGrace is how long each step of the teardown waits before the next, save the first: SIGHUP (bounded by hangupGrace instead), then close the pty master, then SIGTERM, then SIGKILL. Five seconds is long enough for a shell to hang up its jobs and a program to flush, and short enough that `session stop` against something that ignores everything is a worst case of about sixteen seconds -- hangupGrace plus three of these -- not a minute.

Variables

View Source
var ErrJoinTimeout = errors.New("no guest joined within the join timeout")

ErrJoinTimeout marks the winning first-guest deadline. Host passes it as the server context cancellation cause so attached clients also exit zero.

View Source
var ErrNoInitialClient = errors.New("no client attached before the command could start")

ErrNoInitialClient is returned by ServeWithContext when AwaitInitialClient was set and nobody attached within InitialClientTimeout. The command never started; the caller records startup_abandoned.

View Source
var ErrRelayUnsupported = errors.New("the relay answered with a session ID not derived from this host's key")

ErrRelayUnsupported is a redial answered as a relay without proofs answers: with a random ID, under which no guest could find the session.

Functions

This section is empty.

Types

type AdminServer

type AdminServer struct {
	Session    *api.GetSessionResponse
	ClientRepo *ClientRepo
	// Forwards, when set, is where GetSession finds each jump's
	// destinations; the session's Server records them there.
	Forwards *Forwards

	// Route, when set, is where GetSession says guests reach the session,
	// in place of Session's NodeAddr and SshUser: a reconnect can register
	// the session on another node. Session's SessionId is reported either
	// way, since it never changes.
	Route *SessionRoute

	// LaunchID is the launch this server speaks for, from the record the
	// session claimed. StopSession refuses a request that names any other
	// launch, so that a client whose session ended between reading the
	// record and dialling the socket does not stop the session that took
	// the name over. Empty means no name was claimed -- a caller that
	// supplied its own admin socket -- and then no stop can be bound to a
	// launch, so none is accepted.
	LaunchID string

	// OnListening, if set, is called once the socket is bound. It is the other
	// half of readiness: a caller told "ready" must be able to connect, which
	// registering the actor does not establish and binding does.
	OnListening func()

	// OnStop is called when a client asks the session to end. The daemon
	// wires it to an explicit stop cause; the RPC returns at
	// once and the teardown follows.
	OnStop func()

	// OnSetJoinTimeout sets the session's join timeout and reports what it
	// found. Nil answers Unimplemented, as a daemon without the call would.
	OnSetJoinTimeout func(time.Duration) *api.SetJoinTimeoutResponse

	// JoinState reports the join timeout as the daemon holds it, for
	// GetSession. Nil reports none.
	JoinState func() *api.JoinState

	sync.Mutex
	// contains filtered or unexported fields
}

func (*AdminServer) Listen added in v0.30.0

func (s *AdminServer) Listen(sock string) error

Listen binds the admin socket. It is separate from Serve so the caller can bind synchronously, before any concurrent actor exists.

Binding inside the serving goroutine made a bind failure race the command's start: whichever lost the race decided how the run was classified, so the same broken socket path was reported as startup_failed on one run and signaled on the next. Bound up front, the failure is the caller's to return and there is nothing to race.

func (*AdminServer) Serve

func (s *AdminServer) Serve(ctx context.Context) error

Serve serves the listener Listen bound. Listen must have succeeded first: this deliberately refuses to bind on the caller's behalf, because doing so is exactly the race Listen exists to remove.

func (*AdminServer) Shutdown

func (s *AdminServer) Shutdown(ctx context.Context) error

Shutdown drains RPCs until ctx expires, then initiates forced transport closure. It cannot terminate a callback that ignores cancellation: gRPC Serve and its shutdown goroutines may still wait for that callback to return.

type ClientRepo

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

func NewClientRepo

func NewClientRepo() *ClientRepo

func (*ClientRepo) Add

func (c *ClientRepo) Add(client *api.Client) error

func (*ClientRepo) Clients

func (c *ClientRepo) Clients() []*api.Client

func (*ClientRepo) Delete

func (c *ClientRepo) Delete(clientId string)

func (*ClientRepo) Get

func (c *ClientRepo) Get(clientId string) *api.Client

type CommandResult added in v0.30.0

type CommandResult struct {
	// Exited is true when the command terminated under its own control.
	Exited bool
	// Code is its status; meaningful only when Exited.
	Code int
	// Signal names the signal that killed it, when it was killed.
	Signal string
	// SignalNumber is the originating Unix terminating signal, when available.
	SignalNumber *int
}

CommandResult is a command's own outcome, as distinct from the session's shutdown cause.

type CreateSessionRefusedError added in v0.35.0

type CreateSessionRefusedError struct{ Body string }

CreateSessionRefusedError is the relay refusing the registration, with the reason it gave: a refused proof, a newer registration, a store that failed.

func (*CreateSessionRefusedError) Error added in v0.35.0

func (e *CreateSessionRefusedError) Error() string

type ExitError added in v0.25.0

type ExitError struct {
	// Code is the status GetExitCodeProcess reported, kept unsigned because
	// that is what Windows produces and what SSH puts on the wire. On a
	// 32-bit build, narrowing it to int would make every status with the high
	// bit set negative -- an unhandled exception such as 0xC0000005, or a
	// deliberate "exit -1" -- and exitCode would then read it as a process
	// that never exited under its own control.
	Code uint32
}

ExitError reports that a PTY's process exited with a non-zero status.

Unix gets this for free from exec.Cmd.Wait, which returns *exec.ExitError. Windows waits on a process handle rather than an exec.Cmd, so it has no such error to return and needs this to carry the code instead of burying it in a message string.

func (*ExitError) Error added in v0.25.0

func (e *ExitError) Error() string

type ForwardRefusedError added in v0.35.0

type ForwardRefusedError struct{ Err error }

ForwardRefusedError is the relay registering the session but refusing the listener its guests would be forwarded to.

func (*ForwardRefusedError) Error added in v0.35.0

func (e *ForwardRefusedError) Error() string

func (*ForwardRefusedError) Unwrap added in v0.35.0

func (e *ForwardRefusedError) Unwrap() error

type Forwards added in v0.37.0

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

Forwards is where a session's jumps go: each forwarding client's destinations, by client ID, for as long as its connection lasts. The forwarding handler writes it as forwards are accepted, and GetSession reads it, rather than either going through the client events: those are delivered on separate channels, so a later destination could be applied after its client's departure and bring it back. The zero value is ready to use.

type PTY added in v0.18.0

type PTY interface {
	io.ReadWriteCloser

	// Setsize changes the terminal dimensions.
	// On Unix, this sends a SIGWINCH to the slave process.
	// On Windows, this resizes the ConPTY buffer.
	Setsize(h, w int) error

	// Redraw asks the process on the pty to repaint without changing its
	// geometry: a replay repaints what the ring holds, and a full-screen
	// program redraws the rest on SIGWINCH. On Windows there is no SIGWINCH,
	// so the nudge is a resize, and unavailable under a pinned size.
	Redraw() error

	// Wait waits for the process associated with this PTY to exit.
	// On Unix, this delegates to exec.Cmd.Wait().
	// On Windows, this waits on the process handle.
	Wait() error

	// Kill terminates the process associated with this PTY.
	// On Unix, this delegates to exec.Cmd.Process.Kill().
	// On Windows, this calls TerminateProcess on the handle.
	Kill() error

	// Signal sends sig to the command's process group, so that a shell's
	// hangup reaches its jobs. Unsupported on Windows, which returns
	// errors.ErrUnsupported and lets the caller go straight to Kill.
	Signal(sig syscall.Signal) error
}

PTY represents a pseudo-terminal abstraction that works across platforms. On Unix, it wraps a traditional PTY created via creack/pty. On Windows, it wraps a ConPTY (Console Pseudo Terminal).

The interface provides a common abstraction for:

  • Reading/writing terminal I/O (via io.ReadWriteCloser)
  • Resizing the terminal window
  • Managing process lifecycle (Wait/Kill)

Platform-specific implementations:

  • Unix: see pty_unix.go
  • Windows: see pty_windows.go

type PermissionDeniedError

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

PermissionDeniedError is a relay that would not authenticate this host.

It says which of the two ways that happened, because they are different problems: a host that offered nothing has no identity for the relay to have rejected, while a host whose keys were all refused has keys this relay does not accept. x/crypto's own error stays reachable through Unwrap for anything that wants the handshake's words instead.

func (*PermissionDeniedError) Error

func (e *PermissionDeniedError) Error() string

func (*PermissionDeniedError) Unwrap

func (e *PermissionDeniedError) Unwrap() error

type RelayAuthority added in v0.33.0

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

RelayAuthority is the relay key a host verified, and the only key whose signature makes a guest certificate's AuthRequest believable.

The relay mints every guest's credential, naming in it the key the guest authenticated with, and the host's sshd authorizes that name. For that name to mean anything the host has to know which key may sign it. That key is not a new thing to configure: it is the relay host key this host already decided to trust, in its own host key callback. Wrap records it there.

That bounds the damage to the relay the host chose. A host that skipped verification (--skip-host-key-check on a first connection) can still be lied to by whoever answered, which is the separate problem of publishing and pinning the public relay's key.

The zero value trusts nothing, which is what a host whose handshake has not happened yet should do.

func (*RelayAuthority) CheckRedial added in v0.35.0

func (r *RelayAuthority) CheckRedial(hostname string) error

CheckRedial reports whether the key the first connection was shown would pass Pinned for hostname, given as host:port as the callback is: nil if a redial to the same relay would be accepted, and otherwise the *RelayKeyChangedError Pinned would refuse it with. A caller can ask as soon as the first connection is up, rather than learn it from a failed redial. It records nothing, and a connection that recorded nothing fails it.

func (*RelayAuthority) IsUserAuthority added in v0.33.0

func (r *RelayAuthority) IsUserAuthority(key ssh.PublicKey) bool

IsUserAuthority reports whether key is the relay key this host verified. It is the predicate server.UserCertChecker asks, and it is false until a handshake has recorded one.

The comparison is exact, on the marshalled key, and deliberately not utils.KeysEqual: that unwraps a certificate to the key it certifies, so a certificate that merely certified the relay's key would be accepted as the authority itself. An authority is one specific key. Production only ever asks about a cert.SignatureKey, which is always a plain key, so the strictness costs nothing.

Locked because Wrap runs on the goroutine dialing the relay while this runs on the embedded sshd's handler goroutines. They are ordered today -- the server is built after the tunnel is established -- but a redial would not be.

func (*RelayAuthority) Pinned added in v0.35.0

func (r *RelayAuthority) Pinned() ssh.HostKeyCallback

Pinned returns the host key callback for a redial: it accepts the relay authority the first connection recorded, and nothing else. It never prompts and never reads known_hosts, so a redial cannot be talked into trusting anything the first connection did not.

It accepts exactly two things: the recorded key itself, and a host certificate signed by it that verifies, is within its validity and names the dialled hostname (its port dropped) or names none. Every refusal is a *RelayKeyChangedError wrapping the checker's reason, including a certificate that merely certifies the recorded key: the comparison is IsUserAuthority's, exact on the marshalled key, because utils.KeysEqual would unwrap it.

What is recorded is read when the callback runs, not when Pinned is called, and is never changed by it.

A first connection under --skip-host-key-check accepts a certificate without checking its principals (host.KnownHosts.Record). If that certificate does not name the hostname dialled, as when the public relay is dialled by IP, every redial is refused here. That is the verdict known_hosts gives the same host on the next run, so it is kept; CheckRedial reports it as soon as the first connection is up, for a caller that wants to say so before any redial.

func (*RelayAuthority) Wrap added in v0.33.0

Wrap returns cb with the accepted key recorded. Only a key cb accepted is recorded: the verdict belongs to the host's trust policy, and this only remembers what it decided.

A relay presents a host certificate when the client offers certificate algorithms, which ReverseTunnel does, so the key recorded is the certificate's SignatureKey -- the same key the relay signs guest certificates with, and the same key the prompt and known_hosts already reduce a host certificate to. The key as presented is kept too, for CheckRedial.

type RelayKeyChangedError added in v0.35.0

type RelayKeyChangedError struct {
	Hostname string
	Key      ssh.PublicKey
	Err      error
	// contains filtered or unexported fields
}

RelayKeyChangedError is a redial shown a relay key other than the one this session started with. Key is what the relay showed, and nil when nothing was.

One refusal is not a different key: a certificate that names the authority this session started with as its signer, refused anyway (another hostname, expired, not a host certificate). That is how a first connection that auto-accepted a certificate for another hostname shows up (see Pinned), and it reads as what happened, with Err as the reason, instead of calling the recorded authority's own key a changed one. It says the certificate names the authority, not that the authority signed it: the checker verifies the signature last, so a refusal for the principals or the validity says nothing of it.

func (*RelayKeyChangedError) Error added in v0.35.0

func (e *RelayKeyChangedError) Error() string

func (*RelayKeyChangedError) Unwrap added in v0.35.0

func (e *RelayKeyChangedError) Unwrap() error

type ReverseTunnel

type ReverseTunnel struct {
	*ssh.Client

	Host    *url.URL
	Signers []ssh.Signer
	// HostKey is the key the embedded sshd presents. Its public half is what
	// the relay is told to expect on every guest's upstream hop; Signers
	// authenticate this tunnel and are used for nothing else.
	HostKey ssh.Signer
	// SessionSecret, when set, makes Establish prove to the relay that this
	// host holds HostKey, so the session is registered under an ID derived
	// from the key and this secret rather than a random one. Generation orders
	// this registration against earlier ones under the same ID, and must be at
	// least 1 alongside a secret.
	//
	// The relay treats a proven registration as one whose host can reconnect,
	// and may close its tunnel on that basis: set it only for a host that does.
	SessionSecret  []byte
	Generation     uint64
	AuthorizedKeys []ssh.PublicKey
	// KeepAlive is how long the relay may be silent: probed after Interval,
	// given up on after Interval+Bound. A field left zero takes
	// liveness.DefaultTiming's.
	KeepAlive liveness.Timing
	// RequireDerivedID makes Establish a redial's: a relay that answers a
	// proven registration with an ID not derived from HostKey fails it with
	// ErrRelayUnsupported, before a listener is requested for an ID no guest
	// knows. Without it, that answer is a first connection's, which goes on
	// with ReconnectSupported false. It needs a SessionSecret: without one,
	// Establish fails at once.
	RequireDerivedID bool
	// ProxyURL, when non-nil, is the HTTP proxy to connect to Host through.
	ProxyURL        *url.URL
	HostKeyCallback ssh.HostKeyCallback
	Logger          *slog.Logger
	// contains filtered or unexported fields
}

func (*ReverseTunnel) Close

func (c *ReverseTunnel) Close()

Close releases whatever Establish took, and works on a tunnel that never established anything. Establish gives up at three different points — the dial, the session request, the listen — and each leaves a different subset of these set; a Close that assumed the successful one would turn a failed dial into a nil dereference in the caller's teardown.

It is bounded: closing ln goes through the wrapper, which gives the relay listenerCloseGrace and then closes the client itself.

func (*ReverseTunnel) Establish

Establish is one bounded attempt: ctx bounds the dial, the handshake, the session request and the listen, and nothing after. The tunnel it returns outlives ctx, and on any failure it leaves no connection open.

A signature is bounded only as far as ctx releases the signer. Closing the connection fails the handshake's own reads and writes, not a signer waiting on something else: an agent connection the caller closes when ctx ends is released with it, while an agent waiting on an approval prompt, over a connection nothing closes, holds Establish past a cancel.

func (*ReverseTunnel) Listener

func (c *ReverseTunnel) Listener() net.Listener

Listener returns the forwarded listener the guest server serves on, wrapped so that whoever closes it — Shutdown, from an interrupt — cannot be left waiting on a relay that has stopped replying.

func (*ReverseTunnel) ReconnectSupported added in v0.34.0

func (c *ReverseTunnel) ReconnectSupported() bool

ReconnectSupported reports whether the relay honoured the proof, by registering the session under the ID derived from HostKey and SessionSecret. A relay that predates proofs ignores one and issues a random ID, and a tunnel that sent none never asked.

func (*ReverseTunnel) SessionKeyRedial added in v0.34.0

func (c *ReverseTunnel) SessionKeyRedial() bool

SessionKeyRedial reports whether the relay also lets the session key alone authenticate a redial. It is only ever true where ReconnectSupported is.

func (*ReverseTunnel) Wait added in v0.35.0

func (c *ReverseTunnel) Wait() error

Wait blocks until the tunnel's connection has ended, and says why: the keepalive's error, wrapping liveness.ErrSilent, if the relay went silent and the keepalive closed it; otherwise what ended the SSH connection. Every call gives the same answer, and a tunnel that never connected says so at once. It is safe from several goroutines, and after Close.

type SFTPSession added in v0.22.0

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

SFTPSession tracks permission state for a single SFTP session

type Server

type Server struct {
	Command      []string
	CommandEnv   []string
	ForceCommand []string
	// HideClientIP keeps a guest's address out of its forced command's
	// SSH_CONNECTION and SSH_CLIENT, as --hide-client-ip keeps it out of
	// what upterm prints.
	HideClientIP bool
	// HostKey is the key both doors present. A session's key, not the
	// operator's identity: a key exchange signs with whatever is here, on
	// every join, attach and rekey, and an identity held by a confirming
	// agent would be asked each time.
	HostKey        ssh.Signer
	AuthorizedKeys []ssh.PublicKey
	// GuestCertAuthority reports whether a key may vouch for a guest
	// certificate's AuthRequest -- in production, the relay key the host key
	// callback accepted, via RelayAuthority. Nil admits no guest: the only
	// legitimate guest credential is one the relay minted, and without knowing
	// which relay, no claim in one can be believed.
	GuestCertAuthority      func(ssh.PublicKey) bool
	EventEmitter            *emitter.Emitter
	KeepAliveDuration       time.Duration
	Logger                  *slog.Logger
	ReadOnly                bool
	AllowLocalTCPForwarding bool
	Forwards                *Forwards // where each jump's destinations are recorded, for GetSession; optional
	PtySize                 termsize.Size
	PinPtySize              bool
	Term                    string

	// AwaitInitialClient defers starting the command until the first host
	// client's output subscription is installed, and defers serving guests
	// until the command has started. Foreground use sets it: a command that
	// exits at once — `upterm host -- false` — would otherwise race the local
	// terminal's attach, and lose. Headless use leaves it off.
	AwaitInitialClient bool
	// InitialClientTimeout bounds that wait; zero means
	// DefaultInitialClientTimeout.
	InitialClientTimeout time.Duration

	// StopGrace bounds each step of the command's teardown; zero means
	// DefaultStopGrace.
	StopGrace time.Duration

	// OnCommandStarted, if set, is called once the hosted command is running.
	// Readiness is a claim about facts, and this is one of the two facts it
	// rests on: until this fires, "ready" would mean a command that may still
	// fail to start.
	OnCommandStarted func()

	// OnGuestServerStopped is called when the guest listener stops serving,
	// which behind a TunnelListener means the tunnel is lost for good. The
	// session does not end: the command keeps running and keeps its pty.
	// Reporting it is the caller's job, because internal must not know about
	// on-disk state.
	OnGuestServerStopped func(error)

	// SFTP configuration
	SFTPDisabled          bool                   // Disable SFTP subsystem entirely
	SFTPPermissionChecker sftp.PermissionChecker // Optional: prompts user for SFTP permissions (nil = auto-allow)
	// contains filtered or unexported fields
}

func (*Server) CommandResult added in v0.30.0

func (s *Server) CommandResult() CommandResult

CommandResult returns the hosted command's outcome. Valid after ServeWithContext returns.

func (*Server) ServeWithContext

func (s *Server) ServeWithContext(ctx context.Context, guest, host net.Listener) error

ServeWithContext runs the session: the hosted command, the guest door on guest, and — when host is not nil — the host door on host, for clients that are already on this machine.

type SessionRoute added in v0.35.0

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

SessionRoute is where guests reach the session now: the latest registration's node and SSH user. The session ID never changes, so it isn't here. It is safe for concurrent use; the zero value is an empty route.

func (*SessionRoute) Get added in v0.35.0

func (r *SessionRoute) Get() (nodeAddr, sshUser string)

Get returns the node and SSH user Set last recorded.

func (*SessionRoute) Set added in v0.35.0

func (r *SessionRoute) Set(nodeAddr, sshUser string)

Set records a registration's node and SSH user.

type TunnelListener added in v0.35.0

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

TunnelListener is the guest door's listener: one net.Listener that keeps serving while the tunnel behind it is replaced.

The guest door is a single ssh.Server.Serve call for the whole session, and Serve returns on any Accept error that is not temporary, so an error from a tunnel that went away would end the door for good. TunnelListener therefore never returns one: when the listener it is serving from fails, Accept waits for Swap to install the next tunnel's listener, and only Fail and Close end it.

Every inner listener is accepted from on a goroutine of its own, so Accept never waits on an inner listener, and no lock is held across an inner Close. A forwarded listener's Close is a request to the relay, and an unresponsive relay holds it for seconds: Swap and Close, which make that call, wait for it, and Accept and Fail never do.

func NewTunnelListener added in v0.35.0

func NewTunnelListener(first net.Listener) *TunnelListener

NewTunnelListener serves from first until Swap replaces it. From here on first is the TunnelListener's to close, in Swap or in Close.

func (*TunnelListener) Accept added in v0.35.0

func (l *TunnelListener) Accept() (net.Conn, error)

Accept returns a connection from the current tunnel: never one from a tunnel that Swap had already replaced when Accept received it, which is closed instead. It blocks across a Swap, and fails only once Fail or Close has been called: with an error wrapping Fail's, or with net.ErrClosed.

func (*TunnelListener) Addr added in v0.35.0

func (l *TunnelListener) Addr() net.Addr

Addr is the first listener's address, whichever tunnel is current.

func (*TunnelListener) Close added in v0.35.0

func (l *TunnelListener) Close() error

Close ends the listener — every current and later Accept returns net.ErrClosed, unless Fail got there first — and closes the current inner listener, which can take as long as that listener's own Close does. A second Close waits for the first to finish and returns what it returned.

func (*TunnelListener) Fail added in v0.35.0

func (l *TunnelListener) Fail(err error)

Fail ends the listener because the tunnel is lost for good: every current and later Accept returns an error wrapping err. It closes nothing; Close still releases the current listener, and has to be called.

Fail has no effect once Fail or Close has already ended the listener: the first of the two sets what Accept returns.

func (*TunnelListener) Swap added in v0.35.0

func (l *TunnelListener) Swap(next net.Listener) bool

Swap makes next the listener Accept serves from, and closes the one it replaces. Accept is already serving from next by the time that Close starts, so a slow Close delays only Swap's return.

Swap returns false, and installs nothing, once Fail or Close has ended the listener: nothing would ever accept from next, so closing it, and whatever tunnel it belongs to, is the caller's. The listener Swap does install is the TunnelListener's to close from then on.

Jump to

Keyboard shortcuts

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