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

join

package
v0.40.0 Latest Latest
Warning

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

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

Documentation

Overview

Package join is the core of a guest joining an upterm session: it parses the target a guest names, joins the session over the connection to the relay that the embedder's Config.Dial opens, TCP for ssh or a WebSocket for ws and wss, and rejoins the session after the connection drops. It renders no text of its own; it returns typed errors and outcomes, and the command that embeds it decides what to tell the user.

The core has to build for js/wasm as well as for the command line, so its imports are limited to what that target can link. These are the packages of this module it is meant to import, besides the standard library and golang.org/x/crypto/ssh:

  • attach
  • io (the module's own, not the standard library's)
  • internal/termsize
  • internal/liveness
  • internal/logging
  • internal/version
  • upterm

The list is the intended set, which review keeps. The build that "make wasm-check" runs and CI enforces catches only an import that doesn't build for js/wasm, a package that needs cgo or a system call js/wasm lacks, say, and not every import outside the list:

GOOS=js GOARCH=wasm go build ./join ./attach

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrHostKeyUnknown = errors.New("the relay's key is not known")
	ErrHostKeyChanged = errors.New("the relay's key has changed")
)

Errors the seams return.

View Source
var (
	ErrKeysRefused     = errors.New("the session refused your keys")
	ErrIdentity        = errors.New("your SSH identity failed")
	ErrHostKey         = errors.New("the relay's key is not trusted")
	ErrUpstreamHostKey = errors.New("the relay couldn't verify the host")
)

Errors an Outcome wraps.

View Source
var ErrNotATarget = errors.New("not a join target")

ErrNotATarget is what ParseTarget's errors wrap: the text names no session to join.

Functions

This section is empty.

Types

type Config

type Config struct {
	Target Target
	// Dial opens a connection to the relay. It is called once per attempt,
	// with the attempt's ctx, and returns promptly once ctx ends: a stop, and
	// the attempt's deadline, wait for it.
	Dial     func(ctx context.Context) (net.Conn, error)
	Identity Identity
	// CheckHostKey decides on the relay's key without blocking on anyone: nil
	// (trusted), or an error that wraps ErrHostKeyUnknown or
	// ErrHostKeyChanged. It may record a key itself. Its hostname is
	// Target.Host, host:port, and it runs again at every key exchange, not
	// only the first. An unknown key goes to ConfirmHostKey, if the guest may
	// still be asked; any other error, ErrHostKeyChanged or not, stops the
	// join. A refusal at a key exchange during a session stops it too, rather
	// than counting as a drop.
	CheckHostKey ssh.HostKeyCallback
	// ConfirmHostKey asks the guest about an unknown key, with no connection
	// open, and records it if they accept. It returns promptly when ctx ends.
	// It is never called once TakeTerminal has run: from then on nothing
	// prompts. A "no", or an error, ends the join as Failed. Nil means an
	// unknown key is never accepted, and so that nothing can prompt:
	// TakeTerminal runs as Run starts. Setting it holds the guest's keys back
	// until a handshake first succeeds, so that a prompt has stdin to itself,
	// and until then a first join's waits ignore Enter and the escape
	// sequence, and Ctrl-C stops the join only by way of a signal. So a
	// caller passes it only when a prompt is actually possible: when
	// CheckHostKey can find the relay's key unknown and the guest can be
	// asked.
	ConfirmHostKey func(ctx context.Context, hostname string, key ssh.PublicKey) (accepted bool, err error)
	// Terminal runs each session. Run sets Stdin, Pty.Size, Resizes and
	// OnReady on a copy for each session, and wraps its Stdout so that
	// nothing reaches it before the guest has been told the session is ready;
	// this one is left as it is. A value on Terminal.Resizes only says that
	// the terminal's size changed, since one measured before a session's pty
	// request can arrive after it. Run reads Resizes from its start until it
	// returns, and never closes it. A change that comes while a session runs
	// gives the session the size Size measures then, which is never older
	// than the size its pty request carried; one that comes while no session
	// runs is dropped, and the next pty request measures the size anew.
	// Terminal.Escape is the one escape byte, a session's and that of the
	// keys Run reads itself while no session has the keyboard: at the start
	// of a line, followed by '.', it leaves. Zero disables it.
	Terminal *attach.Terminal
	// Size measures the terminal: afresh for each session's pty request, and
	// for each change on Terminal.Resizes while a session runs. Calls are
	// never concurrent, and none is made after Run returns. With Resizes
	// set, they run on a goroutine of Run's own, and may run while a
	// session's Terminal does, its Suspend included, or an Events call. Nil keeps
	// Terminal.Pty's size, and gives a session the value on Resizes as it is.
	Size func() termsize.Size
	// Input is the guest's keys. Run reads it only once TakeTerminal has run:
	// from the start when ConfirmHostKey is nil, and otherwise not until a
	// handshake first succeeds, which is why a caller passes ConfirmHostKey
	// only when a prompt is possible. One goroutine reads it, and gives each
	// key to the session that is ready or else to Run. From then until Run
	// returns, Input is Run's. Keys that arrive while a session's Joined
	// event is being delivered go to Run, not the session, which has the
	// keyboard only once Events has returned. A Read can't be interrupted,
	// so one in progress as Run returns may still take one more chunk, which
	// is dropped, and then nothing reads Input. A caller that needs its input
	// back after Run gives it a reader it can close or interrupt. Nil means
	// no keys.
	Input io.Reader
	// TakeTerminal runs once: as Run starts when ConfirmHostKey is nil, since
	// nothing can prompt, and otherwise when a handshake first succeeds, even
	// if that attempt's session then fails to start. A run whose ctx has
	// already ended never runs it, and is Stopped. It is where the command
	// puts the terminal in raw mode, and from then on the guest's keys are
	// Run's. So a caller passes ConfirmHostKey only when a prompt is actually
	// possible: setting it delays this, and the guest's keys with it, until
	// the first handshake. Nil does nothing. An error ends the join as
	// Failed, with that error.
	TakeTerminal func() error
	// Events tells the guest how the join is going. It returns only once
	// whatever it wrote has been written or has failed: Run starts no attempt
	// and lets no session write while it is in progress. A Waiting call's time
	// comes out of its wait, so the attempt is still due at Next, or follows
	// at once if the call returns later than that. While the run goes
	// on, calls are serial, each starting once the last has returned. Each
	// runs on its own goroutine, so a write stuck on a terminal never keeps a
	// stop from ending the run, nor a first join's deadline, which a Waiting
	// line counts against as the rest of the wait does. Run starts no call
	// once it has stopped, but one that began as the stop landed, or that the
	// stop or the deadline abandoned, may still be writing after Run returns.
	// Nil tells nobody.
	Events func(Event)
	// Wait removes the first join's deadline.
	Wait   bool
	Timing Timing
	// contains filtered or unexported fields
}

Config is a join: the session, and the seams through which Run reaches the world.

type DeadlineError

type DeadlineError struct {
	Why WaitReason
	Err error
}

DeadlineError ends a first join whose Timing.FirstJoin ran out.

func (*DeadlineError) Error

func (e *DeadlineError) Error() string

func (*DeadlineError) Unwrap

func (e *DeadlineError) Unwrap() error

type Event

type Event struct {
	Kind   EventKind
	Rejoin bool          // Joined: not the first
	Down   time.Duration // Joined, on a rejoin: how long the session was out of reach
	Why    WaitReason    // Lost, Waiting
	Err    error         // Lost, Waiting: the error behind Why, if any
	Since  time.Time     // Waiting: when this wait began, on the wall clock
	Next   time.Time     // Waiting: when the next attempt is due, on the wall clock
	Long   bool          // Waiting: this wait has lasted Timing.LongAfter
	First  bool          // Waiting: no session has been ready yet
}

Event is a change the guest is told about.

type EventKind

type EventKind int

EventKind is what an Event reports.

const (
	// Joined: a session is ready. Its output follows.
	Joined EventKind = iota + 1
	// Lost: the connection to a session went away without an exit status.
	Lost
	// Waiting: the next attempt is a wait away.
	Waiting
)

type HostKeyError

type HostKeyError struct {
	Changed  bool // a line names another key
	Declined bool // the guest said no
	Err      error
}

HostKeyError is the guest not trusting the relay's host key.

func (*HostKeyError) Error

func (e *HostKeyError) Error() string

func (*HostKeyError) Is

func (e *HostKeyError) Is(target error) bool

Is reports a match with ErrHostKey.

func (*HostKeyError) Unwrap

func (e *HostKeyError) Unwrap() error

type Identity

type Identity struct {
	// Signers is called once per attempt, with the attempt's ctx, and returns
	// promptly once ctx ends. Every agent operation it or its signers start
	// is bound to ctx, and release, which may be nil, closes the attempt's
	// agent connection. release must be idempotent: Run arms it on ctx and
	// calls it again when the attempt returns, so an agent that stops
	// answering costs no more than the attempt's deadline. The agent is a
	// connection of its own, which closing the relay's would not end.
	//
	// An error from Signers is the guest's own identity failing, as a signer
	// that fails to sign is: a first join stops on it (ErrIdentity), and a
	// rejoin waits it out (IdentityFailure). It should return at least one
	// signer: an empty list leaves no key to offer, which ends the join as
	// refused.
	Signers func(ctx context.Context) (signers []ssh.Signer, release func(), err error)
	Source  KeySource
}

Identity is the guest's keys.

type IdentityError

type IdentityError struct {
	Key  string   // the fingerprint that failed to sign; "" otherwise
	List bool     // the agent couldn't list its keys, and no key file loaded
	Load []string // "file: error" for key files that exist and didn't load, when nothing else did
	Err  error
}

IdentityError is the guest's own SSH identity failing, as opposed to a peer refusing it.

func (*IdentityError) Error

func (e *IdentityError) Error() string

func (*IdentityError) Is

func (e *IdentityError) Is(target error) bool

Is reports a match with ErrIdentity.

func (*IdentityError) Unwrap

func (e *IdentityError) Unwrap() error

type KeySource

type KeySource struct {
	Agent, Files int  // keys from the agent, and from key files
	Generated    bool // no agent keys and no default key files: a key was generated
	Explicit     bool // the keys were named with -i
}

KeySource says where the guest's keys came from, so that the command can explain a refusal in terms the guest recognises. It is known before Run: the identity is prepared, and the agent listed, before the first attempt.

type Outcome

type Outcome struct {
	Kind   OutcomeKind
	Status int // Exited
	// Err is why a run Failed. It is one of:
	//   - a *DeadlineError: a first join's time ran out, waiting on its Why;
	//   - a *RefusedError (ErrKeysRefused);
	//   - an *IdentityError (ErrIdentity), on a first join;
	//   - a *HostKeyError (ErrHostKey): a changed key, an unknown one the
	//     guest declined or couldn't be asked about, CheckHostKey's own
	//     error, or ConfirmHostKey's;
	//   - ErrUpstreamHostKey;
	//   - TakeTerminal's error, as it is;
	//   - an error naming a Config field Run needs and wasn't given.
	Err error
}

Outcome is how a run ended.

func Run

func Run(ctx context.Context, cfg Config) Outcome

Run joins the session cfg names and stays with it until it ends: an exit status, the guest leaving or stopping, or a failure that waiting won't mend. A connection that drops is rejoined, however long that takes; a first join that can't get in gives up after Timing.FirstJoin, unless cfg.Wait.

The end of a run is bounded, and a write to a terminal can't be interrupted: Run doesn't own the descriptor, which is what would have to be closed. So a write Run has given up on may still complete after it returns: a status line that a stop or the first join's deadline abandoned, or the last session's output or mode restore, still parked on Terminal.Stdout when their one-second bound passed. A caller that writes to the same terminal afterwards should allow for that; the command exits.

Example

An embedder that is not the command line: it owns the output, the keyboard and the relay's key. screen and relayKey stand in for what it has.

package main

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"errors"
	"fmt"
	"io"
	"net"
	"os"

	"github.com/owenthereal/upterm/attach"
	"github.com/owenthereal/upterm/internal/termsize"
	"github.com/owenthereal/upterm/join"
	"golang.org/x/crypto/ssh"
)

func main() {
	var screen io.Writer = os.Stdout
	var relayKey ssh.PublicKey // pinned ahead of time
	say := func(format string, a ...any) { _, _ = fmt.Fprintf(screen, format+"\r\n", a...) }

	// A pasted "ssh abc@relay.example.com" fails here instead, with a *TargetError
	// that wraps join.ErrNotATarget and suggests abc@relay.example.com.
	target, err := join.ParseTarget("abc@relay.example.com")
	if err != nil {
		var bad *join.TargetError
		if errors.As(err, &bad) && bad.Suggestion != "" {
			say("did you mean %s?", bad.Suggestion)
		}
		return
	}

	_, key, _ := ed25519.GenerateKey(rand.Reader) // errors elided here and below
	signer, _ := ssh.NewSignerFromKey(key)
	pr, pw := io.Pipe() // the embedder's keyboard writes to pw

	// The embedder's stop ends the run in any phase; Dial and Signers return on it.
	ctx, stop := context.WithCancel(context.Background())
	defer stop() // its close button calls stop too, from any goroutine

	// ConfirmHostKey is nil, so nothing can prompt and the keys are Run's from
	// its start: while no session is ready, Enter cuts a wait short, "~." leaves
	// and Ctrl-C stops the run; once one is, the session gets the keys.
	out := join.Run(ctx, join.Config{
		Target: target,
		Dial: func(ctx context.Context) (net.Conn, error) { // a ws or wss target needs a WebSocket
			var d net.Dialer
			return d.DialContext(ctx, "tcp", target.Host)
		},
		Identity: join.Identity{Signers: func(context.Context) ([]ssh.Signer, func(), error) {
			return []ssh.Signer{signer}, nil, nil
		}},
		// Any key but the pinned one is a changed key, and stops the join.
		CheckHostKey: func(host string, remote net.Addr, k ssh.PublicKey) error {
			if err := ssh.FixedHostKey(relayKey)(host, remote, k); err != nil {
				return fmt.Errorf("%w: %w", join.ErrHostKeyChanged, err)
			}
			return nil
		},
		// A session runs on a copy of this, and writes nothing until it is ready.
		Terminal: &attach.Terminal{Stdout: screen, Pty: &attach.Pty{Term: "xterm-256color"}, Escape: '~'},
		Size:     func() termsize.Size { return termsize.Size{Cols: 120, Rows: 40} },
		Input:    pr,
		// Run starts no attempt and lets no session write while a call runs.
		Events: func(e join.Event) {
			if e.Kind == join.Waiting {
				say("waiting; next attempt at %s", e.Next.Format("15:04:05"))
			}
		},
	})

	// A Read can't be interrupted, so one may still be parked on pr, and would
	// drop the next chunk written. Closing pw ends it; nothing reads pr after.
	_ = pw.Close()

	// A write Run gave up on may still finish after it returns, so screen must
	// tolerate one among its own: a status line, or the last session's output
	// or restore.
	switch out.Kind {
	case join.Exited:
		say("the connection ended with status %d", out.Status)
	case join.Left: // "~." was typed, or pw was closed while Run ran
		say("you left the session")
	case join.Stopped: // stop was called, or Ctrl-C was typed outside a session
		say("stopped")
	case join.Failed:
		// A *join.DeadlineError once a first join's 30 seconds are up, or an error
		// errors.Is matches to a sentinel such as join.ErrKeysRefused.
		say("could not join: %v", out.Err)
	}
}

type OutcomeKind

type OutcomeKind int

OutcomeKind is how a run ended.

const (
	// Exited: an exit status arrived for this connection: the session's
	// command ended, or a forced command's did, which ends only this guest's
	// connection while the shared session may carry on.
	Exited OutcomeKind = iota + 1
	// Left: the guest left, with the escape sequence or by closing the
	// terminal.
	Left
	// Stopped: ctx ended, or the guest pressed Ctrl-C while no session had
	// the keyboard.
	Stopped
	// Failed: the join can't go on; Err says why.
	Failed
)

type RefusedError

type RefusedError struct {
	Ambiguous bool // no positive evidence the key itself was refused: an entry refusal without version evidence, or a round of such refusals that left no key to offer
}

RefusedError is a relay or a host turning the guest's keys away.

func (*RefusedError) Error

func (e *RefusedError) Error() string

func (*RefusedError) Is

func (e *RefusedError) Is(target error) bool

Is reports a match with ErrKeysRefused.

type Target

type Target struct {
	Scheme string // "ssh", "ws" or "wss"
	User   string // the connect string's user, colon form included
	Host   string // host:port, the port defaulted
}

Target is a session to join and the relay to reach it through.

func ParseTarget

func ParseTarget(s string) (Target, error)

ParseTarget reads scheme://USER@HOST[:PORT][/] or the bare USER@HOST[:PORT], which means ssh. USER is the session ID, or SESSION:NODE when the relay is one of several and the connect string names the node that holds the session. An IPv6 literal must be bracketed, as it is in a URL. The error is a *TargetError, which wraps ErrNotATarget.

func (Target) Relay

func (t Target) Relay() string

Relay is Host as a person writes it: with the port dropped when it is the scheme's default, and an IPv6 literal still in its brackets.

func (Target) SessionID

func (t Target) SessionID() string

SessionID is the part of User before its first colon.

func (Target) URL

func (t Target) URL() *url.URL

URL is the target as the relay dialers read it: the session ID is the userinfo's username and the node, if there is one, its password.

type TargetError

type TargetError struct {
	Input  string // the text, as given
	Reason string // what is wrong with it
	// Suggestion is the target Input's command reaches, when Input, less any
	// whitespace around it, is one of the ssh commands upterm prints for
	// guests; "" for anything else. It parses as a target.
	Suggestion string
}

TargetError is ParseTarget's error for text that names no session to join.

func (*TargetError) Error

func (e *TargetError) Error() string

func (*TargetError) Is

func (e *TargetError) Is(target error) bool

Is reports a match with ErrNotATarget.

type Timing

type Timing struct {
	// FastBase and FastCap are the first wait of a run of attempts and the
	// most a wait can be while the guest is still in the fast tier. Each wait
	// doubles the bound of the one before until it reaches FastCap. 1 s, 10 s.
	FastBase, FastCap time.Duration

	// LongAfter is how long a wait has lasted when the slow tier begins, and
	// when Waiting says so. 5 min. The slow tier lasts until the schedule
	// starts over (see ResetAfter), so a session that comes back only briefly
	// doesn't put the guest back in the fast tier; Waiting's Long is the
	// current wait's alone. The wait is measured on the monotonic clock, as
	// the deadlines are, and not on Wall: a wall clock that is stepped, or
	// that jumps when a laptop wakes, leaves it where it was.
	LongAfter time.Duration

	// SlowWait and SlowJitter are what separates attempts in the slow tier:
	// SlowWait plus up to SlowJitter more. 60 s, 30 s.
	SlowWait, SlowJitter time.Duration

	// ResetAfter is how long a session has to have been ready for the next
	// drop to start the schedule over, in the fast tier at FastBase. A
	// session that drops sooner leaves the schedule where it was, in the slow
	// tier if it had got there, so that a host that keeps dropping a guest is
	// not rejoined every second. 60 s.
	ResetAfter time.Duration

	// FirstJoin is the deadline of a first join, from its first attempt. 30 s.
	FirstJoin time.Duration

	// Attempt is how long one attempt has to connect, from the dial to the
	// shell. 30 s.
	Attempt time.Duration

	// Liveness is when a connection that has gone quiet is probed and given
	// up on. The host's tunnel figures: 15 s and 15 s.
	Liveness liveness.Timing

	// WallCheck is the longest a wait goes without looking at the wall clock.
	// 1 s.
	WallCheck time.Duration

	// Jitter returns a duration chosen from [0, max]. Nil means uniform.
	Jitter func(max time.Duration) time.Duration

	// Wall tells the wall-clock time. Nil means time.Now with its monotonic
	// reading dropped, so that the time moves with the clock on the wall and
	// not with the one that stops while the machine sleeps.
	Wall func() time.Time
}

Timing is every duration the core runs by. A field left zero or negative takes its default, so that a caller, a test above all, can set one and inherit the rest.

type WaitReason

type WaitReason int

WaitReason is why the guest is waiting: the Why of a Lost or Waiting event, and of a DeadlineError, what a first join was waiting on when its time ran out.

const (
	NoHost WaitReason = iota + 1
	Unreachable
	RelayLookup
	Upstream
	IdentityFailure // a signer failed (the agent); named apart from the Identity seam type
	Setup
)

Jump to

Keyboard shortcuts

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