mux.go RawHistoryFolder

1// SSH stdio daemonmux carriage.
2//
3// This file exposes the mux-specific SSH stdio carriage the daemonmux
4// multiplexer (internal/adapters/daemonmux) runs over. It is deliberately
5// separate from the ordinary Dial/DialContext pair: DialMuxContext starts an
6// explicit, caller-supplied command specification and never builds or reuses
7// an implicit legacy session command, so a mux carriage can never become a
8// second navigation path to a session. Session selection stays owned by the
9// typed Hello handshake over the ordinary carriage; the two endpoints cannot be
10// confused because the mux dial takes the whole command from its caller.
11//
12// The carriage reuses the existing SSH stdio machinery unchanged: an ssh (or
13// other) child process started with exec.Command argv - never through a shell -
14// whose stdin/stdout are wrapped by the shared streamframe framing and the
15// bounded RecvBounded capability the daemonmux FramedCarrierBridge consumes.
16// One child process is one physical daemonmux connection; logical attachments
17// are multiplexed inside its stdio stream, never mapped to further processes.
18//
19// Non-interactive by construction: stdin is a pipe the transport owns, no PTY
20// is ever allocated locally, and BuildCommandForMux adds only ssh's `-T` (no
21// remote TTY). Host-key and authentication trust stay entirely owned by the
22// target's effective OpenSSH configuration: this file never injects
23// StrictHostKeyChecking, UserKnownHostsFile, BatchMode, or ProxyCommand, so a
24// mux carriage cannot weaken the trust policy the user configured.
25//
26// The caller's setup context (or its deadline) bounds subprocess start and is
27// shared with the daemonmux physical handshake the caller runs afterwards over
28// the returned carriage. A successful carriage is deliberately detached from
29// that context: Transport.Close - not the setup context - owns the subprocess
30// and its pipes, so a handshake deadline cannot kill a healthy carriage.
31//
32// Diagnostics are bounded and sanitized. The child's stderr is captured up to a
33// hard byte ceiling; a non-clean exit logs the captured text with terminal
34// escapes and control bytes stripped, and the public error carries only a typed
35// sentinel plus the process outcome, never raw remote stderr.
36
37package sshstdio
38
39import (
40	"bytes"
41	"context"
42	"errors"
43	"fmt"
44	"log/slog"
45	"os"
46	"os/exec"
47	"strings"
48	"sync"
49	"time"
50	"unicode"
51	"unicode/utf8"
52
53	"github.com/bnema/vev/internal/protocol/wire"
54)
55
56const (
57	// muxStderrLimit bounds how much of the mux child's stderr is captured.
58	// The capture is a diagnostic aid, so an unbounded or hostile stream can
59	// never grow the buffer without limit.
60	muxStderrLimit = 4 << 10
61	// MuxExitNoDaemon is reserved for the hidden mux helper when its local
62	// daemon carriage is absent. OpenSSH uses 255 for transport/auth failures.
63	MuxExitNoDaemon = 73
64	// muxDiagnosticLimit bounds the sanitized diagnostic echoed to a logger.
65	muxDiagnosticLimit = 512
66)
67
68// Mux carriage sentinels. They are typed so a caller can classify a
69// configuration or process outcome without matching on message text.
70var (
71	// ErrMuxConfig reports a DialMuxContext call with an empty command
72	// specification. It is a caller bug, not a peer outcome.
73	ErrMuxConfig = errors.New("sshstdio: invalid mux command specification")
74	// ErrMuxSSHExit reports a mux child that exited non-cleanly. Its cause
75	// carries the process outcome; the child's stderr is bounded, sanitized,
76	// and logged, never embedded in the public error.
77	ErrMuxSSHExit = errors.New("sshstdio: mux ssh exited")
78)
79
80// BuildCommandForMux constructs the local ssh argv for one explicit remote mux
81// command. It is the documented way to build a mux CommandSpec: it guarantees
82// stdio-only invocation (`-T`, no remote PTY) without touching host-key,
83// authentication, or proxy policy, which stays authoritative in the target's
84// effective OpenSSH configuration. Every remote word is POSIX single-quoted and
85// the target stays one local argv word after the option terminator.
86func BuildCommandForMux(target string, command ...string) CommandSpec {
87	spec := BuildCommandForRemoteCommand(target, command...)
88	return CommandSpec{Path: spec.Path, Args: append([]string{"-T"}, spec.Args...)}
89}
90
91// DialMuxContext starts spec as one explicit remote command over a local ssh
92// child process and returns its stdin/stdout as the raw bounded carriage the
93// daemonmux multiplexer runs over. spec is executed verbatim as argv, never
94// through a shell, and never selects a session itself: the caller
95// supplies the whole command.
96//
97// ctx (or its deadline) bounds subprocess start. The returned carriage is
98// detached from ctx and owned by Close, which terminates the child and its
99// pipes and joins the process wait; cancellation and Close therefore stop the
100// carriage promptly without leaking the process. A nil logger disables
101// diagnostics; a non-clean exit is logged with bounded, sanitized stderr and
102// surfaced as ErrMuxSSHExit with the process outcome as its cause.
103func DialMuxContext(ctx context.Context, spec CommandSpec, logger *slog.Logger, opts ...Option) (wire.BoundedTransport, error) {
104	return dialMuxContext(ctx, spec, logger, opts...)
105}
106
107// NewDiagnosticSink returns a bounded stderr sink that captures at most limit
108// bytes and reports a sanitized, truncated diagnostic: terminal escapes and
109// control bytes are stripped, so a hostile peer can neither grow the capture
110// without bound nor inject control bytes into a log. It is the same bounded,
111// sanitized capture the mux carriage uses for its own child diagnostics, shared
112// with callers (such as the broker's authenticated QUIC bootstrap) that start
113// an ssh child of their own and must never surface raw remote stderr.
114func NewDiagnosticSink(limit int) *DiagnosticSink {
115	if limit <= 0 {
116		limit = muxStderrLimit
117	}
118	return &DiagnosticSink{capped: newCappedDiagnostic(limit)}
119}
120
121// DiagnosticSink is a bounded, sanitizing, concurrent-safe io.Writer for one
122// child's stderr. It is safe for the os/exec stderr copy goroutine to write
123// while another goroutine reads String: the read is internally synchronized and
124// observes a bounded, sanitized snapshot rather than a partial capture.
125type DiagnosticSink struct{ capped *cappedDiagnostic }
126
127// Write captures at most the sink's ceiling and records that the rest was
128// dropped, so the child is never blocked writing diagnostics.
129func (s *DiagnosticSink) Write(p []byte) (int, error) { return s.capped.Write(p) }
130
131// String returns the captured text sanitized and truncated to the sink's
132// ceiling, so every reader observes a bounded, control-byte-free value.
133func (s *DiagnosticSink) String() string { return s.capped.String() }
134
135// NewStdioTransport returns the helper half of one SSH stdio carriage: the
136// current process' own stdin and stdout framed as the raw bounded transport the
137// daemonmux daemon path consumes. It is the counterpart of DialMuxContext, so a
138// hidden remote mux helper serves the one physical connection that dial started
139// without inventing a frame format of its own. Close releases the reader; the
140// process owns its own stdout, exactly as the session helper does.
141func NewStdioTransport(opts ...Option) wire.BoundedTransport {
142	return NewTransport(os.Stdin, os.Stdout, nil, opts...).(wire.BoundedTransport)
143}
144
145func dialMuxContext(ctx context.Context, spec CommandSpec, logger *slog.Logger, opts ...Option) (wire.BoundedTransport, error) {
146	if spec.Path == "" {
147		return nil, ErrMuxConfig
148	}
149	if ctx == nil {
150		ctx = context.Background()
151	}
152	if err := ctx.Err(); err != nil {
153		return nil, err
154	}
155	// The argument vector is executed verbatim. The caller's context is not
156	// bound to the command: it is shared with the daemonmux handshake that
157	// follows, and Transport.Close owns the subprocess lifetime.
158	cmd := exec.Command(spec.Path, spec.Args...)
159	stdin, err := cmd.StdinPipe()
160	if err != nil {
161		return nil, fmt.Errorf("sshstdio: mux stdin pipe: %w", err)
162	}
163	stdout, err := cmd.StdoutPipe()
164	if err != nil {
165		_ = stdin.Close()
166		return nil, fmt.Errorf("sshstdio: mux stdout pipe: %w", err)
167	}
168	stderr := newCappedDiagnostic(muxStderrLimit)
169	cmd.Stderr = stderr
170	// A grandchild (for example ssh's ProxyCommand) can outlive a killed
171	// child and hold the stderr pipe open; bound how long Wait keeps copying
172	// from it so Close never waits for that grandchild to exit.
173	cmd.WaitDelay = time.Second
174	if err := cmd.Start(); err != nil {
175		_ = stdin.Close()
176		return nil, fmt.Errorf("sshstdio: mux start %s: %w", spec.Path, err)
177	}
178	waiter := newMuxProcessWaiter(cmd, stdin, stderr, sshCloseTimeout, logger)
179	transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
180	if err := ctx.Err(); err != nil {
181		_ = transport.Close()
182		return nil, err
183	}
184	for _, opt := range opts {
185		if opt != nil {
186			opt(transport)
187		}
188	}
189	return transport, nil
190}
191
192// cappedDiagnostic captures a child process' stderr up to an explicit byte
193// ceiling and reports a bounded, sanitized diagnostic: terminal escapes and
194// non-printable bytes are stripped, so a hostile peer can neither grow the
195// capture without bound nor inject control bytes into a log.
196type cappedDiagnostic struct {
197	// mu makes Write and String safe to call concurrently: an os/exec stderr
198	// copy goroutine may still be writing while a reader renders the diagnostic,
199	// and the capture must never be observed partially written.
200	mu       sync.Mutex
201	buf      bytes.Buffer
202	limit    int
203	overflow bool
204}
205
206func newCappedDiagnostic(limit int) *cappedDiagnostic {
207	return &cappedDiagnostic{limit: limit}
208}
209
210// Write captures at most the configured ceiling and records that the rest was
211// dropped, so the child is never blocked writing diagnostics.
212func (d *cappedDiagnostic) Write(p []byte) (int, error) {
213	d.mu.Lock()
214	defer d.mu.Unlock()
215	remaining := d.limit - d.buf.Len()
216	if remaining <= 0 {
217		d.overflow = true
218		return len(p), nil
219	}
220	if len(p) > remaining {
221		d.overflow = true
222		_, _ = d.buf.Write(p[:remaining])
223		return len(p), nil
224	}
225	return d.buf.Write(p)
226}
227
228// String returns the captured text sanitized and truncated to the diagnostic
229// ceiling, so every reader observes a bounded, control-byte-free value.
230func (d *cappedDiagnostic) String() string {
231	d.mu.Lock()
232	defer d.mu.Unlock()
233	return sanitizeDiagnostic(d.buf.String())
234}
235
236// sanitizeDiagnostic strips terminal escapes and control bytes while preserving
237// useful printable diagnostic text, and truncates the result to the diagnostic
238// ceiling.
239func sanitizeDiagnostic(raw string) string {
240	var b strings.Builder
241	b.Grow(min(len(raw), muxDiagnosticLimit))
242	for i := 0; i < len(raw); {
243		if raw[i] == 0x1b {
244			i = skipTerminalEscape(raw, i+1)
245			continue
246		}
247		r, size := utf8.DecodeRuneInString(raw[i:])
248		if r == utf8.RuneError && size == 1 {
249			i++
250			continue
251		}
252		if unicode.IsPrint(r) || r == '\t' || r == '\n' {
253			if b.Len()+size > muxDiagnosticLimit {
254				break
255			}
256			b.WriteRune(r)
257		}
258		i += size
259	}
260	return strings.TrimSpace(b.String())
261}
262
263// skipTerminalEscape returns the index just past one terminal escape sequence
264// starting at i (the byte after ESC).
265func skipTerminalEscape(s string, i int) int {
266	if i >= len(s) {
267		return i
268	}
269	switch s[i] {
270	case '[': // CSI: ESC [ ... final byte in 0x40-0x7E
271		i++
272		for i < len(s) {
273			ch := s[i]
274			i++
275			if ch >= 0x40 && ch <= 0x7e {
276				break
277			}
278		}
279		return i
280	case ']': // OSC: ESC ] ... BEL or ST (ESC \)
281		i++
282		for i < len(s) {
283			if s[i] == 0x07 {
284				return i + 1
285			}
286			if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' {
287				return i + 2
288			}
289			i++
290		}
291		return i
292	default:
293		// Two-byte Fe escape or unknown: drop ESC and the next byte.
294		return i + 1
295	}
296}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close