Merge pull request #34 from bnema/fix/remote-attach-ssh
Restore UDP as default transport and improve probe failure guidance
8 files · +510 -39
M README.md +2 -0
| @@ -122,6 +122,8 @@ vev attach user@host:session | |||
| 122 | 122 | ||
| 123 | 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 | 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 | 129 | ## 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 color | 129 | ## 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) { | |||
| 153 | 153 | defer cancel() | |
| 154 | 154 | if err := probeUDPTransport(probeCtx, t); err != nil { | |
| 155 | 155 | _ = t.Close() | |
| 156 | - | return nil, udpUnavailable("probe UDP transport", err, &stderr) | |
| 156 | + | return nil, udpProbeUnreachable(d.Target, err, &stderr) | |
| 157 | 157 | } | |
| 158 | 158 | cleanup = false | |
| 159 | 159 | return t, nil | |
| @@ -235,9 +235,20 @@ func readUDPReady(r io.Reader) (udpReady, error) { | |||
| 235 | 235 | func udpUnavailable(action string, err error, stderr fmt.Stringer) error { | |
| 236 | 236 | msg := strings.TrimSpace(stderr.String()) | |
| 237 | 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 | 254 | 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 = false | 158 | cleanup = false | ||
| 159 | return t, nil | 159 | 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) { | |||
| 59 | 59 | waited bool | |
| 60 | 60 | 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 | 63 | {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true}, | |
| 64 | 64 | {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true}, | |
| 65 | 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 | 131 | d := NewRemoteDialer("127.0.0.1", "work") | |
| 132 | 132 | d.ProbeTimeout = time.Nanosecond | |
| 133 | 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 | 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 | 142 | if tr != nil { | |
| 138 | 143 | t.Fatalf("transport=%T, want nil", tr) | |
| 139 | 144 | } | |
| @@ -59,7 +59,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |||||
| 59 | waited bool | 59 | waited bool | ||
| 60 | wantStdoutClosed bool | 60 | 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.Nanosecond | 132 | 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 ( | |||
| 32 | 32 | ) | |
| 33 | 33 | ||
| 34 | 34 | type closeFunc func() error | |
| 35 | + | type eofErrFunc func() error | |
| 35 | 36 | ||
| 36 | 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 | - | 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 } | |
| 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 | 49 | 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 | |
| 48 | 54 | ||
| 49 | 55 | mu sync.Mutex | |
| 50 | 56 | readBuf []byte | |
| @@ -70,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error { | |||
| 70 | 76 | func (t *transport) Recv() (ports.Frame, error) { | |
| 71 | 77 | var hdr [frameHeaderLen]byte | |
| 72 | 78 | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | |
| 73 | - | return ports.Frame{}, err | |
| 79 | + | return ports.Frame{}, t.mapEOFError(err) | |
| 74 | 80 | } | |
| 75 | 81 | ||
| 76 | 82 | n := binary.BigEndian.Uint32(hdr[:]) | |
| @@ -87,46 +93,98 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 87 | 93 | t.readBuf = t.readBuf[:n] | |
| 88 | 94 | } | |
| 89 | 95 | if _, err := io.ReadFull(t.r, t.readBuf); err != nil { | |
| 90 | - | return ports.Frame{}, err | |
| 96 | + | return ports.Frame{}, t.mapEOFError(err) | |
| 91 | 97 | } | |
| 92 | 98 | ||
| 93 | 99 | payload := append([]byte(nil), t.readBuf[1:]...) | |
| 94 | 100 | 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 | 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 { | |
| 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 | + | } | |
| 102 | 127 | ||
| 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 | + | } | |
| 105 | 140 | ||
| 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() }() | |
| 107 | 157 | select { | |
| 108 | - | case err = <-waitErr: | |
| 158 | + | case w.waitErr = <-waitCh: | |
| 109 | 159 | 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 | |
| 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()) | |
| 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} | |
| 125 | 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 | 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 | 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)), nil | |
| 256 | + | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 257 | + | return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil | |
| 199 | 258 | } | |
| @@ -32,19 +32,25 @@ var ( | |||||
| 32 | ) | 32 | ) | ||
| 33 | 33 | ||||
| 34 | type closeFunc func() error | 34 | 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.Reader | 50 | + | r io.Reader |
| 46 | - | w io.Writer | 51 | + | w io.Writer |
| 47 | - | close closeFunc | 52 | + | close closeFunc |
| 53 | + | eofErr eofErrFunc | |||
| 48 | 54 | ||||
| 49 | mu sync.Mutex | 55 | mu sync.Mutex | ||
| 50 | readBuf []byte | 56 | 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]byte | 77 | 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{}, err | 79 | + | 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{}, err | 96 | + | 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}, nil | 100 | 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 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() }() | |||
| 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 = <-waitErr | 161 | + | 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)), nil | 256 | + | 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) { | |||
| 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 | 171 | func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) { | |
| 132 | 172 | cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7") | |
| 133 | 173 | 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 ( | |||
| 20 | 20 | "os/exec" | |
| 21 | 21 | "os/signal" | |
| 22 | 22 | "path/filepath" | |
| 23 | + | "strconv" | |
| 24 | + | "strings" | |
| 23 | 25 | "syscall" | |
| 24 | 26 | "text/tabwriter" | |
| 25 | 27 | "time" | |
| @@ -705,8 +707,11 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 705 | 707 | defer func() { _ = logCloser.Close() }() | |
| 706 | 708 | log.Debug("udp proxy starting", "session", session) | |
| 707 | 709 | ||
| 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) | |
| 710 | 715 | if err != nil { | |
| 711 | 716 | return err | |
| 712 | 717 | } | |
| @@ -741,6 +746,87 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 741 | 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 | 830 | type preHelloNameTransport struct { | |
| 745 | 831 | ports.Transport | |
| 746 | 832 | 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.ListenConfig | 710 | + | 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 err | 716 | 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.Transport | 831 | ports.Transport | ||
| 746 | session string | 832 | session string | ||
M internal/app/run_test.go +103 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "net" | |
| 9 | 10 | "os" | |
| 10 | 11 | "os/exec" | |
| 11 | 12 | "path/filepath" | |
| @@ -633,3 +634,105 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) { | |||
| 633 | 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 | + | } | |
| @@ -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" | |||