fix(remote): keep udp default, make probe failure actionable
The mitigation commit flipped the default remote transport to stdio, which hid the real UDP path instead of fixing it. Restore udp as the default and, when the UDP reachability probe fails, surface actionable guidance: a failed probe is almost always a remote firewall dropping datagrams to vev's ephemeral UDP proxy port, not a vev bug. Point at the two real fixes (open UDP on the remote, e.g. ufw allow in on tailscale0, or VEV_REMOTE_TRANSPORT=stdio) and drop the doubled 'vev:' prefix. Keeps the branch's useful sshstdio EOF->ssh-exit error surfacing and the debug-remote-attach.sh script.
- commit
- 5db946641e30ac6b142efbe16fc83b92ea081199
- author
- brice · · 2026-07-07 01:13 UTC
- parent
- e555c28
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +30 -13
M README.md +4 -2
| @@ -120,9 +120,11 @@ vev attach user@host | |||
| 120 | 120 | vev attach user@host:session | |
| 121 | 121 | ``` | |
| 122 | 122 | ||
| 123 | - | Remote attach uses SSH stdio by default, so it works anywhere normal SSH works, including jump-host or SSH-config-only routes. `vev` must be installed on the remote host. Omitting `:session` opens an ephemeral remote session. | |
| 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 | - | Set `VEV_REMOTE_TRANSPORT=udp` to try the UDP transport: SSH starts a UDP proxy on the remote host, then the session talks directly to that host over UDP. UDP mode requires direct UDP reachability to the remote host and can detect some network failures more promptly than SSH stdio. | |
| 125 | + | The UDP proxy binds an ephemeral high port on the remote, 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 on the remote to fix it — for example, on a trusted Tailscale network, `sudo ufw allow in on tailscale0`. | |
| 126 | + | ||
| 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 | |
| 128 | 130 | ||
| @@ -120,9 +120,11 @@ vev attach user@host | |||||
| 120 | vev attach user@host:session | 120 | vev attach user@host:session | ||
| 121 | ``` | 121 | ``` | ||
| 122 | 122 | ||||
| 123 | - | Remote attach uses SSH stdio by default, so it works anywhere normal SSH works, including jump-host or SSH-config-only routes. `vev` must be installed on the remote host. Omitting `:session` opens an ephemeral remote session. | 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 | - | Set `VEV_REMOTE_TRANSPORT=udp` to try the UDP transport: SSH starts a UDP proxy on the remote host, then the session talks directly to that host over UDP. UDP mode requires direct UDP reachability to the remote host and can detect some network failures more promptly than SSH stdio. | 125 | + | The UDP proxy binds an ephemeral high port on the remote, 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 on the remote to fix it — for example, on a trusted Tailscale network, `sudo ufw allow in on tailscale0`. |
| 126 | + | ||||
| 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 | ||
| 128 | 130 | ||||
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/app/run.go +3 -3
| @@ -374,10 +374,10 @@ type runAttachDeps struct { | |||
| 374 | 374 | ||
| 375 | 375 | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | |
| 376 | 376 | switch value { | |
| 377 | - | case "", string(ports.RemoteTransportStdio): | |
| 378 | - | return ports.RemoteTransportStdio, nil | |
| 379 | - | case string(ports.RemoteTransportUDP): | |
| 377 | + | case "", string(ports.RemoteTransportUDP): | |
| 380 | 378 | return ports.RemoteTransportUDP, nil | |
| 379 | + | case string(ports.RemoteTransportStdio): | |
| 380 | + | return ports.RemoteTransportStdio, nil | |
| 381 | 381 | default: | |
| 382 | 382 | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio) | |
| 383 | 383 | } | |
| @@ -374,10 +374,10 @@ type runAttachDeps struct { | |||||
| 374 | 374 | ||||
| 375 | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | 375 | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | ||
| 376 | switch value { | 376 | switch value { | ||
| 377 | - | case "", string(ports.RemoteTransportStdio): | 377 | + | case "", string(ports.RemoteTransportUDP): |
| 378 | - | return ports.RemoteTransportStdio, nil | |||
| 379 | - | case string(ports.RemoteTransportUDP): | |||
| 380 | return ports.RemoteTransportUDP, nil | 378 | return ports.RemoteTransportUDP, nil | ||
| 379 | + | case string(ports.RemoteTransportStdio): | |||
| 380 | + | return ports.RemoteTransportStdio, nil | |||
| 381 | default: | 381 | default: | ||
| 382 | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio) | 382 | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio) | ||
| 383 | } | 383 | } | ||
M internal/app/run_test.go +2 -3
| @@ -448,8 +448,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||
| 448 | 448 | selectedTransport string | |
| 449 | 449 | wantMode ports.RemoteTransportMode | |
| 450 | 450 | }{ | |
| 451 | - | {name: "default remote mode is stdio", selectedTransport: "", wantMode: ports.RemoteTransportStdio}, | |
| 452 | - | {name: "explicit udp mode", selectedTransport: "udp", wantMode: ports.RemoteTransportUDP}, | |
| 451 | + | {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP}, | |
| 453 | 452 | {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio}, | |
| 454 | 453 | } | |
| 455 | 454 | ||
| @@ -519,7 +518,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing. | |||
| 519 | 518 | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |
| 520 | 519 | factoryErr := errors.New("factory failed") | |
| 521 | 520 | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 522 | - | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(nil, factoryErr) | |
| 521 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr) | |
| 523 | 522 | runClientCalled := false | |
| 524 | 523 | ||
| 525 | 524 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| @@ -448,8 +448,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||||
| 448 | selectedTransport string | 448 | selectedTransport string | ||
| 449 | wantMode ports.RemoteTransportMode | 449 | wantMode ports.RemoteTransportMode | ||
| 450 | }{ | 450 | }{ | ||
| 451 | - | {name: "default remote mode is stdio", selectedTransport: "", wantMode: ports.RemoteTransportStdio}, | 451 | + | {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP}, |
| 452 | - | {name: "explicit udp mode", selectedTransport: "udp", wantMode: ports.RemoteTransportUDP}, | |||
| 453 | {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio}, | 452 | {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio}, | ||
| 454 | } | 453 | } | ||
| 455 | 454 | ||||
| @@ -519,7 +518,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing. | |||||
| 519 | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | 518 | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | ||
| 520 | factoryErr := errors.New("factory failed") | 519 | factoryErr := errors.New("factory failed") | ||
| 521 | factory := portsmocks.NewMockRemoteDialerFactory(t) | 520 | factory := portsmocks.NewMockRemoteDialerFactory(t) | ||
| 522 | - | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(nil, factoryErr) | 521 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr) |
| 523 | runClientCalled := false | 522 | runClientCalled := false | ||
| 524 | 523 | ||||
| 525 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | 524 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | ||