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 ¶
var ( ErrHostKeyUnknown = errors.New("the relay's key is not known") ErrHostKeyChanged = errors.New("the relay's key has changed") )
Errors the seams return.
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.
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 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 ¶
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)
}
}
Output:
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 ¶
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 ¶
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.
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 )