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}