detached bf22b36

Merge pull request #34 from bnema/fix/remote-attach-ssh

Restore UDP as default transport and improve probe failure guidance
commit
bf22b3689e8310d47cc985ea373bf1c69fd2adf7
author
bnema · · 2026-07-07 04:33 UTC
committer
GitHub · · 2026-07-07 04:33 UTC
merge of
7047f01 + 49ca2bd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +510 -39
M README.md +2 -0
@@ -122,6 +122,8 @@ vev attach user@host:session
122122
123123 Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
124124
125+The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
126+
125127 Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.
126128
127129 ## Terminal color
@@ -122,6 +122,8 @@ vev attach user@host:session
122 122
123 Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.123 Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
124 124
125+The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
126+
125 Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.127 Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.
126 128
127 ## Terminal color129 ## Terminal color
M internal/adapters/dgram/ssh_dialer.go +14 -3
@@ -153,7 +153,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
153153 defer cancel()
154154 if err := probeUDPTransport(probeCtx, t); err != nil {
155155 _ = t.Close()
156- return nil, udpUnavailable("probe UDP transport", err, &stderr)
156+ return nil, udpProbeUnreachable(d.Target, err, &stderr)
157157 }
158158 cleanup = false
159159 return t, nil
@@ -235,9 +235,20 @@ func readUDPReady(r io.Reader) (udpReady, error) {
235235 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
236236 msg := strings.TrimSpace(stderr.String())
237237 if msg != "" {
238- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
238+ return fmt.Errorf("remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
239239 }
240- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err)
240+ return fmt.Errorf("remote UDP transport unavailable: %s: %w", action, err)
241+}
242+
243+// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
244+// probe almost always means a firewall on the remote is dropping datagrams to
245+// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
246+// surfacing a bare deadline error.
247+func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
248+ return fmt.Errorf("%w\n"+
249+ " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
250+ " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`",
251+ udpUnavailable("probe UDP transport", err, stderr), target)
241252 }
242253
243254 func sshTargetHost(target string) string {
@@ -153,7 +153,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
153 defer cancel()153 defer cancel()
154 if err := probeUDPTransport(probeCtx, t); err != nil {154 if err := probeUDPTransport(probeCtx, t); err != nil {
155 _ = t.Close()155 _ = t.Close()
156- return nil, udpUnavailable("probe UDP transport", err, &stderr)156+ return nil, udpProbeUnreachable(d.Target, err, &stderr)
157 }157 }
158 cleanup = false158 cleanup = false
159 return t, nil159 return t, nil
@@ -235,9 +235,20 @@ func readUDPReady(r io.Reader) (udpReady, error) {
235 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {235 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
236 msg := strings.TrimSpace(stderr.String())236 msg := strings.TrimSpace(stderr.String())
237 if msg != "" {237 if msg != "" {
238- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)238+ return fmt.Errorf("remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
239 }239 }
240- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err)240+ return fmt.Errorf("remote UDP transport unavailable: %s: %w", action, err)
241+}
242+
243+// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
244+// probe almost always means a firewall on the remote is dropping datagrams to
245+// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
246+// surfacing a bare deadline error.
247+func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
248+ return fmt.Errorf("%w\n"+
249+ " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
250+ " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`",
251+ udpUnavailable("probe UDP transport", err, stderr), target)
241 }252 }
242 253
243 func sshTargetHost(target string) string {254 func sshTargetHost(target string) string {
M internal/adapters/dgram/ssh_dialer_test.go +7 -2
@@ -59,7 +59,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
5959 waited bool
6060 wantStdoutClosed bool
6161 }{
62- {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
62+ {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
6363 {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
6464 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
6565 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
@@ -131,9 +131,14 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
131131 d := NewRemoteDialer("127.0.0.1", "work")
132132 d.ProbeTimeout = time.Nanosecond
133133 tr, err := d.Dial(context.Background())
134- if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") {
134+ if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
135135 t.Fatalf("Dial() error = %v, want probe unavailable", err)
136136 }
137+ for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
138+ if !strings.Contains(err.Error(), want) {
139+ t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
140+ }
141+ }
137142 if tr != nil {
138143 t.Fatalf("transport=%T, want nil", tr)
139144 }
@@ -59,7 +59,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
59 waited bool59 waited bool
60 wantStdoutClosed bool60 wantStdoutClosed bool
61 }{61 }{
62- {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},62+ {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63 {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},63 {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
64 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},64 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},65 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
@@ -131,9 +131,14 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
131 d := NewRemoteDialer("127.0.0.1", "work")131 d := NewRemoteDialer("127.0.0.1", "work")
132 d.ProbeTimeout = time.Nanosecond132 d.ProbeTimeout = time.Nanosecond
133 tr, err := d.Dial(context.Background())133 tr, err := d.Dial(context.Background())
134- if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") {134+ if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
135 t.Fatalf("Dial() error = %v, want probe unavailable", err)135 t.Fatalf("Dial() error = %v, want probe unavailable", err)
136 }136 }
137+ for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
138+ if !strings.Contains(err.Error(), want) {
139+ t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
140+ }
141+ }
137 if tr != nil {142 if tr != nil {
138 t.Fatalf("transport=%T, want nil", tr)143 t.Fatalf("transport=%T, want nil", tr)
139 }144 }
M internal/adapters/sshstdio/transport.go +91 -32
@@ -32,19 +32,25 @@ var (
3232 )
3333
3434 type closeFunc func() error
35+type eofErrFunc func() error
3536
3637 // NewTransport wraps separate reader/writer streams as a framed Transport.
37-func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
38- if close == nil {
39- close = func() error { return nil }
38+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
39+ return newTransport(r, w, closeFn, nil)
40+}
41+
42+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
43+ if closeFn == nil {
44+ closeFn = func() error { return nil }
4045 }
41- return &transport{r: r, w: w, close: close}
46+ return &transport{r: r, w: w, close: closeFn, eofErr: eofErr}
4247 }
4348
4449 type transport struct {
45- r io.Reader
46- w io.Writer
47- close closeFunc
50+ r io.Reader
51+ w io.Writer
52+ close closeFunc
53+ eofErr eofErrFunc
4854
4955 mu sync.Mutex
5056 readBuf []byte
@@ -70,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error {
7076 func (t *transport) Recv() (ports.Frame, error) {
7177 var hdr [frameHeaderLen]byte
7278 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
73- return ports.Frame{}, err
79+ return ports.Frame{}, t.mapEOFError(err)
7480 }
7581
7682 n := binary.BigEndian.Uint32(hdr[:])
@@ -87,46 +93,98 @@ func (t *transport) Recv() (ports.Frame, error) {
8793 t.readBuf = t.readBuf[:n]
8894 }
8995 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
90- return ports.Frame{}, err
96+ return ports.Frame{}, t.mapEOFError(err)
9197 }
9298
9399 payload := append([]byte(nil), t.readBuf[1:]...)
94100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
95101 }
96102
103+func (t *transport) mapEOFError(err error) error {
104+ if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105+ return err
106+ }
107+ if sshErr := t.eofErr(); sshErr != nil {
108+ return sshErr
109+ }
110+ return err
111+}
112+
97113 func (t *transport) Close() error { return t.close() }
98114
99-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
100- return func() error {
101- _ = stdin.Close()
115+type processWaiter struct {
116+ cmd *exec.Cmd
117+ stdin io.Closer
118+ stderr *bytes.Buffer
119+ timeout time.Duration
120+ log *slog.Logger
121+ target string
122+ session string
123+
124+ waitOnce sync.Once
125+ waitErr error
126+}
102127
103- waitErr := make(chan error, 1)
104- go func() { waitErr <- cmd.Wait() }()
128+func newProcessWaiter(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) *processWaiter {
129+ w := &processWaiter{
130+ cmd: cmd,
131+ stdin: stdin,
132+ stderr: stderr,
133+ timeout: timeout,
134+ log: log,
135+ target: target,
136+ session: session,
137+ }
138+ return w
139+}
105140
106- var err error
141+func (w *processWaiter) close() error {
142+ _ = w.stdin.Close()
143+ return w.wait(w.timeout)
144+}
145+
146+func (w *processWaiter) eofErr() error {
147+ return w.wait(sshCloseTimeout)
148+}
149+
150+func (w *processWaiter) wait(timeout time.Duration) error {
151+ w.waitOnce.Do(func() {
152+ // cmd.Wait must start only after stdout is no longer being read. os/exec
153+ // requires callers to finish reading StdoutPipe before Wait, so DialContext
154+ // calls this from transport Close or after Recv has already observed EOF.
155+ waitCh := make(chan error, 1)
156+ go func() { waitCh <- w.cmd.Wait() }()
107157 select {
108- case err = <-waitErr:
158+ case w.waitErr = <-waitCh:
109159 case <-time.After(timeout):
110- _ = cmd.Process.Kill()
111- err = <-waitErr
112- }
113- if err == nil {
114- return nil
160+ _ = w.cmd.Process.Kill()
161+ w.waitErr = <-waitCh
115162 }
163+ w.waitErr = formatProcessWaitError(w.waitErr, w.stderr, w.log, w.target, w.session)
164+ })
165+ return w.waitErr
166+}
116167
117- stderrText := strings.TrimSpace(stderr.String())
118- if log != nil {
119- attrs := []any{"target", target, "session", session, "err", err}
120- if stderrText != "" {
121- attrs = append(attrs, "stderr", stderrText)
122- }
123- log.Warn("ssh exited non-cleanly", attrs...)
124- }
168+func formatProcessWaitError(err error, stderr *bytes.Buffer, log *slog.Logger, target, session string) error {
169+ if err == nil {
170+ return nil
171+ }
172+ stderrText := strings.TrimSpace(stderr.String())
173+ if log != nil {
174+ attrs := []any{"target", target, "session", session, "err", err}
125175 if stderrText != "" {
126- return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
176+ attrs = append(attrs, "stderr", stderrText)
127177 }
128- return fmt.Errorf("sshstdio: ssh exited: %w", err)
178+ log.Warn("ssh exited non-cleanly", attrs...)
179+ }
180+ if stderrText != "" {
181+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
129182 }
183+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
184+}
185+
186+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
187+ return newProcessWaiter(cmd, stdin, stderr, timeout, log, target, session).close
130188 }
131189
132190 // CommandSpec is the exact ssh argv vev will execute locally.
@@ -195,5 +253,6 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
195253 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
196254 }
197255
198- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)), nil
256+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257+ return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
199258 }
@@ -32,19 +32,25 @@ var (
32 )32 )
33 33
34 type closeFunc func() error34 type closeFunc func() error
35+type eofErrFunc func() error
35 36
36 // NewTransport wraps separate reader/writer streams as a framed Transport.37 // NewTransport wraps separate reader/writer streams as a framed Transport.
37-func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {38+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
38- if close == nil {39+ return newTransport(r, w, closeFn, nil)
39- close = func() error { return nil }40+}
41+
42+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
43+ if closeFn == nil {
44+ closeFn = func() error { return nil }
40 }45 }
41- return &transport{r: r, w: w, close: close}46+ return &transport{r: r, w: w, close: closeFn, eofErr: eofErr}
42 }47 }
43 48
44 type transport struct {49 type transport struct {
45- r io.Reader50+ r io.Reader
46- w io.Writer51+ w io.Writer
47- close closeFunc52+ close closeFunc
53+ eofErr eofErrFunc
48 54
49 mu sync.Mutex55 mu sync.Mutex
50 readBuf []byte56 readBuf []byte
@@ -70,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error {
70 func (t *transport) Recv() (ports.Frame, error) {76 func (t *transport) Recv() (ports.Frame, error) {
71 var hdr [frameHeaderLen]byte77 var hdr [frameHeaderLen]byte
72 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {78 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
73- return ports.Frame{}, err79+ return ports.Frame{}, t.mapEOFError(err)
74 }80 }
75 81
76 n := binary.BigEndian.Uint32(hdr[:])82 n := binary.BigEndian.Uint32(hdr[:])
@@ -87,46 +93,98 @@ func (t *transport) Recv() (ports.Frame, error) {
87 t.readBuf = t.readBuf[:n]93 t.readBuf = t.readBuf[:n]
88 }94 }
89 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {95 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
90- return ports.Frame{}, err96+ return ports.Frame{}, t.mapEOFError(err)
91 }97 }
92 98
93 payload := append([]byte(nil), t.readBuf[1:]...)99 payload := append([]byte(nil), t.readBuf[1:]...)
94 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
95 }101 }
96 102
103+func (t *transport) mapEOFError(err error) error {
104+ if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105+ return err
106+ }
107+ if sshErr := t.eofErr(); sshErr != nil {
108+ return sshErr
109+ }
110+ return err
111+}
112+
97 func (t *transport) Close() error { return t.close() }113 func (t *transport) Close() error { return t.close() }
98 114
99-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {115+type processWaiter struct {
100- return func() error {116+ cmd *exec.Cmd
101- _ = stdin.Close()117+ stdin io.Closer
118+ stderr *bytes.Buffer
119+ timeout time.Duration
120+ log *slog.Logger
121+ target string
122+ session string
123+
124+ waitOnce sync.Once
125+ waitErr error
126+}
102 127
103- waitErr := make(chan error, 1)128+func newProcessWaiter(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) *processWaiter {
104- go func() { waitErr <- cmd.Wait() }()129+ w := &processWaiter{
130+ cmd: cmd,
131+ stdin: stdin,
132+ stderr: stderr,
133+ timeout: timeout,
134+ log: log,
135+ target: target,
136+ session: session,
137+ }
138+ return w
139+}
105 140
106- var err error141+func (w *processWaiter) close() error {
142+ _ = w.stdin.Close()
143+ return w.wait(w.timeout)
144+}
145+
146+func (w *processWaiter) eofErr() error {
147+ return w.wait(sshCloseTimeout)
148+}
149+
150+func (w *processWaiter) wait(timeout time.Duration) error {
151+ w.waitOnce.Do(func() {
152+ // cmd.Wait must start only after stdout is no longer being read. os/exec
153+ // requires callers to finish reading StdoutPipe before Wait, so DialContext
154+ // calls this from transport Close or after Recv has already observed EOF.
155+ waitCh := make(chan error, 1)
156+ go func() { waitCh <- w.cmd.Wait() }()
107 select {157 select {
108- case err = <-waitErr:158+ case w.waitErr = <-waitCh:
109 case <-time.After(timeout):159 case <-time.After(timeout):
110- _ = cmd.Process.Kill()160+ _ = w.cmd.Process.Kill()
111- err = <-waitErr161+ w.waitErr = <-waitCh
112- }
113- if err == nil {
114- return nil
115 }162 }
163+ w.waitErr = formatProcessWaitError(w.waitErr, w.stderr, w.log, w.target, w.session)
164+ })
165+ return w.waitErr
166+}
116 167
117- stderrText := strings.TrimSpace(stderr.String())168+func formatProcessWaitError(err error, stderr *bytes.Buffer, log *slog.Logger, target, session string) error {
118- if log != nil {169+ if err == nil {
119- attrs := []any{"target", target, "session", session, "err", err}170+ return nil
120- if stderrText != "" {171+ }
121- attrs = append(attrs, "stderr", stderrText)172+ stderrText := strings.TrimSpace(stderr.String())
122- }173+ if log != nil {
123- log.Warn("ssh exited non-cleanly", attrs...)174+ attrs := []any{"target", target, "session", session, "err", err}
124- }
125 if stderrText != "" {175 if stderrText != "" {
126- return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)176+ attrs = append(attrs, "stderr", stderrText)
127 }177 }
128- return fmt.Errorf("sshstdio: ssh exited: %w", err)178+ log.Warn("ssh exited non-cleanly", attrs...)
179+ }
180+ if stderrText != "" {
181+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
129 }182 }
183+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
184+}
185+
186+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
187+ return newProcessWaiter(cmd, stdin, stderr, timeout, log, target, session).close
130 }188 }
131 189
132 // CommandSpec is the exact ssh argv vev will execute locally.190 // CommandSpec is the exact ssh argv vev will execute locally.
@@ -195,5 +253,6 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
195 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)253 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
196 }254 }
197 255
198- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)), nil256+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257+ return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
199 }258 }
M internal/adapters/sshstdio/transport_test.go +40 -0
@@ -128,6 +128,46 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
128128 }
129129 }
130130
131+func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) {
132+ sshErr := errors.New("ssh exited with status 255")
133+ tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr })
134+
135+ _, err := tr.Recv()
136+ if !errors.Is(err, sshErr) {
137+ t.Fatalf("Recv error = %v, want %v", err, sshErr)
138+ }
139+}
140+
141+func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
142+ cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo connection refused >&2; exit 255")
143+ stdin, err := cmd.StdinPipe()
144+ if err != nil {
145+ t.Fatalf("StdinPipe: %v", err)
146+ }
147+ stdout, err := cmd.StdoutPipe()
148+ if err != nil {
149+ t.Fatalf("StdoutPipe: %v", err)
150+ }
151+ var stderr bytes.Buffer
152+ cmd.Stderr = &stderr
153+ if err := cmd.Start(); err != nil {
154+ t.Fatalf("Start: %v", err)
155+ }
156+
157+ waiter := newProcessWaiter(cmd, stdin, &stderr, time.Second, nil, "user@example.com", "work")
158+ tr := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
159+
160+ _, err = tr.Recv()
161+ if err == nil {
162+ t.Fatal("Recv error = nil, want ssh exit error")
163+ }
164+ for _, want := range []string{"sshstdio: ssh exited:", "connection refused"} {
165+ if !strings.Contains(err.Error(), want) {
166+ t.Fatalf("Recv error = %q, want substring %q", err, want)
167+ }
168+ }
169+}
170+
131171 func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
132172 cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
133173 stdin, err := cmd.StdinPipe()
@@ -128,6 +128,46 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
128 }128 }
129 }129 }
130 130
131+func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) {
132+ sshErr := errors.New("ssh exited with status 255")
133+ tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr })
134+
135+ _, err := tr.Recv()
136+ if !errors.Is(err, sshErr) {
137+ t.Fatalf("Recv error = %v, want %v", err, sshErr)
138+ }
139+}
140+
141+func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
142+ cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo connection refused >&2; exit 255")
143+ stdin, err := cmd.StdinPipe()
144+ if err != nil {
145+ t.Fatalf("StdinPipe: %v", err)
146+ }
147+ stdout, err := cmd.StdoutPipe()
148+ if err != nil {
149+ t.Fatalf("StdoutPipe: %v", err)
150+ }
151+ var stderr bytes.Buffer
152+ cmd.Stderr = &stderr
153+ if err := cmd.Start(); err != nil {
154+ t.Fatalf("Start: %v", err)
155+ }
156+
157+ waiter := newProcessWaiter(cmd, stdin, &stderr, time.Second, nil, "user@example.com", "work")
158+ tr := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
159+
160+ _, err = tr.Recv()
161+ if err == nil {
162+ t.Fatal("Recv error = nil, want ssh exit error")
163+ }
164+ for _, want := range []string{"sshstdio: ssh exited:", "connection refused"} {
165+ if !strings.Contains(err.Error(), want) {
166+ t.Fatalf("Recv error = %q, want substring %q", err, want)
167+ }
168+ }
169+}
170+
131 func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {171 func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
132 cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")172 cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
133 stdin, err := cmd.StdinPipe()173 stdin, err := cmd.StdinPipe()
M internal/app/run.go +88 -2
@@ -20,6 +20,8 @@ import (
2020 "os/exec"
2121 "os/signal"
2222 "path/filepath"
23+ "strconv"
24+ "strings"
2325 "syscall"
2426 "text/tabwriter"
2527 "time"
@@ -705,8 +707,11 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
705707 defer func() { _ = logCloser.Close() }()
706708 log.Debug("udp proxy starting", "session", session)
707709
708- var lc net.ListenConfig
709- conn, err := lc.ListenPacket(ctx, "udp", ":0")
710+ portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
711+ if err != nil {
712+ return err
713+ }
714+ conn, err := listenUDPInRange(ctx, portRange)
710715 if err != nil {
711716 return err
712717 }
@@ -741,6 +746,87 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
741746 return proxyTransports(ctx, dgramTr, daemonTr, log)
742747 }
743748
749+const (
750+ // envUDPPortRange configures the remote UDP proxy's listen port range so a
751+ // host firewall can allow a known range instead of a random ephemeral port.
752+ // Format: "START-END" (inclusive), a single "PORT", or "0" for a random
753+ // ephemeral port. Unset uses the default range below.
754+ envUDPPortRange = "VEV_UDP_PORT_RANGE"
755+ defaultUDPPortStart = 61000
756+ defaultUDPPortEnd = 61023
757+)
758+
759+// udpPortRange is an inclusive [start, end] UDP port range. start == 0 means a
760+// random ephemeral port (the old ":0" behavior).
761+type udpPortRange struct {
762+ start int
763+ end int
764+}
765+
766+// parseUDPPortRange parses VEV_UDP_PORT_RANGE. Empty -> default range. "0" ->
767+// ephemeral. "N" -> single port N. "A-B" -> inclusive range. Ports must be in
768+// 1..65535 (or 0 for ephemeral) and end must be >= start.
769+func parseUDPPortRange(value string) (udpPortRange, error) {
770+ value = strings.TrimSpace(value)
771+ if value == "" {
772+ return udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}, nil
773+ }
774+ startStr, endStr, hasRange := strings.Cut(value, "-")
775+ start, err := parseUDPPort(startStr)
776+ if err != nil {
777+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
778+ }
779+ if start == 0 {
780+ if hasRange {
781+ return udpPortRange{}, fmt.Errorf("invalid %s %q: port 0 (ephemeral) cannot be combined with a range", envUDPPortRange, value)
782+ }
783+ return udpPortRange{start: 0, end: 0}, nil
784+ }
785+ end := start
786+ if hasRange {
787+ end, err = parseUDPPort(endStr)
788+ if err != nil {
789+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
790+ }
791+ }
792+ if end < start {
793+ return udpPortRange{}, fmt.Errorf("invalid %s %q: end %d before start %d", envUDPPortRange, value, end, start)
794+ }
795+ return udpPortRange{start: start, end: end}, nil
796+}
797+
798+func parseUDPPort(s string) (int, error) {
799+ p, err := strconv.Atoi(strings.TrimSpace(s))
800+ if err != nil {
801+ return 0, fmt.Errorf("port %q is not a number", strings.TrimSpace(s))
802+ }
803+ if p < 0 || p > 65535 {
804+ return 0, fmt.Errorf("port %d out of range 0-65535", p)
805+ }
806+ return p, nil
807+}
808+
809+// listenUDPInRange binds a UDP packet conn on the first free port in the range,
810+// skipping busy ports. A zero-start range binds a random ephemeral port.
811+func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, error) {
812+ var lc net.ListenConfig
813+ if r.start == 0 {
814+ return lc.ListenPacket(ctx, "udp", ":0")
815+ }
816+ if r.start < 0 || r.end < r.start {
817+ return nil, fmt.Errorf("invalid UDP port range %d-%d", r.start, r.end)
818+ }
819+ var lastErr error
820+ for port := r.start; port <= r.end; port++ {
821+ conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
822+ if err == nil {
823+ return conn, nil
824+ }
825+ lastErr = err
826+ }
827+ return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
828+}
829+
744830 type preHelloNameTransport struct {
745831 ports.Transport
746832 session string
@@ -20,6 +20,8 @@ import (
20 "os/exec"20 "os/exec"
21 "os/signal"21 "os/signal"
22 "path/filepath"22 "path/filepath"
23+ "strconv"
24+ "strings"
23 "syscall"25 "syscall"
24 "text/tabwriter"26 "text/tabwriter"
25 "time"27 "time"
@@ -705,8 +707,11 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
705 defer func() { _ = logCloser.Close() }()707 defer func() { _ = logCloser.Close() }()
706 log.Debug("udp proxy starting", "session", session)708 log.Debug("udp proxy starting", "session", session)
707 709
708- var lc net.ListenConfig710+ portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
709- conn, err := lc.ListenPacket(ctx, "udp", ":0")711+ if err != nil {
712+ return err
713+ }
714+ conn, err := listenUDPInRange(ctx, portRange)
710 if err != nil {715 if err != nil {
711 return err716 return err
712 }717 }
@@ -741,6 +746,87 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
741 return proxyTransports(ctx, dgramTr, daemonTr, log)746 return proxyTransports(ctx, dgramTr, daemonTr, log)
742 }747 }
743 748
749+const (
750+ // envUDPPortRange configures the remote UDP proxy's listen port range so a
751+ // host firewall can allow a known range instead of a random ephemeral port.
752+ // Format: "START-END" (inclusive), a single "PORT", or "0" for a random
753+ // ephemeral port. Unset uses the default range below.
754+ envUDPPortRange = "VEV_UDP_PORT_RANGE"
755+ defaultUDPPortStart = 61000
756+ defaultUDPPortEnd = 61023
757+)
758+
759+// udpPortRange is an inclusive [start, end] UDP port range. start == 0 means a
760+// random ephemeral port (the old ":0" behavior).
761+type udpPortRange struct {
762+ start int
763+ end int
764+}
765+
766+// parseUDPPortRange parses VEV_UDP_PORT_RANGE. Empty -> default range. "0" ->
767+// ephemeral. "N" -> single port N. "A-B" -> inclusive range. Ports must be in
768+// 1..65535 (or 0 for ephemeral) and end must be >= start.
769+func parseUDPPortRange(value string) (udpPortRange, error) {
770+ value = strings.TrimSpace(value)
771+ if value == "" {
772+ return udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}, nil
773+ }
774+ startStr, endStr, hasRange := strings.Cut(value, "-")
775+ start, err := parseUDPPort(startStr)
776+ if err != nil {
777+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
778+ }
779+ if start == 0 {
780+ if hasRange {
781+ return udpPortRange{}, fmt.Errorf("invalid %s %q: port 0 (ephemeral) cannot be combined with a range", envUDPPortRange, value)
782+ }
783+ return udpPortRange{start: 0, end: 0}, nil
784+ }
785+ end := start
786+ if hasRange {
787+ end, err = parseUDPPort(endStr)
788+ if err != nil {
789+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
790+ }
791+ }
792+ if end < start {
793+ return udpPortRange{}, fmt.Errorf("invalid %s %q: end %d before start %d", envUDPPortRange, value, end, start)
794+ }
795+ return udpPortRange{start: start, end: end}, nil
796+}
797+
798+func parseUDPPort(s string) (int, error) {
799+ p, err := strconv.Atoi(strings.TrimSpace(s))
800+ if err != nil {
801+ return 0, fmt.Errorf("port %q is not a number", strings.TrimSpace(s))
802+ }
803+ if p < 0 || p > 65535 {
804+ return 0, fmt.Errorf("port %d out of range 0-65535", p)
805+ }
806+ return p, nil
807+}
808+
809+// listenUDPInRange binds a UDP packet conn on the first free port in the range,
810+// skipping busy ports. A zero-start range binds a random ephemeral port.
811+func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, error) {
812+ var lc net.ListenConfig
813+ if r.start == 0 {
814+ return lc.ListenPacket(ctx, "udp", ":0")
815+ }
816+ if r.start < 0 || r.end < r.start {
817+ return nil, fmt.Errorf("invalid UDP port range %d-%d", r.start, r.end)
818+ }
819+ var lastErr error
820+ for port := r.start; port <= r.end; port++ {
821+ conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
822+ if err == nil {
823+ return conn, nil
824+ }
825+ lastErr = err
826+ }
827+ return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
828+}
829+
744 type preHelloNameTransport struct {830 type preHelloNameTransport struct {
745 ports.Transport831 ports.Transport
746 session string832 session string
M internal/app/run_test.go +103 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "net"
910 "os"
1011 "os/exec"
1112 "path/filepath"
@@ -633,3 +634,105 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
633634 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
634635 }
635636 }
637+
638+func TestParseUDPPortRange(t *testing.T) {
639+ tests := []struct {
640+ name string
641+ value string
642+ want udpPortRange
643+ wantError bool
644+ }{
645+ {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
646+ {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
647+ {name: "zero range rejected", value: "0-100", wantError: true},
648+ {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
649+ {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
650+ {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
651+ {name: "reversed range", value: "61023-61000", wantError: true},
652+ {name: "non-numeric", value: "abc", wantError: true},
653+ {name: "missing end", value: "61000-", wantError: true},
654+ {name: "port too high", value: "70000", wantError: true},
655+ }
656+ for _, tt := range tests {
657+ t.Run(tt.name, func(t *testing.T) {
658+ got, err := parseUDPPortRange(tt.value)
659+ if tt.wantError {
660+ if err == nil {
661+ t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
662+ }
663+ return
664+ }
665+ if err != nil {
666+ t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
667+ }
668+ if got != tt.want {
669+ t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
670+ }
671+ })
672+ }
673+}
674+
675+func TestListenUDPInRange(t *testing.T) {
676+ // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
677+ var lc net.ListenConfig
678+ occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
679+ if err != nil {
680+ t.Fatal(err)
681+ }
682+ defer func() { _ = occupied.Close() }()
683+ udpPort := func(t *testing.T, conn net.PacketConn) int {
684+ t.Helper()
685+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
686+ if !ok {
687+ t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
688+ }
689+ return addr.Port
690+ }
691+ busyPort := udpPort(t, occupied)
692+
693+ t.Run("skips busy port", func(t *testing.T) {
694+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
695+ if err != nil {
696+ t.Fatalf("listenUDPInRange error = %v", err)
697+ }
698+ defer func() { _ = conn.Close() }()
699+ got := udpPort(t, conn)
700+ if got == busyPort {
701+ t.Fatalf("bound busy port %d, want a different port in range", busyPort)
702+ }
703+ if got < busyPort || got > busyPort+8 {
704+ t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
705+ }
706+ })
707+
708+ t.Run("exhausted range errors", func(t *testing.T) {
709+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
710+ if err == nil {
711+ t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
712+ }
713+ if !strings.Contains(err.Error(), "no free UDP port in range") {
714+ t.Fatalf("error = %v, want 'no free UDP port in range'", err)
715+ }
716+ })
717+
718+ t.Run("invalid direct range errors", func(t *testing.T) {
719+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
720+ if err == nil {
721+ t.Fatal("listenUDPInRange error = nil, want invalid-range error")
722+ }
723+ if !strings.Contains(err.Error(), "invalid UDP port range") {
724+ t.Fatalf("error = %v, want 'invalid UDP port range'", err)
725+ }
726+ })
727+
728+ t.Run("ephemeral binds", func(t *testing.T) {
729+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
730+ if err != nil {
731+ t.Fatalf("listenUDPInRange ephemeral error = %v", err)
732+ }
733+ defer func() { _ = conn.Close() }()
734+ if udpPort(t, conn) == 0 {
735+ t.Fatal("ephemeral bind returned port 0")
736+ }
737+ })
738+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "net"
9 "os"10 "os"
10 "os/exec"11 "os/exec"
11 "path/filepath"12 "path/filepath"
@@ -633,3 +634,105 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
633 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)634 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
634 }635 }
635 }636 }
637+
638+func TestParseUDPPortRange(t *testing.T) {
639+ tests := []struct {
640+ name string
641+ value string
642+ want udpPortRange
643+ wantError bool
644+ }{
645+ {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
646+ {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
647+ {name: "zero range rejected", value: "0-100", wantError: true},
648+ {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
649+ {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
650+ {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
651+ {name: "reversed range", value: "61023-61000", wantError: true},
652+ {name: "non-numeric", value: "abc", wantError: true},
653+ {name: "missing end", value: "61000-", wantError: true},
654+ {name: "port too high", value: "70000", wantError: true},
655+ }
656+ for _, tt := range tests {
657+ t.Run(tt.name, func(t *testing.T) {
658+ got, err := parseUDPPortRange(tt.value)
659+ if tt.wantError {
660+ if err == nil {
661+ t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
662+ }
663+ return
664+ }
665+ if err != nil {
666+ t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
667+ }
668+ if got != tt.want {
669+ t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
670+ }
671+ })
672+ }
673+}
674+
675+func TestListenUDPInRange(t *testing.T) {
676+ // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
677+ var lc net.ListenConfig
678+ occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
679+ if err != nil {
680+ t.Fatal(err)
681+ }
682+ defer func() { _ = occupied.Close() }()
683+ udpPort := func(t *testing.T, conn net.PacketConn) int {
684+ t.Helper()
685+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
686+ if !ok {
687+ t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
688+ }
689+ return addr.Port
690+ }
691+ busyPort := udpPort(t, occupied)
692+
693+ t.Run("skips busy port", func(t *testing.T) {
694+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
695+ if err != nil {
696+ t.Fatalf("listenUDPInRange error = %v", err)
697+ }
698+ defer func() { _ = conn.Close() }()
699+ got := udpPort(t, conn)
700+ if got == busyPort {
701+ t.Fatalf("bound busy port %d, want a different port in range", busyPort)
702+ }
703+ if got < busyPort || got > busyPort+8 {
704+ t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
705+ }
706+ })
707+
708+ t.Run("exhausted range errors", func(t *testing.T) {
709+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
710+ if err == nil {
711+ t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
712+ }
713+ if !strings.Contains(err.Error(), "no free UDP port in range") {
714+ t.Fatalf("error = %v, want 'no free UDP port in range'", err)
715+ }
716+ })
717+
718+ t.Run("invalid direct range errors", func(t *testing.T) {
719+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
720+ if err == nil {
721+ t.Fatal("listenUDPInRange error = nil, want invalid-range error")
722+ }
723+ if !strings.Contains(err.Error(), "invalid UDP port range") {
724+ t.Fatalf("error = %v, want 'invalid UDP port range'", err)
725+ }
726+ })
727+
728+ t.Run("ephemeral binds", func(t *testing.T) {
729+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
730+ if err != nil {
731+ t.Fatalf("listenUDPInRange ephemeral error = %v", err)
732+ }
733+ defer func() { _ = conn.Close() }()
734+ if udpPort(t, conn) == 0 {
735+ t.Fatal("ephemeral bind returned port 0")
736+ }
737+ })
738+}
A scripts/debug-remote-attach.sh +165 -0
@@ -0,0 +1,165 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat <<'USAGE' >&2
6+Usage: scripts/debug-remote-attach.sh [--mode stdio|udp|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
7+
8+Runs a real remote attach smoke/debug attempt under a PTY, captures local and
9+remote vev versions, and stores local/remote log tails around the attempt.
10+
11+Examples:
12+ scripts/debug-remote-attach.sh brice@arch
13+ scripts/debug-remote-attach.sh --session work brice@arch
14+ scripts/debug-remote-attach.sh --mode udp brice@arch
15+ go build -o /tmp/vev-debug . && scripts/debug-remote-attach.sh --vev-bin /tmp/vev-debug brice@arch
16+
17+Notes:
18+ - The command times out intentionally if attach succeeds and stays interactive.
19+ - Exit 124 from timeout is treated as a likely successful attach if no early
20+ vev/ssh error is captured; inspect the bundle for confirmation.
21+ - The remote host must be reachable by SSH for log collection.
22+ - The attach attempt unsets VEV so the smoke test can run from inside vev.
23+USAGE
24+}
25+
26+mode="default"
27+session=""
28+duration="8s"
29+vev_bin="${VEV_BIN:-vev}"
30+while [[ $# -gt 0 ]]; do
31+ case "$1" in
32+ --mode)
33+ mode="${2:-}"; shift 2 ;;
34+ --session)
35+ session="${2:-}"; shift 2 ;;
36+ --duration)
37+ duration="${2:-}"; shift 2 ;;
38+ --vev-bin)
39+ vev_bin="${2:-}"; shift 2 ;;
40+ -h|--help)
41+ usage; exit 0 ;;
42+ --)
43+ shift; break ;;
44+ -*)
45+ echo "unknown option: $1" >&2; usage; exit 2 ;;
46+ *)
47+ break ;;
48+ esac
49+done
50+
51+if [[ $# -ne 1 ]]; then
52+ usage
53+ exit 2
54+fi
55+case "$mode" in
56+ default|stdio|udp) ;;
57+ *) echo "invalid --mode $mode (want default, stdio, or udp)" >&2; exit 2 ;;
58+esac
59+
60+target="$1"
61+attach_target="$target"
62+if [[ -n "$session" ]]; then
63+ attach_target="$target:$session"
64+fi
65+
66+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
67+out_dir="${TMPDIR:-/tmp}/vev-remote-attach-$target-$(date +%Y%m%d-%H%M%S)"
68+out_dir="${out_dir//@/_}"
69+out_dir="${out_dir//:/_}"
70+mkdir -p "$out_dir"
71+
72+local_state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"
73+
74+run_capture() {
75+ local name="$1"; shift
76+ {
77+ printf '$'
78+ printf ' %q' "$@"
79+ printf '\n'
80+ "$@"
81+ } >"$out_dir/$name.out" 2>"$out_dir/$name.err" || printf '%s\n' "$?" >"$out_dir/$name.exit"
82+}
83+
84+copy_local_logs() {
85+ local label="$1"
86+ mkdir -p "$out_dir/local-$label"
87+ if [[ -d "$local_state" ]]; then
88+ find "$local_state" -maxdepth 1 -type f -name 'vev*.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
89+ tail -n 200 "$f" >"$out_dir/local-$label/$(basename "$f")" || true
90+ done
91+ fi
92+}
93+
94+copy_remote_logs() {
95+ local label="$1"
96+ mkdir -p "$out_dir/remote-$label"
97+ ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; if [ -d "$state" ]; then for f in "$state"/vev*.log; do [ -e "$f" ] || continue; echo "===== $f ====="; tail -n 200 "$f"; done; fi' \
98+ >"$out_dir/remote-$label/logs.out" 2>"$out_dir/remote-$label/logs.err" || true
99+}
100+
101+printf 'debug bundle: %s\n' "$out_dir"
102+printf 'target: %s\nattach_target: %s\nmode: %s\nduration: %s\n' "$target" "$attach_target" "$mode" "$duration" >"$out_dir/summary.txt"
103+
104+(
105+ cd "$repo_root"
106+ git rev-parse --show-toplevel
107+ git rev-parse --short HEAD
108+ git status --short
109+) >"$out_dir/git.txt" 2>&1 || true
110+
111+run_capture local-version "$vev_bin" --version
112+ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'set -e; command -v vev; vev --version; pgrep -a vev || true; state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; echo "state=$state"' \
113+ >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"
114+
115+copy_local_logs before
116+copy_remote_logs before
117+
118+attach_log="$out_dir/attach.typescript"
119+attach_err="$out_dir/attach.err"
120+cmd=("$vev_bin" attach "$attach_target")
121+if [[ "$mode" == "default" ]]; then
122+ env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT)
123+else
124+ env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
125+fi
126+
127+set +e
128+if command -v script >/dev/null 2>&1; then
129+ printf -v attach_cmd '%q ' "${cmd[@]}"
130+ "${env_cmd[@]}" timeout "$duration" script -q -e -c "$attach_cmd" "$attach_log" >"$out_dir/attach.stdout" 2>"$attach_err"
131+else
132+ "${env_cmd[@]}" timeout "$duration" "${cmd[@]}" >"$out_dir/attach.stdout" 2>"$attach_err"
133+fi
134+attach_status=$?
135+set -e
136+printf '%s\n' "$attach_status" >"$out_dir/attach.exit"
137+
138+copy_local_logs after
139+copy_remote_logs after
140+
141+{
142+ echo "debug bundle: $out_dir"
143+ echo "attach exit: $attach_status"
144+ echo "local version:"
145+ sed 's/^/ /' "$out_dir/local-version.out" 2>/dev/null || true
146+ echo "remote version:"
147+ sed 's/^/ /' "$out_dir/remote-version.out" 2>/dev/null || true
148+ if [[ -s "$attach_err" ]]; then
149+ echo "attach stderr:"
150+ sed 's/^/ /' "$attach_err" | tail -40
151+ fi
152+ echo "recent local after logs:"
153+ find "$out_dir/local-after" -maxdepth 1 -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
154+ echo " --- $(basename "$f") ---"
155+ tail -20 "$f" | sed 's/^/ /'
156+ done
157+ echo "recent remote after logs:"
158+ tail -80 "$out_dir/remote-after/logs.out" 2>/dev/null | sed 's/^/ /' || true
159+} | tee -a "$out_dir/summary.txt"
160+
161+if [[ "$attach_status" -eq 124 ]]; then
162+ echo "attach timed out after $duration; this often means the interactive attach stayed alive. Inspect $out_dir for protocol/log errors."
163+ exit 0
164+fi
165+exit "$attach_status"
@@ -0,0 +1,165 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat <<'USAGE' >&2
6+Usage: scripts/debug-remote-attach.sh [--mode stdio|udp|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
7+
8+Runs a real remote attach smoke/debug attempt under a PTY, captures local and
9+remote vev versions, and stores local/remote log tails around the attempt.
10+
11+Examples:
12+ scripts/debug-remote-attach.sh brice@arch
13+ scripts/debug-remote-attach.sh --session work brice@arch
14+ scripts/debug-remote-attach.sh --mode udp brice@arch
15+ go build -o /tmp/vev-debug . && scripts/debug-remote-attach.sh --vev-bin /tmp/vev-debug brice@arch
16+
17+Notes:
18+ - The command times out intentionally if attach succeeds and stays interactive.
19+ - Exit 124 from timeout is treated as a likely successful attach if no early
20+ vev/ssh error is captured; inspect the bundle for confirmation.
21+ - The remote host must be reachable by SSH for log collection.
22+ - The attach attempt unsets VEV so the smoke test can run from inside vev.
23+USAGE
24+}
25+
26+mode="default"
27+session=""
28+duration="8s"
29+vev_bin="${VEV_BIN:-vev}"
30+while [[ $# -gt 0 ]]; do
31+ case "$1" in
32+ --mode)
33+ mode="${2:-}"; shift 2 ;;
34+ --session)
35+ session="${2:-}"; shift 2 ;;
36+ --duration)
37+ duration="${2:-}"; shift 2 ;;
38+ --vev-bin)
39+ vev_bin="${2:-}"; shift 2 ;;
40+ -h|--help)
41+ usage; exit 0 ;;
42+ --)
43+ shift; break ;;
44+ -*)
45+ echo "unknown option: $1" >&2; usage; exit 2 ;;
46+ *)
47+ break ;;
48+ esac
49+done
50+
51+if [[ $# -ne 1 ]]; then
52+ usage
53+ exit 2
54+fi
55+case "$mode" in
56+ default|stdio|udp) ;;
57+ *) echo "invalid --mode $mode (want default, stdio, or udp)" >&2; exit 2 ;;
58+esac
59+
60+target="$1"
61+attach_target="$target"
62+if [[ -n "$session" ]]; then
63+ attach_target="$target:$session"
64+fi
65+
66+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
67+out_dir="${TMPDIR:-/tmp}/vev-remote-attach-$target-$(date +%Y%m%d-%H%M%S)"
68+out_dir="${out_dir//@/_}"
69+out_dir="${out_dir//:/_}"
70+mkdir -p "$out_dir"
71+
72+local_state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"
73+
74+run_capture() {
75+ local name="$1"; shift
76+ {
77+ printf '$'
78+ printf ' %q' "$@"
79+ printf '\n'
80+ "$@"
81+ } >"$out_dir/$name.out" 2>"$out_dir/$name.err" || printf '%s\n' "$?" >"$out_dir/$name.exit"
82+}
83+
84+copy_local_logs() {
85+ local label="$1"
86+ mkdir -p "$out_dir/local-$label"
87+ if [[ -d "$local_state" ]]; then
88+ find "$local_state" -maxdepth 1 -type f -name 'vev*.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
89+ tail -n 200 "$f" >"$out_dir/local-$label/$(basename "$f")" || true
90+ done
91+ fi
92+}
93+
94+copy_remote_logs() {
95+ local label="$1"
96+ mkdir -p "$out_dir/remote-$label"
97+ ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; if [ -d "$state" ]; then for f in "$state"/vev*.log; do [ -e "$f" ] || continue; echo "===== $f ====="; tail -n 200 "$f"; done; fi' \
98+ >"$out_dir/remote-$label/logs.out" 2>"$out_dir/remote-$label/logs.err" || true
99+}
100+
101+printf 'debug bundle: %s\n' "$out_dir"
102+printf 'target: %s\nattach_target: %s\nmode: %s\nduration: %s\n' "$target" "$attach_target" "$mode" "$duration" >"$out_dir/summary.txt"
103+
104+(
105+ cd "$repo_root"
106+ git rev-parse --show-toplevel
107+ git rev-parse --short HEAD
108+ git status --short
109+) >"$out_dir/git.txt" 2>&1 || true
110+
111+run_capture local-version "$vev_bin" --version
112+ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'set -e; command -v vev; vev --version; pgrep -a vev || true; state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; echo "state=$state"' \
113+ >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"
114+
115+copy_local_logs before
116+copy_remote_logs before
117+
118+attach_log="$out_dir/attach.typescript"
119+attach_err="$out_dir/attach.err"
120+cmd=("$vev_bin" attach "$attach_target")
121+if [[ "$mode" == "default" ]]; then
122+ env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT)
123+else
124+ env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
125+fi
126+
127+set +e
128+if command -v script >/dev/null 2>&1; then
129+ printf -v attach_cmd '%q ' "${cmd[@]}"
130+ "${env_cmd[@]}" timeout "$duration" script -q -e -c "$attach_cmd" "$attach_log" >"$out_dir/attach.stdout" 2>"$attach_err"
131+else
132+ "${env_cmd[@]}" timeout "$duration" "${cmd[@]}" >"$out_dir/attach.stdout" 2>"$attach_err"
133+fi
134+attach_status=$?
135+set -e
136+printf '%s\n' "$attach_status" >"$out_dir/attach.exit"
137+
138+copy_local_logs after
139+copy_remote_logs after
140+
141+{
142+ echo "debug bundle: $out_dir"
143+ echo "attach exit: $attach_status"
144+ echo "local version:"
145+ sed 's/^/ /' "$out_dir/local-version.out" 2>/dev/null || true
146+ echo "remote version:"
147+ sed 's/^/ /' "$out_dir/remote-version.out" 2>/dev/null || true
148+ if [[ -s "$attach_err" ]]; then
149+ echo "attach stderr:"
150+ sed 's/^/ /' "$attach_err" | tail -40
151+ fi
152+ echo "recent local after logs:"
153+ find "$out_dir/local-after" -maxdepth 1 -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
154+ echo " --- $(basename "$f") ---"
155+ tail -20 "$f" | sed 's/^/ /'
156+ done
157+ echo "recent remote after logs:"
158+ tail -80 "$out_dir/remote-after/logs.out" 2>/dev/null | sed 's/^/ /' || true
159+} | tee -a "$out_dir/summary.txt"
160+
161+if [[ "$attach_status" -eq 124 ]]; then
162+ echo "attach timed out after $duration; this often means the interactive attach stayed alive. Inspect $out_dir for protocol/log errors."
163+ exit 0
164+fi
165+exit "$attach_status"

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