Merge pull request #33 from bnema/feat/udp-remote-reconnect
feat(remote): add UDP-first reconnect transport
13 files · +1041 -84
M README.md +2 -9
| @@ -120,16 +120,9 @@ vev attach user@host | |||
| 120 | 120 | vev attach user@host:session | |
| 121 | 121 | ``` | |
| 122 | 122 | ||
| 123 | - | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so `vev` must be installed on the remote host and reachable through your normal SSH config. 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 | - | For faster reconnects, enable OpenSSH connection reuse: | |
| 126 | - | ||
| 127 | - | ```sshconfig | |
| 128 | - | Host * | |
| 129 | - | ControlMaster auto | |
| 130 | - | ControlPath ~/.ssh/cm-%r@%h:%p | |
| 131 | - | ControlPersist 10m | |
| 132 | - | ``` | |
| 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. | |
| 133 | 126 | ||
| 134 | 127 | ## Terminal color | |
| 135 | 128 | ||
| @@ -120,16 +120,9 @@ vev attach user@host | |||||
| 120 | vev attach user@host:session | 120 | vev attach user@host:session | ||
| 121 | ``` | 121 | ``` | ||
| 122 | 122 | ||||
| 123 | - | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so `vev` must be installed on the remote host and reachable through your normal SSH config. 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 | - | For faster reconnects, enable OpenSSH connection reuse: | 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. |
| 126 | - | ||||
| 127 | - | ```sshconfig | |||
| 128 | - | Host * | |||
| 129 | - | ControlMaster auto | |||
| 130 | - | ControlPath ~/.ssh/cm-%r@%h:%p | |||
| 131 | - | ControlPersist 10m | |||
| 132 | - | ``` | |||
| 133 | 126 | ||||
| 134 | ## Terminal color | 127 | ## Terminal color | ||
| 135 | 128 | ||||
M internal/adapters/dgram/ssh_dialer.go +198 -14
| @@ -1,19 +1,55 @@ | |||
| 1 | 1 | package dgram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bufio" | |
| 4 | 5 | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 5 | 9 | "log/slog" | |
| 6 | 10 | "net" | |
| 11 | + | "os/exec" | |
| 12 | + | "strconv" | |
| 7 | 13 | "strings" | |
| 8 | 14 | "time" | |
| 9 | 15 | ||
| 10 | 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 11 | 17 | "github.com/bnema/vev/internal/ports" | |
| 18 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 12 | 19 | ) | |
| 13 | 20 | ||
| 14 | 21 | const ( | |
| 15 | - | maxBootstrapStderr = 64 * 1024 | |
| 16 | - | defaultProbeTimeout = 3 * time.Second | |
| 22 | + | maxBootstrapStderr = 64 * 1024 | |
| 23 | + | defaultBootstrapTimeout = 10 * time.Second | |
| 24 | + | defaultProbeTimeout = 3 * time.Second | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | type bootstrapProcess interface { | |
| 28 | + | StdoutPipe() (io.ReadCloser, error) | |
| 29 | + | Start() error | |
| 30 | + | Kill() error | |
| 31 | + | Wait() error | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type execBootstrapProcess struct{ cmd *exec.Cmd } | |
| 35 | + | ||
| 36 | + | func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() } | |
| 37 | + | func (p execBootstrapProcess) Start() error { return p.cmd.Start() } | |
| 38 | + | func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() } | |
| 39 | + | func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() } | |
| 40 | + | ||
| 41 | + | var ( | |
| 42 | + | startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess { | |
| 43 | + | spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session) | |
| 44 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 45 | + | cmd.Stderr = stderr | |
| 46 | + | return execBootstrapProcess{cmd: cmd} | |
| 47 | + | } | |
| 48 | + | listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) { | |
| 49 | + | var lc net.ListenConfig | |
| 50 | + | return lc.ListenPacket(ctx, "udp", ":0") | |
| 51 | + | } | |
| 52 | + | probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) } | |
| 17 | 53 | ) | |
| 18 | 54 | ||
| 19 | 55 | type limitedBuffer struct { | |
| @@ -30,13 +66,14 @@ func (b *limitedBuffer) Write(p []byte) (int, error) { | |||
| 30 | 66 | ||
| 31 | 67 | func (b *limitedBuffer) String() string { return string(b.buf) } | |
| 32 | 68 | ||
| 33 | - | // RemoteDialer tries the authenticated datagram bootstrap first and falls back | |
| 34 | - | // to ssh stdio whenever bootstrap, UDP resolution, or the UDP probe fails. | |
| 69 | + | // RemoteDialer connects to the authenticated datagram bootstrap for UDP remote | |
| 70 | + | // attach. SSH stdio is selected only through the explicit stdio transport mode. | |
| 35 | 71 | type RemoteDialer struct { | |
| 36 | - | Target string | |
| 37 | - | Session string | |
| 38 | - | ProbeTimeout time.Duration | |
| 39 | - | Log *slog.Logger | |
| 72 | + | Target string | |
| 73 | + | Session string | |
| 74 | + | BootstrapTimeout time.Duration | |
| 75 | + | ProbeTimeout time.Duration | |
| 76 | + | Log *slog.Logger | |
| 40 | 77 | } | |
| 41 | 78 | ||
| 42 | 79 | func NewRemoteDialer(target, session string) RemoteDialer { | |
| @@ -48,15 +85,159 @@ func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteD | |||
| 48 | 85 | } | |
| 49 | 86 | ||
| 50 | 87 | func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 51 | - | // The UDP bootstrap currently requires a long-lived remote proxy process. Do | |
| 52 | - | // not select it as the app's remote attach path until that process can detach | |
| 53 | - | // from SSH safely; stdio is the bounded fallback path. | |
| 88 | + | if err := ctx.Err(); err != nil { | |
| 89 | + | return nil, err | |
| 90 | + | } | |
| 91 | + | bootstrapTimeout := d.BootstrapTimeout | |
| 92 | + | if bootstrapTimeout <= 0 { | |
| 93 | + | bootstrapTimeout = defaultBootstrapTimeout | |
| 94 | + | } | |
| 95 | + | probeTimeout := d.ProbeTimeout | |
| 96 | + | if probeTimeout <= 0 { | |
| 97 | + | probeTimeout = defaultProbeTimeout | |
| 98 | + | } | |
| 99 | + | bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout) | |
| 100 | + | defer bootstrapCancel() | |
| 101 | + | ||
| 102 | + | var stderr limitedBuffer | |
| 103 | + | proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr) | |
| 104 | + | stdout, err := proc.StdoutPipe() | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, udpUnavailable("bootstrap stdout", err, &stderr) | |
| 107 | + | } | |
| 108 | + | if err := proc.Start(); err != nil { | |
| 109 | + | _ = stdout.Close() | |
| 110 | + | return nil, udpUnavailable("start bootstrap", err, &stderr) | |
| 111 | + | } | |
| 112 | + | cleanup := true | |
| 113 | + | waited := false | |
| 114 | + | defer func() { | |
| 115 | + | if cleanup { | |
| 116 | + | _ = proc.Kill() | |
| 117 | + | } | |
| 118 | + | if !waited { | |
| 119 | + | _ = proc.Wait() | |
| 120 | + | } | |
| 121 | + | _ = stdout.Close() | |
| 122 | + | }() | |
| 123 | + | ||
| 124 | + | ready, err := readUDPReadyContext(bootstrapCtx, stdout) | |
| 125 | + | if err != nil { | |
| 126 | + | _ = proc.Kill() | |
| 127 | + | _ = proc.Wait() | |
| 128 | + | waited = true | |
| 129 | + | cleanup = false | |
| 130 | + | return nil, udpUnavailable("read bootstrap readiness", err, &stderr) | |
| 131 | + | } | |
| 132 | + | if err := waitBootstrapContext(bootstrapCtx, proc); err != nil { | |
| 133 | + | waited = true | |
| 134 | + | cleanup = false | |
| 135 | + | return nil, udpUnavailable("wait bootstrap", err, &stderr) | |
| 136 | + | } | |
| 137 | + | waited = true | |
| 138 | + | cleanup = false | |
| 139 | + | peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port) | |
| 140 | + | if err != nil { | |
| 141 | + | return nil, udpUnavailable("resolve UDP peer", err, &stderr) | |
| 142 | + | } | |
| 143 | + | pc, err := listenUDPPacket(ctx) | |
| 144 | + | if err != nil { | |
| 145 | + | return nil, udpUnavailable("listen UDP", err, &stderr) | |
| 146 | + | } | |
| 147 | + | t, err := NewTransport(pc, peer, ready.key, 1, 2) | |
| 148 | + | if err != nil { | |
| 149 | + | _ = pc.Close() | |
| 150 | + | return nil, udpUnavailable("create UDP transport", err, &stderr) | |
| 151 | + | } | |
| 152 | + | probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) | |
| 153 | + | defer cancel() | |
| 154 | + | if err := probeUDPTransport(probeCtx, t); err != nil { | |
| 155 | + | _ = t.Close() | |
| 156 | + | return nil, udpUnavailable("probe UDP transport", err, &stderr) | |
| 157 | + | } | |
| 158 | + | cleanup = false | |
| 159 | + | return t, nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) { | |
| 163 | + | type result struct { | |
| 164 | + | ready udpReady | |
| 165 | + | err error | |
| 166 | + | } | |
| 167 | + | ch := make(chan result, 1) | |
| 168 | + | go func() { | |
| 169 | + | ready, err := readUDPReady(r) | |
| 170 | + | ch <- result{ready: ready, err: err} | |
| 171 | + | }() | |
| 54 | 172 | select { | |
| 173 | + | case res := <-ch: | |
| 174 | + | return res.ready, res.err | |
| 55 | 175 | case <-ctx.Done(): | |
| 56 | - | return nil, ctx.Err() | |
| 57 | - | default: | |
| 176 | + | return udpReady{}, ctx.Err() | |
| 58 | 177 | } | |
| 59 | - | return sshstdio.DialContext(ctx, d.Target, d.Session, d.Log) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error { | |
| 181 | + | ch := make(chan error, 1) | |
| 182 | + | go func() { ch <- proc.Wait() }() | |
| 183 | + | select { | |
| 184 | + | case err := <-ch: | |
| 185 | + | return err | |
| 186 | + | case <-ctx.Done(): | |
| 187 | + | _ = proc.Kill() | |
| 188 | + | return ctx.Err() | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) { | |
| 193 | + | host := sshTargetHost(target) | |
| 194 | + | if ip := net.ParseIP(host); ip != nil { | |
| 195 | + | return &net.UDPAddr{IP: ip, Port: port}, nil | |
| 196 | + | } | |
| 197 | + | addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) | |
| 198 | + | if err != nil { | |
| 199 | + | return nil, err | |
| 200 | + | } | |
| 201 | + | if len(addrs) == 0 { | |
| 202 | + | return nil, fmt.Errorf("no UDP peer addresses for %q", host) | |
| 203 | + | } | |
| 204 | + | return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | type udpReady struct { | |
| 208 | + | port int | |
| 209 | + | key []byte | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func readUDPReady(r io.Reader) (udpReady, error) { | |
| 213 | + | line, err := bufio.NewReader(r).ReadString('\n') | |
| 214 | + | if err != nil && line == "" { | |
| 215 | + | return udpReady{}, err | |
| 216 | + | } | |
| 217 | + | fields := strings.Fields(line) | |
| 218 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |
| 219 | + | return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line)) | |
| 220 | + | } | |
| 221 | + | port, err := strconv.Atoi(fields[1]) | |
| 222 | + | if err != nil || port < 1 || port > 65535 { | |
| 223 | + | return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1]) | |
| 224 | + | } | |
| 225 | + | key, err := base64.StdEncoding.DecodeString(fields[2]) | |
| 226 | + | if err != nil { | |
| 227 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | |
| 228 | + | } | |
| 229 | + | if len(key) != pdgram.KeySize { | |
| 230 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key)) | |
| 231 | + | } | |
| 232 | + | return udpReady{port: port, key: key}, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func udpUnavailable(action string, err error, stderr fmt.Stringer) error { | |
| 236 | + | msg := strings.TrimSpace(stderr.String()) | |
| 237 | + | if msg != "" { | |
| 238 | + | return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg) | |
| 239 | + | } | |
| 240 | + | return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err) | |
| 60 | 241 | } | |
| 61 | 242 | ||
| 62 | 243 | func sshTargetHost(target string) string { | |
| @@ -66,5 +247,8 @@ func sshTargetHost(target string) string { | |||
| 66 | 247 | if h, _, err := net.SplitHostPort(target); err == nil { | |
| 67 | 248 | return h | |
| 68 | 249 | } | |
| 250 | + | if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { | |
| 251 | + | return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[") | |
| 252 | + | } | |
| 69 | 253 | return target | |
| 70 | 254 | } | |
| @@ -1,19 +1,55 @@ | |||||
| 1 | package dgram | 1 | package dgram | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bufio" | |||
| 4 | "context" | 5 | "context" | ||
| 6 | + | "encoding/base64" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 5 | "log/slog" | 9 | "log/slog" | ||
| 6 | "net" | 10 | "net" | ||
| 11 | + | "os/exec" | |||
| 12 | + | "strconv" | |||
| 7 | "strings" | 13 | "strings" | ||
| 8 | "time" | 14 | "time" | ||
| 9 | 15 | ||||
| 10 | "github.com/bnema/vev/internal/adapters/sshstdio" | 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 18 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |||
| 12 | ) | 19 | ) | ||
| 13 | 20 | ||||
| 14 | const ( | 21 | const ( | ||
| 15 | - | maxBootstrapStderr = 64 * 1024 | 22 | + | maxBootstrapStderr = 64 * 1024 |
| 16 | - | defaultProbeTimeout = 3 * time.Second | 23 | + | defaultBootstrapTimeout = 10 * time.Second |
| 24 | + | defaultProbeTimeout = 3 * time.Second | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | type bootstrapProcess interface { | |||
| 28 | + | StdoutPipe() (io.ReadCloser, error) | |||
| 29 | + | Start() error | |||
| 30 | + | Kill() error | |||
| 31 | + | Wait() error | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type execBootstrapProcess struct{ cmd *exec.Cmd } | |||
| 35 | + | ||||
| 36 | + | func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() } | |||
| 37 | + | func (p execBootstrapProcess) Start() error { return p.cmd.Start() } | |||
| 38 | + | func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() } | |||
| 39 | + | func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() } | |||
| 40 | + | ||||
| 41 | + | var ( | |||
| 42 | + | startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess { | |||
| 43 | + | spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session) | |||
| 44 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |||
| 45 | + | cmd.Stderr = stderr | |||
| 46 | + | return execBootstrapProcess{cmd: cmd} | |||
| 47 | + | } | |||
| 48 | + | listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) { | |||
| 49 | + | var lc net.ListenConfig | |||
| 50 | + | return lc.ListenPacket(ctx, "udp", ":0") | |||
| 51 | + | } | |||
| 52 | + | probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) } | |||
| 17 | ) | 53 | ) | ||
| 18 | 54 | ||||
| 19 | type limitedBuffer struct { | 55 | type limitedBuffer struct { | ||
| @@ -30,13 +66,14 @@ func (b *limitedBuffer) Write(p []byte) (int, error) { | |||||
| 30 | 66 | ||||
| 31 | func (b *limitedBuffer) String() string { return string(b.buf) } | 67 | func (b *limitedBuffer) String() string { return string(b.buf) } | ||
| 32 | 68 | ||||
| 33 | - | // RemoteDialer tries the authenticated datagram bootstrap first and falls back | 69 | + | // RemoteDialer connects to the authenticated datagram bootstrap for UDP remote |
| 34 | - | // to ssh stdio whenever bootstrap, UDP resolution, or the UDP probe fails. | 70 | + | // attach. SSH stdio is selected only through the explicit stdio transport mode. |
| 35 | type RemoteDialer struct { | 71 | type RemoteDialer struct { | ||
| 36 | - | Target string | 72 | + | Target string |
| 37 | - | Session string | 73 | + | Session string |
| 38 | - | ProbeTimeout time.Duration | 74 | + | BootstrapTimeout time.Duration |
| 39 | - | Log *slog.Logger | 75 | + | ProbeTimeout time.Duration |
| 76 | + | Log *slog.Logger | |||
| 40 | } | 77 | } | ||
| 41 | 78 | ||||
| 42 | func NewRemoteDialer(target, session string) RemoteDialer { | 79 | func NewRemoteDialer(target, session string) RemoteDialer { | ||
| @@ -48,15 +85,159 @@ func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteD | |||||
| 48 | } | 85 | } | ||
| 49 | 86 | ||||
| 50 | func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | 87 | func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | ||
| 51 | - | // The UDP bootstrap currently requires a long-lived remote proxy process. Do | 88 | + | if err := ctx.Err(); err != nil { |
| 52 | - | // not select it as the app's remote attach path until that process can detach | 89 | + | return nil, err |
| 53 | - | // from SSH safely; stdio is the bounded fallback path. | 90 | + | } |
| 91 | + | bootstrapTimeout := d.BootstrapTimeout | |||
| 92 | + | if bootstrapTimeout <= 0 { | |||
| 93 | + | bootstrapTimeout = defaultBootstrapTimeout | |||
| 94 | + | } | |||
| 95 | + | probeTimeout := d.ProbeTimeout | |||
| 96 | + | if probeTimeout <= 0 { | |||
| 97 | + | probeTimeout = defaultProbeTimeout | |||
| 98 | + | } | |||
| 99 | + | bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout) | |||
| 100 | + | defer bootstrapCancel() | |||
| 101 | + | ||||
| 102 | + | var stderr limitedBuffer | |||
| 103 | + | proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr) | |||
| 104 | + | stdout, err := proc.StdoutPipe() | |||
| 105 | + | if err != nil { | |||
| 106 | + | return nil, udpUnavailable("bootstrap stdout", err, &stderr) | |||
| 107 | + | } | |||
| 108 | + | if err := proc.Start(); err != nil { | |||
| 109 | + | _ = stdout.Close() | |||
| 110 | + | return nil, udpUnavailable("start bootstrap", err, &stderr) | |||
| 111 | + | } | |||
| 112 | + | cleanup := true | |||
| 113 | + | waited := false | |||
| 114 | + | defer func() { | |||
| 115 | + | if cleanup { | |||
| 116 | + | _ = proc.Kill() | |||
| 117 | + | } | |||
| 118 | + | if !waited { | |||
| 119 | + | _ = proc.Wait() | |||
| 120 | + | } | |||
| 121 | + | _ = stdout.Close() | |||
| 122 | + | }() | |||
| 123 | + | ||||
| 124 | + | ready, err := readUDPReadyContext(bootstrapCtx, stdout) | |||
| 125 | + | if err != nil { | |||
| 126 | + | _ = proc.Kill() | |||
| 127 | + | _ = proc.Wait() | |||
| 128 | + | waited = true | |||
| 129 | + | cleanup = false | |||
| 130 | + | return nil, udpUnavailable("read bootstrap readiness", err, &stderr) | |||
| 131 | + | } | |||
| 132 | + | if err := waitBootstrapContext(bootstrapCtx, proc); err != nil { | |||
| 133 | + | waited = true | |||
| 134 | + | cleanup = false | |||
| 135 | + | return nil, udpUnavailable("wait bootstrap", err, &stderr) | |||
| 136 | + | } | |||
| 137 | + | waited = true | |||
| 138 | + | cleanup = false | |||
| 139 | + | peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return nil, udpUnavailable("resolve UDP peer", err, &stderr) | |||
| 142 | + | } | |||
| 143 | + | pc, err := listenUDPPacket(ctx) | |||
| 144 | + | if err != nil { | |||
| 145 | + | return nil, udpUnavailable("listen UDP", err, &stderr) | |||
| 146 | + | } | |||
| 147 | + | t, err := NewTransport(pc, peer, ready.key, 1, 2) | |||
| 148 | + | if err != nil { | |||
| 149 | + | _ = pc.Close() | |||
| 150 | + | return nil, udpUnavailable("create UDP transport", err, &stderr) | |||
| 151 | + | } | |||
| 152 | + | probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) | |||
| 153 | + | defer cancel() | |||
| 154 | + | if err := probeUDPTransport(probeCtx, t); err != nil { | |||
| 155 | + | _ = t.Close() | |||
| 156 | + | return nil, udpUnavailable("probe UDP transport", err, &stderr) | |||
| 157 | + | } | |||
| 158 | + | cleanup = false | |||
| 159 | + | return t, nil | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) { | |||
| 163 | + | type result struct { | |||
| 164 | + | ready udpReady | |||
| 165 | + | err error | |||
| 166 | + | } | |||
| 167 | + | ch := make(chan result, 1) | |||
| 168 | + | go func() { | |||
| 169 | + | ready, err := readUDPReady(r) | |||
| 170 | + | ch <- result{ready: ready, err: err} | |||
| 171 | + | }() | |||
| 54 | select { | 172 | select { | ||
| 173 | + | case res := <-ch: | |||
| 174 | + | return res.ready, res.err | |||
| 55 | case <-ctx.Done(): | 175 | case <-ctx.Done(): | ||
| 56 | - | return nil, ctx.Err() | 176 | + | return udpReady{}, ctx.Err() |
| 57 | - | default: | |||
| 58 | } | 177 | } | ||
| 59 | - | return sshstdio.DialContext(ctx, d.Target, d.Session, d.Log) | 178 | + | } |
| 179 | + | ||||
| 180 | + | func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error { | |||
| 181 | + | ch := make(chan error, 1) | |||
| 182 | + | go func() { ch <- proc.Wait() }() | |||
| 183 | + | select { | |||
| 184 | + | case err := <-ch: | |||
| 185 | + | return err | |||
| 186 | + | case <-ctx.Done(): | |||
| 187 | + | _ = proc.Kill() | |||
| 188 | + | return ctx.Err() | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) { | |||
| 193 | + | host := sshTargetHost(target) | |||
| 194 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 195 | + | return &net.UDPAddr{IP: ip, Port: port}, nil | |||
| 196 | + | } | |||
| 197 | + | addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return nil, err | |||
| 200 | + | } | |||
| 201 | + | if len(addrs) == 0 { | |||
| 202 | + | return nil, fmt.Errorf("no UDP peer addresses for %q", host) | |||
| 203 | + | } | |||
| 204 | + | return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | type udpReady struct { | |||
| 208 | + | port int | |||
| 209 | + | key []byte | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func readUDPReady(r io.Reader) (udpReady, error) { | |||
| 213 | + | line, err := bufio.NewReader(r).ReadString('\n') | |||
| 214 | + | if err != nil && line == "" { | |||
| 215 | + | return udpReady{}, err | |||
| 216 | + | } | |||
| 217 | + | fields := strings.Fields(line) | |||
| 218 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |||
| 219 | + | return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line)) | |||
| 220 | + | } | |||
| 221 | + | port, err := strconv.Atoi(fields[1]) | |||
| 222 | + | if err != nil || port < 1 || port > 65535 { | |||
| 223 | + | return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1]) | |||
| 224 | + | } | |||
| 225 | + | key, err := base64.StdEncoding.DecodeString(fields[2]) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | |||
| 228 | + | } | |||
| 229 | + | if len(key) != pdgram.KeySize { | |||
| 230 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key)) | |||
| 231 | + | } | |||
| 232 | + | return udpReady{port: port, key: key}, nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func udpUnavailable(action string, err error, stderr fmt.Stringer) error { | |||
| 236 | + | msg := strings.TrimSpace(stderr.String()) | |||
| 237 | + | if msg != "" { | |||
| 238 | + | return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg) | |||
| 239 | + | } | |||
| 240 | + | return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err) | |||
| 60 | } | 241 | } | ||
| 61 | 242 | ||||
| 62 | func sshTargetHost(target string) string { | 243 | func sshTargetHost(target string) string { | ||
| @@ -66,5 +247,8 @@ func sshTargetHost(target string) string { | |||||
| 66 | if h, _, err := net.SplitHostPort(target); err == nil { | 247 | if h, _, err := net.SplitHostPort(target); err == nil { | ||
| 67 | return h | 248 | return h | ||
| 68 | } | 249 | } | ||
| 250 | + | if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { | |||
| 251 | + | return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[") | |||
| 252 | + | } | |||
| 69 | return target | 253 | return target | ||
| 70 | } | 254 | } | ||
M internal/adapters/dgram/ssh_dialer_test.go +181 -0
| @@ -1,10 +1,191 @@ | |||
| 1 | 1 | package dgram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 4 | 9 | "strings" | |
| 5 | 10 | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 6 | 14 | ) | |
| 7 | 15 | ||
| 16 | + | type fakeBootstrapProcess struct { | |
| 17 | + | stdout io.ReadCloser | |
| 18 | + | startErr error | |
| 19 | + | waitErr error | |
| 20 | + | killed bool | |
| 21 | + | waited bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type closeTrackReadCloser struct { | |
| 25 | + | io.Reader | |
| 26 | + | closed *bool | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil } | |
| 30 | + | ||
| 31 | + | func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil } | |
| 32 | + | func (p *fakeBootstrapProcess) Start() error { return p.startErr } | |
| 33 | + | func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil } | |
| 34 | + | func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr } | |
| 35 | + | ||
| 36 | + | func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) { | |
| 37 | + | t.Helper() | |
| 38 | + | old := startUDPBootstrap | |
| 39 | + | startUDPBootstrap = fn | |
| 40 | + | t.Cleanup(func() { startUDPBootstrap = old }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) { | |
| 44 | + | t.Helper() | |
| 45 | + | old := listenUDPPacket | |
| 46 | + | listenUDPPacket = fn | |
| 47 | + | t.Cleanup(func() { listenUDPPacket = old }) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |
| 51 | + | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |
| 52 | + | tests := []struct { | |
| 53 | + | name string | |
| 54 | + | stdout string | |
| 55 | + | start error | |
| 56 | + | listen error | |
| 57 | + | want string | |
| 58 | + | killed bool | |
| 59 | + | waited bool | |
| 60 | + | wantStdoutClosed bool | |
| 61 | + | }{ | |
| 62 | + | {name: "start failure", start: errors.New("ssh missing"), want: "vev: 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}, | |
| 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}, | |
| 66 | + | } | |
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | stdoutClosed := false | |
| 70 | + | proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start} | |
| 71 | + | if tt.name == "bootstrap wait failure" { | |
| 72 | + | proc.waitErr = errors.New("exit status 255") | |
| 73 | + | } | |
| 74 | + | withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc }) | |
| 75 | + | if tt.listen != nil { | |
| 76 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen }) | |
| 77 | + | } | |
| 78 | + | tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background()) | |
| 79 | + | if err == nil || !strings.Contains(err.Error(), tt.want) { | |
| 80 | + | t.Fatalf("Dial() error = %v, want containing %q", err, tt.want) | |
| 81 | + | } | |
| 82 | + | if tr != nil { | |
| 83 | + | t.Fatalf("Dial() transport = %T, want nil", tr) | |
| 84 | + | } | |
| 85 | + | if proc.killed != tt.killed { | |
| 86 | + | t.Fatalf("killed=%v, want %v", proc.killed, tt.killed) | |
| 87 | + | } | |
| 88 | + | if proc.waited != tt.waited { | |
| 89 | + | t.Fatalf("waited=%v, want %v", proc.waited, tt.waited) | |
| 90 | + | } | |
| 91 | + | if stdoutClosed != tt.wantStdoutClosed { | |
| 92 | + | t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed) | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) { | |
| 99 | + | pc, err := listenUDPPacket(context.Background()) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | defer func() { _ = pc.Close() }() | |
| 104 | + | addr, ok := pc.LocalAddr().(*net.UDPAddr) | |
| 105 | + | if !ok { | |
| 106 | + | t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr()) | |
| 107 | + | } | |
| 108 | + | if !addr.IP.IsUnspecified() { | |
| 109 | + | t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) { | |
| 114 | + | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |
| 115 | + | proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))} | |
| 116 | + | withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess { | |
| 117 | + | if target != "127.0.0.1" || session != "work" { | |
| 118 | + | t.Fatalf("bootstrap target/session = %q/%q", target, session) | |
| 119 | + | } | |
| 120 | + | return proc | |
| 121 | + | }) | |
| 122 | + | var lc net.ListenConfig | |
| 123 | + | pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | closed := false | |
| 128 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { | |
| 129 | + | return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil | |
| 130 | + | }) | |
| 131 | + | d := NewRemoteDialer("127.0.0.1", "work") | |
| 132 | + | d.ProbeTimeout = time.Nanosecond | |
| 133 | + | tr, err := d.Dial(context.Background()) | |
| 134 | + | if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") { | |
| 135 | + | t.Fatalf("Dial() error = %v, want probe unavailable", err) | |
| 136 | + | } | |
| 137 | + | if tr != nil { | |
| 138 | + | t.Fatalf("transport=%T, want nil", tr) | |
| 139 | + | } | |
| 140 | + | if !closed { | |
| 141 | + | t.Fatal("packet conn not closed after probe failure") | |
| 142 | + | } | |
| 143 | + | if proc.killed { | |
| 144 | + | t.Fatal("bootstrap process killed after it was already waited") | |
| 145 | + | } | |
| 146 | + | if !proc.waited { | |
| 147 | + | t.Fatal("bootstrap process not waited after probe failure") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | type closeTrackPacketConn struct { | |
| 152 | + | net.PacketConn | |
| 153 | + | closed *bool | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() } | |
| 157 | + | ||
| 158 | + | func TestReadUDPReady(t *testing.T) { | |
| 159 | + | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |
| 160 | + | tests := []struct { | |
| 161 | + | name, line string | |
| 162 | + | wantPort int | |
| 163 | + | wantErr string | |
| 164 | + | }{ | |
| 165 | + | {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234}, | |
| 166 | + | {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"}, | |
| 167 | + | {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"}, | |
| 168 | + | {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"}, | |
| 169 | + | } | |
| 170 | + | for _, tt := range tests { | |
| 171 | + | t.Run(tt.name, func(t *testing.T) { | |
| 172 | + | got, err := readUDPReady(strings.NewReader(tt.line)) | |
| 173 | + | if tt.wantErr != "" { | |
| 174 | + | if err == nil || !strings.Contains(err.Error(), tt.wantErr) { | |
| 175 | + | t.Fatalf("err=%v, want %q", err, tt.wantErr) | |
| 176 | + | } | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | if err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | if got.port != tt.wantPort || len(got.key) != pdgram.KeySize { | |
| 183 | + | t.Fatalf("ready=%+v keylen=%d", got, len(got.key)) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 8 | 189 | func TestLimitedBufferCapsBootstrapStderr(t *testing.T) { | |
| 9 | 190 | var b limitedBuffer | |
| 10 | 191 | chunk := strings.Repeat("x", maxBootstrapStderr+1024) | |
| @@ -1,10 +1,191 @@ | |||||
| 1 | package dgram | 1 | package dgram | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 4 | "strings" | 9 | "strings" | ||
| 5 | "testing" | 10 | "testing" | ||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |||
| 6 | ) | 14 | ) | ||
| 7 | 15 | ||||
| 16 | + | type fakeBootstrapProcess struct { | |||
| 17 | + | stdout io.ReadCloser | |||
| 18 | + | startErr error | |||
| 19 | + | waitErr error | |||
| 20 | + | killed bool | |||
| 21 | + | waited bool | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type closeTrackReadCloser struct { | |||
| 25 | + | io.Reader | |||
| 26 | + | closed *bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil } | |||
| 30 | + | ||||
| 31 | + | func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil } | |||
| 32 | + | func (p *fakeBootstrapProcess) Start() error { return p.startErr } | |||
| 33 | + | func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil } | |||
| 34 | + | func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr } | |||
| 35 | + | ||||
| 36 | + | func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) { | |||
| 37 | + | t.Helper() | |||
| 38 | + | old := startUDPBootstrap | |||
| 39 | + | startUDPBootstrap = fn | |||
| 40 | + | t.Cleanup(func() { startUDPBootstrap = old }) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) { | |||
| 44 | + | t.Helper() | |||
| 45 | + | old := listenUDPPacket | |||
| 46 | + | listenUDPPacket = fn | |||
| 47 | + | t.Cleanup(func() { listenUDPPacket = old }) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |||
| 51 | + | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |||
| 52 | + | tests := []struct { | |||
| 53 | + | name string | |||
| 54 | + | stdout string | |||
| 55 | + | start error | |||
| 56 | + | listen error | |||
| 57 | + | want string | |||
| 58 | + | killed bool | |||
| 59 | + | waited bool | |||
| 60 | + | wantStdoutClosed bool | |||
| 61 | + | }{ | |||
| 62 | + | {name: "start failure", start: errors.New("ssh missing"), want: "vev: 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}, | |||
| 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}, | |||
| 66 | + | } | |||
| 67 | + | for _, tt := range tests { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | stdoutClosed := false | |||
| 70 | + | proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start} | |||
| 71 | + | if tt.name == "bootstrap wait failure" { | |||
| 72 | + | proc.waitErr = errors.New("exit status 255") | |||
| 73 | + | } | |||
| 74 | + | withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc }) | |||
| 75 | + | if tt.listen != nil { | |||
| 76 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen }) | |||
| 77 | + | } | |||
| 78 | + | tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background()) | |||
| 79 | + | if err == nil || !strings.Contains(err.Error(), tt.want) { | |||
| 80 | + | t.Fatalf("Dial() error = %v, want containing %q", err, tt.want) | |||
| 81 | + | } | |||
| 82 | + | if tr != nil { | |||
| 83 | + | t.Fatalf("Dial() transport = %T, want nil", tr) | |||
| 84 | + | } | |||
| 85 | + | if proc.killed != tt.killed { | |||
| 86 | + | t.Fatalf("killed=%v, want %v", proc.killed, tt.killed) | |||
| 87 | + | } | |||
| 88 | + | if proc.waited != tt.waited { | |||
| 89 | + | t.Fatalf("waited=%v, want %v", proc.waited, tt.waited) | |||
| 90 | + | } | |||
| 91 | + | if stdoutClosed != tt.wantStdoutClosed { | |||
| 92 | + | t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed) | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) { | |||
| 99 | + | pc, err := listenUDPPacket(context.Background()) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | defer func() { _ = pc.Close() }() | |||
| 104 | + | addr, ok := pc.LocalAddr().(*net.UDPAddr) | |||
| 105 | + | if !ok { | |||
| 106 | + | t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr()) | |||
| 107 | + | } | |||
| 108 | + | if !addr.IP.IsUnspecified() { | |||
| 109 | + | t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) { | |||
| 114 | + | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |||
| 115 | + | proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))} | |||
| 116 | + | withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess { | |||
| 117 | + | if target != "127.0.0.1" || session != "work" { | |||
| 118 | + | t.Fatalf("bootstrap target/session = %q/%q", target, session) | |||
| 119 | + | } | |||
| 120 | + | return proc | |||
| 121 | + | }) | |||
| 122 | + | var lc net.ListenConfig | |||
| 123 | + | pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | closed := false | |||
| 128 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { | |||
| 129 | + | return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil | |||
| 130 | + | }) | |||
| 131 | + | d := NewRemoteDialer("127.0.0.1", "work") | |||
| 132 | + | d.ProbeTimeout = time.Nanosecond | |||
| 133 | + | tr, err := d.Dial(context.Background()) | |||
| 134 | + | if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") { | |||
| 135 | + | t.Fatalf("Dial() error = %v, want probe unavailable", err) | |||
| 136 | + | } | |||
| 137 | + | if tr != nil { | |||
| 138 | + | t.Fatalf("transport=%T, want nil", tr) | |||
| 139 | + | } | |||
| 140 | + | if !closed { | |||
| 141 | + | t.Fatal("packet conn not closed after probe failure") | |||
| 142 | + | } | |||
| 143 | + | if proc.killed { | |||
| 144 | + | t.Fatal("bootstrap process killed after it was already waited") | |||
| 145 | + | } | |||
| 146 | + | if !proc.waited { | |||
| 147 | + | t.Fatal("bootstrap process not waited after probe failure") | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | type closeTrackPacketConn struct { | |||
| 152 | + | net.PacketConn | |||
| 153 | + | closed *bool | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() } | |||
| 157 | + | ||||
| 158 | + | func TestReadUDPReady(t *testing.T) { | |||
| 159 | + | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |||
| 160 | + | tests := []struct { | |||
| 161 | + | name, line string | |||
| 162 | + | wantPort int | |||
| 163 | + | wantErr string | |||
| 164 | + | }{ | |||
| 165 | + | {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234}, | |||
| 166 | + | {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"}, | |||
| 167 | + | {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"}, | |||
| 168 | + | {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"}, | |||
| 169 | + | } | |||
| 170 | + | for _, tt := range tests { | |||
| 171 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 172 | + | got, err := readUDPReady(strings.NewReader(tt.line)) | |||
| 173 | + | if tt.wantErr != "" { | |||
| 174 | + | if err == nil || !strings.Contains(err.Error(), tt.wantErr) { | |||
| 175 | + | t.Fatalf("err=%v, want %q", err, tt.wantErr) | |||
| 176 | + | } | |||
| 177 | + | return | |||
| 178 | + | } | |||
| 179 | + | if err != nil { | |||
| 180 | + | t.Fatal(err) | |||
| 181 | + | } | |||
| 182 | + | if got.port != tt.wantPort || len(got.key) != pdgram.KeySize { | |||
| 183 | + | t.Fatalf("ready=%+v keylen=%d", got, len(got.key)) | |||
| 184 | + | } | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 8 | func TestLimitedBufferCapsBootstrapStderr(t *testing.T) { | 189 | func TestLimitedBufferCapsBootstrapStderr(t *testing.T) { | ||
| 9 | var b limitedBuffer | 190 | var b limitedBuffer | ||
| 10 | chunk := strings.Repeat("x", maxBootstrapStderr+1024) | 191 | chunk := strings.Repeat("x", maxBootstrapStderr+1024) | ||
M internal/adapters/dgram/transport.go +1 -1
| @@ -127,7 +127,7 @@ func (t *Transport) Close() error { | |||
| 127 | 127 | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | |
| 128 | 128 | ||
| 129 | 129 | // Probe sends an authenticated datagram and waits for the peer to authenticate a | |
| 130 | - | // response or for ctx to expire. It is intended for UDP bootstrap/fallback checks. | |
| 130 | + | // response or for ctx to expire. It is intended for UDP bootstrap reachability checks. | |
| 131 | 131 | func (t *Transport) Probe(ctx context.Context) error { | |
| 132 | 132 | id, ch, err := t.registerProbe() | |
| 133 | 133 | if err != nil { | |
| @@ -127,7 +127,7 @@ func (t *Transport) Close() error { | |||||
| 127 | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | 127 | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | ||
| 128 | 128 | ||||
| 129 | // Probe sends an authenticated datagram and waits for the peer to authenticate a | 129 | // Probe sends an authenticated datagram and waits for the peer to authenticate a | ||
| 130 | - | // response or for ctx to expire. It is intended for UDP bootstrap/fallback checks. | 130 | + | // response or for ctx to expire. It is intended for UDP bootstrap reachability checks. |
| 131 | func (t *Transport) Probe(ctx context.Context) error { | 131 | func (t *Transport) Probe(ctx context.Context) error { | ||
| 132 | id, ch, err := t.registerProbe() | 132 | id, ch, err := t.registerProbe() | ||
| 133 | if err != nil { | 133 | if err != nil { | ||
A internal/adapters/remote/remote_factory.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "log/slog" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/adapters/dgram" | |
| 9 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // DialerFactory selects the requested remote transport adapter. | |
| 14 | + | type DialerFactory struct{} | |
| 15 | + | ||
| 16 | + | var _ ports.RemoteDialerFactory = DialerFactory{} | |
| 17 | + | ||
| 18 | + | func NewDialerFactory() DialerFactory { return DialerFactory{} } | |
| 19 | + | ||
| 20 | + | func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) { | |
| 21 | + | switch mode { | |
| 22 | + | case ports.RemoteTransportUDP: | |
| 23 | + | if log != nil { | |
| 24 | + | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 25 | + | } | |
| 26 | + | return dgram.NewRemoteDialerWithLogger(target, session, log), nil | |
| 27 | + | case ports.RemoteTransportStdio: | |
| 28 | + | if log != nil { | |
| 29 | + | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 30 | + | } | |
| 31 | + | return stdioDialer{target: target, session: session, log: log}, nil | |
| 32 | + | default: | |
| 33 | + | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type stdioDialer struct { | |
| 38 | + | target string | |
| 39 | + | session string | |
| 40 | + | log *slog.Logger | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 44 | + | return sshstdio.DialContext(ctx, d.target, d.session, d.log) | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "log/slog" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/adapters/dgram" | |||
| 9 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // DialerFactory selects the requested remote transport adapter. | |||
| 14 | + | type DialerFactory struct{} | |||
| 15 | + | ||||
| 16 | + | var _ ports.RemoteDialerFactory = DialerFactory{} | |||
| 17 | + | ||||
| 18 | + | func NewDialerFactory() DialerFactory { return DialerFactory{} } | |||
| 19 | + | ||||
| 20 | + | func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) { | |||
| 21 | + | switch mode { | |||
| 22 | + | case ports.RemoteTransportUDP: | |||
| 23 | + | if log != nil { | |||
| 24 | + | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |||
| 25 | + | } | |||
| 26 | + | return dgram.NewRemoteDialerWithLogger(target, session, log), nil | |||
| 27 | + | case ports.RemoteTransportStdio: | |||
| 28 | + | if log != nil { | |||
| 29 | + | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |||
| 30 | + | } | |||
| 31 | + | return stdioDialer{target: target, session: session, log: log}, nil | |||
| 32 | + | default: | |||
| 33 | + | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type stdioDialer struct { | |||
| 38 | + | target string | |||
| 39 | + | session string | |||
| 40 | + | log *slog.Logger | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 44 | + | return sshstdio.DialContext(ctx, d.target, d.session, d.log) | |||
| 45 | + | } | |||
A internal/adapters/remote/remote_factory_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/adapters/dgram" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |
| 13 | + | factory := NewDialerFactory() | |
| 14 | + | ||
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | mode ports.RemoteTransportMode | |
| 18 | + | wantType any | |
| 19 | + | }{ | |
| 20 | + | {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}}, | |
| 21 | + | {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}}, | |
| 22 | + | } | |
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil) | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatalf("DialerForRemote() error = %v", err) | |
| 28 | + | } | |
| 29 | + | switch tt.wantType.(type) { | |
| 30 | + | case dgram.RemoteDialer: | |
| 31 | + | got, ok := dialer.(dgram.RemoteDialer) | |
| 32 | + | if !ok { | |
| 33 | + | t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{}) | |
| 34 | + | } | |
| 35 | + | if got.Target != "remote.example" || got.Session != "work" { | |
| 36 | + | t.Fatalf("dialer = %+v, want target/session copied", got) | |
| 37 | + | } | |
| 38 | + | case stdioDialer: | |
| 39 | + | got, ok := dialer.(stdioDialer) | |
| 40 | + | if !ok { | |
| 41 | + | t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{}) | |
| 42 | + | } | |
| 43 | + | if got.target != "remote.example" || got.session != "work" { | |
| 44 | + | t.Fatalf("dialer = %+v, want target/session copied", got) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) { | |
| 52 | + | factory := NewDialerFactory() | |
| 53 | + | ||
| 54 | + | dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil) | |
| 55 | + | if err == nil { | |
| 56 | + | t.Fatal("DialerForRemote() error = nil, want unsupported mode error") | |
| 57 | + | } | |
| 58 | + | if dialer != nil { | |
| 59 | + | t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer) | |
| 60 | + | } | |
| 61 | + | if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want { | |
| 62 | + | t.Fatalf("error = %q, want %q", got, want) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) { | |
| 67 | + | dialer := stdioDialer{target: "remote.example", session: "work"} | |
| 68 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 69 | + | cancel() | |
| 70 | + | ||
| 71 | + | transport, err := dialer.Dial(ctx) | |
| 72 | + | if !errors.Is(err, context.Canceled) { | |
| 73 | + | t.Fatalf("Dial() error = %v, want context canceled", err) | |
| 74 | + | } | |
| 75 | + | if transport != nil { | |
| 76 | + | t.Fatalf("Dial() transport = %T, want nil", transport) | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/adapters/dgram" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |||
| 13 | + | factory := NewDialerFactory() | |||
| 14 | + | ||||
| 15 | + | tests := []struct { | |||
| 16 | + | name string | |||
| 17 | + | mode ports.RemoteTransportMode | |||
| 18 | + | wantType any | |||
| 19 | + | }{ | |||
| 20 | + | {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}}, | |||
| 21 | + | {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}}, | |||
| 22 | + | } | |||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatalf("DialerForRemote() error = %v", err) | |||
| 28 | + | } | |||
| 29 | + | switch tt.wantType.(type) { | |||
| 30 | + | case dgram.RemoteDialer: | |||
| 31 | + | got, ok := dialer.(dgram.RemoteDialer) | |||
| 32 | + | if !ok { | |||
| 33 | + | t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{}) | |||
| 34 | + | } | |||
| 35 | + | if got.Target != "remote.example" || got.Session != "work" { | |||
| 36 | + | t.Fatalf("dialer = %+v, want target/session copied", got) | |||
| 37 | + | } | |||
| 38 | + | case stdioDialer: | |||
| 39 | + | got, ok := dialer.(stdioDialer) | |||
| 40 | + | if !ok { | |||
| 41 | + | t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{}) | |||
| 42 | + | } | |||
| 43 | + | if got.target != "remote.example" || got.session != "work" { | |||
| 44 | + | t.Fatalf("dialer = %+v, want target/session copied", got) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) { | |||
| 52 | + | factory := NewDialerFactory() | |||
| 53 | + | ||||
| 54 | + | dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil) | |||
| 55 | + | if err == nil { | |||
| 56 | + | t.Fatal("DialerForRemote() error = nil, want unsupported mode error") | |||
| 57 | + | } | |||
| 58 | + | if dialer != nil { | |||
| 59 | + | t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer) | |||
| 60 | + | } | |||
| 61 | + | if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want { | |||
| 62 | + | t.Fatalf("error = %q, want %q", got, want) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) { | |||
| 67 | + | dialer := stdioDialer{target: "remote.example", session: "work"} | |||
| 68 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 69 | + | cancel() | |||
| 70 | + | ||||
| 71 | + | transport, err := dialer.Dial(ctx) | |||
| 72 | + | if !errors.Is(err, context.Canceled) { | |||
| 73 | + | t.Fatalf("Dial() error = %v, want context canceled", err) | |||
| 74 | + | } | |||
| 75 | + | if transport != nil { | |||
| 76 | + | t.Fatalf("Dial() transport = %T, want nil", transport) | |||
| 77 | + | } | |||
| 78 | + | } | |||
M internal/app/remote.go +16 -0
| @@ -11,6 +11,22 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) { | |||
| 11 | 11 | return "", "", false | |
| 12 | 12 | } | |
| 13 | 13 | afterAt := s[at+1:] | |
| 14 | + | if strings.HasPrefix(afterAt, "[") { | |
| 15 | + | closeBracket := strings.IndexByte(afterAt, ']') | |
| 16 | + | if closeBracket < 0 { | |
| 17 | + | return "", "", false | |
| 18 | + | } | |
| 19 | + | suffix := afterAt[closeBracket+1:] | |
| 20 | + | switch { | |
| 21 | + | case suffix == "": | |
| 22 | + | return s, "", true | |
| 23 | + | case strings.HasPrefix(suffix, ":"): | |
| 24 | + | targetEnd := at + 1 + closeBracket + 1 | |
| 25 | + | return s[:targetEnd], suffix[1:], true | |
| 26 | + | default: | |
| 27 | + | return "", "", false | |
| 28 | + | } | |
| 29 | + | } | |
| 14 | 30 | colon := strings.LastIndexByte(afterAt, ':') | |
| 15 | 31 | if colon < 0 { | |
| 16 | 32 | return s, "", true | |
| @@ -11,6 +11,22 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) { | |||||
| 11 | return "", "", false | 11 | return "", "", false | ||
| 12 | } | 12 | } | ||
| 13 | afterAt := s[at+1:] | 13 | afterAt := s[at+1:] | ||
| 14 | + | if strings.HasPrefix(afterAt, "[") { | |||
| 15 | + | closeBracket := strings.IndexByte(afterAt, ']') | |||
| 16 | + | if closeBracket < 0 { | |||
| 17 | + | return "", "", false | |||
| 18 | + | } | |||
| 19 | + | suffix := afterAt[closeBracket+1:] | |||
| 20 | + | switch { | |||
| 21 | + | case suffix == "": | |||
| 22 | + | return s, "", true | |||
| 23 | + | case strings.HasPrefix(suffix, ":"): | |||
| 24 | + | targetEnd := at + 1 + closeBracket + 1 | |||
| 25 | + | return s[:targetEnd], suffix[1:], true | |||
| 26 | + | default: | |||
| 27 | + | return "", "", false | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 14 | colon := strings.LastIndexByte(afterAt, ':') | 30 | colon := strings.LastIndexByte(afterAt, ':') | ||
| 15 | if colon < 0 { | 31 | if colon < 0 { | ||
| 16 | return s, "", true | 32 | return s, "", true | ||
M internal/app/remote_test.go +4 -0
| @@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) { | |||
| 17 | 17 | {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true}, | |
| 18 | 18 | {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true}, | |
| 19 | 19 | {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true}, | |
| 20 | + | {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true}, | |
| 21 | + | {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true}, | |
| 22 | + | {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"}, | |
| 23 | + | {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"}, | |
| 20 | 24 | {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true}, | |
| 21 | 25 | } | |
| 22 | 26 | ||
| @@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) { | |||||
| 17 | {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true}, | 17 | {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true}, | ||
| 18 | {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true}, | 18 | {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true}, | ||
| 19 | {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true}, | 19 | {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true}, | ||
| 20 | + | {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true}, | |||
| 21 | + | {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true}, | |||
| 22 | + | {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"}, | |||
| 23 | + | {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"}, | |||
| 20 | {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true}, | 24 | {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true}, | ||
| 21 | } | 25 | } | ||
| 22 | 26 | ||||
M internal/app/run.go +134 -30
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | package app | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "bufio" | |
| 8 | 9 | "context" | |
| 9 | 10 | "crypto/rand" | |
| 10 | 11 | "encoding/base64" | |
| @@ -16,6 +17,7 @@ import ( | |||
| 16 | 17 | "net/http" | |
| 17 | 18 | _ "net/http/pprof" | |
| 18 | 19 | "os" | |
| 20 | + | "os/exec" | |
| 19 | 21 | "os/signal" | |
| 20 | 22 | "path/filepath" | |
| 21 | 23 | "syscall" | |
| @@ -28,6 +30,7 @@ import ( | |||
| 28 | 30 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 29 | 31 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 30 | 32 | "github.com/bnema/vev/internal/adapters/pty" | |
| 33 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 31 | 34 | "github.com/bnema/vev/internal/adapters/shellcmd" | |
| 32 | 35 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |
| 33 | 36 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| @@ -53,6 +56,7 @@ const ( | |||
| 53 | 56 | kindDaemon | |
| 54 | 57 | kindStdio | |
| 55 | 58 | kindUDPBootstrap | |
| 59 | + | kindUDPProxy | |
| 56 | 60 | kindHelp | |
| 57 | 61 | kindVersion | |
| 58 | 62 | ) | |
| @@ -136,6 +140,15 @@ func parseArgs(args []string) (command, error) { | |||
| 136 | 140 | cmd.name = args[1] | |
| 137 | 141 | } | |
| 138 | 142 | return cmd, nil | |
| 143 | + | case "_udp-proxy": | |
| 144 | + | if len(args) > 2 { | |
| 145 | + | return command{}, usagef("`_udp-proxy` accepts at most one session name") | |
| 146 | + | } | |
| 147 | + | cmd := command{kind: kindUDPProxy} | |
| 148 | + | if len(args) == 2 { | |
| 149 | + | cmd.name = args[1] | |
| 150 | + | } | |
| 151 | + | return cmd, nil | |
| 139 | 152 | case "new": | |
| 140 | 153 | if len(args) < 2 || args[1] == "" { | |
| 141 | 154 | return command{}, usagef("`new` requires a session name") | |
| @@ -210,6 +223,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 210 | 223 | return runStdio(ctx) | |
| 211 | 224 | case kindUDPBootstrap: | |
| 212 | 225 | return runUDPBootstrap(ctx, cmd.name) | |
| 226 | + | case kindUDPProxy: | |
| 227 | + | return runUDPProxy(ctx, cmd.name, os.Stdout) | |
| 213 | 228 | case kindList: | |
| 214 | 229 | return runList(ctx) | |
| 215 | 230 | case kindKill: | |
| @@ -326,26 +341,30 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 326 | 341 | defer func() { _ = logCloser.Close() }() | |
| 327 | 342 | ||
| 328 | 343 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | |
| 329 | - | localDialer: defaultLocalDialer, | |
| 330 | - | remoteDialer: defaultRemoteDialer, | |
| 331 | - | runClient: client.Run, | |
| 332 | - | createDetached: createDetachedLocalSession, | |
| 333 | - | clipboard: clipboard.New(), | |
| 344 | + | localDialer: defaultLocalDialer, | |
| 345 | + | remoteDialerFactory: defaultRemoteDialerFactory(), | |
| 346 | + | selectedRemoteTransport: os.Getenv(envRemoteTransport), | |
| 347 | + | runClient: client.Run, | |
| 348 | + | createDetached: createDetachedLocalSession, | |
| 349 | + | clipboard: clipboard.New(), | |
| 334 | 350 | }) | |
| 335 | 351 | } | |
| 336 | 352 | ||
| 353 | + | const envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |
| 354 | + | ||
| 337 | 355 | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | |
| 338 | 356 | ||
| 339 | - | func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer { | |
| 340 | - | return dgram.NewRemoteDialerWithLogger(target, session, log) | |
| 357 | + | func defaultRemoteDialerFactory() ports.RemoteDialerFactory { | |
| 358 | + | return remoteadapter.NewDialerFactory() | |
| 341 | 359 | } | |
| 342 | 360 | ||
| 343 | 361 | type runAttachDeps struct { | |
| 344 | - | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | |
| 345 | - | localDialer func() ports.Dialer | |
| 346 | - | remoteDialer func(target, session string, log *slog.Logger) ports.Dialer | |
| 347 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | |
| 348 | - | createDetached func(context.Context, string) error | |
| 362 | + | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | |
| 363 | + | localDialer func() ports.Dialer | |
| 364 | + | remoteDialerFactory ports.RemoteDialerFactory | |
| 365 | + | selectedRemoteTransport string | |
| 366 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | |
| 367 | + | createDetached func(context.Context, string) error | |
| 349 | 368 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |
| 350 | 369 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 351 | 370 | // Only used for the remote-dialer branch below; local attaches never | |
| @@ -353,6 +372,17 @@ type runAttachDeps struct { | |||
| 353 | 372 | clipboard ports.ClipboardReader | |
| 354 | 373 | } | |
| 355 | 374 | ||
| 375 | + | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | |
| 376 | + | switch value { | |
| 377 | + | case "", string(ports.RemoteTransportUDP): | |
| 378 | + | return ports.RemoteTransportUDP, nil | |
| 379 | + | case string(ports.RemoteTransportStdio): | |
| 380 | + | return ports.RemoteTransportStdio, nil | |
| 381 | + | default: | |
| 382 | + | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 356 | 386 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | |
| 357 | 387 | if activeSession != "" { | |
| 358 | 388 | if remoteTarget == "" && intent == ports.IntentNew { | |
| @@ -366,14 +396,22 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 366 | 396 | runClient = client.Run | |
| 367 | 397 | } | |
| 368 | 398 | if remoteTarget != "" { | |
| 369 | - | remoteDialer := deps.remoteDialer | |
| 370 | - | if remoteDialer == nil { | |
| 371 | - | remoteDialer = defaultRemoteDialer | |
| 399 | + | mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | |
| 400 | + | if err != nil { | |
| 401 | + | return err | |
| 402 | + | } | |
| 403 | + | factory := deps.remoteDialerFactory | |
| 404 | + | if factory == nil { | |
| 405 | + | factory = defaultRemoteDialerFactory() | |
| 372 | 406 | } | |
| 373 | 407 | if log != nil { | |
| 374 | - | log.Info("attaching to remote session", "target", remoteTarget, "name", name) | |
| 408 | + | log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode)) | |
| 375 | 409 | } | |
| 376 | - | return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log) | |
| 410 | + | dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log) | |
| 411 | + | if err != nil { | |
| 412 | + | return err | |
| 413 | + | } | |
| 414 | + | return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log) | |
| 377 | 415 | } | |
| 378 | 416 | ||
| 379 | 417 | attachLocal := deps.attachLocal | |
| @@ -584,24 +622,91 @@ func runStdio(ctx context.Context) error { | |||
| 584 | 622 | return proxyTransports(ctx, stdio, transport, log) | |
| 585 | 623 | } | |
| 586 | 624 | ||
| 587 | - | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | |
| 588 | - | // authenticated datagrams. It deliberately prints only an ephemeral UDP port and | |
| 589 | - | // random session key to stdout so callers can fall back to _stdio if setup fails. | |
| 625 | + | var ( | |
| 626 | + | udpBootstrapTimeout = 5 * time.Second | |
| 627 | + | udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd { | |
| 628 | + | return exec.CommandContext(ctx, exe, args...) | |
| 629 | + | } | |
| 630 | + | ) | |
| 631 | + | ||
| 632 | + | // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness | |
| 633 | + | // line, and exits so SSH stdio can close without owning the UDP proxy lifetime. | |
| 590 | 634 | func runUDPBootstrap(ctx context.Context, session string) error { | |
| 591 | - | log, logCloser, err := configureLogging(logging.Stdio, false) | |
| 635 | + | r, w, err := os.Pipe() | |
| 592 | 636 | if err != nil { | |
| 593 | 637 | return err | |
| 594 | 638 | } | |
| 595 | - | defer func() { _ = logCloser.Close() }() | |
| 596 | - | log.Debug("udp bootstrap starting", "session", session) | |
| 639 | + | defer func() { _ = r.Close() }() | |
| 640 | + | ||
| 641 | + | exe, err := os.Executable() | |
| 642 | + | if err != nil { | |
| 643 | + | _ = w.Close() | |
| 644 | + | return err | |
| 645 | + | } | |
| 646 | + | args := []string{"_udp-proxy"} | |
| 647 | + | if session != "" { | |
| 648 | + | args = append(args, session) | |
| 649 | + | } | |
| 650 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 651 | + | if err != nil { | |
| 652 | + | _ = w.Close() | |
| 653 | + | return err | |
| 654 | + | } | |
| 655 | + | defer func() { _ = devNull.Close() }() | |
| 656 | + | ||
| 657 | + | cmd := udpProxyCommand(ctx, exe, args...) | |
| 658 | + | // _udp-proxy writes diagnostics through configureLogging(logging.Stdio, false); | |
| 659 | + | // stdio is detached here so the bootstrap SSH channel can close. | |
| 660 | + | cmd.Stdin = devNull | |
| 661 | + | cmd.Stdout = w | |
| 662 | + | cmd.Stderr = devNull | |
| 663 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 664 | + | if err := cmd.Start(); err != nil { | |
| 665 | + | _ = w.Close() | |
| 666 | + | return err | |
| 667 | + | } | |
| 668 | + | _ = w.Close() | |
| 597 | 669 | ||
| 670 | + | readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout) | |
| 671 | + | defer cancel() | |
| 672 | + | lineCh := make(chan string, 1) | |
| 673 | + | errCh := make(chan error, 1) | |
| 674 | + | go func() { | |
| 675 | + | line, err := bufio.NewReader(r).ReadString('\n') | |
| 676 | + | if err != nil { | |
| 677 | + | errCh <- err | |
| 678 | + | return | |
| 679 | + | } | |
| 680 | + | lineCh <- line | |
| 681 | + | }() | |
| 598 | 682 | select { | |
| 599 | - | case <-ctx.Done(): | |
| 600 | - | return ctx.Err() | |
| 601 | - | default: | |
| 683 | + | case line := <-lineCh: | |
| 684 | + | if err := cmd.Process.Release(); err != nil { | |
| 685 | + | return err | |
| 686 | + | } | |
| 687 | + | _, err := fmt.Fprint(os.Stdout, line) | |
| 688 | + | return err | |
| 689 | + | case err := <-errCh: | |
| 690 | + | _ = cmd.Wait() | |
| 691 | + | return err | |
| 692 | + | case <-readyCtx.Done(): | |
| 693 | + | _ = cmd.Process.Kill() | |
| 694 | + | _ = cmd.Wait() | |
| 695 | + | return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err()) | |
| 602 | 696 | } | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // runUDPProxy is the detached long-lived remote-side datagram proxy. | |
| 700 | + | func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |
| 701 | + | log, logCloser, err := configureLogging(logging.Stdio, false) | |
| 702 | + | if err != nil { | |
| 703 | + | return err | |
| 704 | + | } | |
| 705 | + | defer func() { _ = logCloser.Close() }() | |
| 706 | + | log.Debug("udp proxy starting", "session", session) | |
| 707 | + | ||
| 603 | 708 | var lc net.ListenConfig | |
| 604 | - | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | |
| 709 | + | conn, err := lc.ListenPacket(ctx, "udp", ":0") | |
| 605 | 710 | if err != nil { | |
| 606 | 711 | return err | |
| 607 | 712 | } | |
| @@ -623,12 +728,11 @@ func runUDPBootstrap(ctx context.Context, session string) error { | |||
| 623 | 728 | ||
| 624 | 729 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | |
| 625 | 730 | if !ok { | |
| 626 | - | return errors.New("vev: udp bootstrap did not get a UDP address") | |
| 731 | + | return errors.New("vev: udp proxy did not get a UDP address") | |
| 627 | 732 | } | |
| 628 | - | if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | |
| 733 | + | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | |
| 629 | 734 | return err | |
| 630 | 735 | } | |
| 631 | - | _ = os.Stdout.Sync() | |
| 632 | 736 | ||
| 633 | 737 | var dgramTr ports.Transport = dg | |
| 634 | 738 | if session != "" { | |
| @@ -5,6 +5,7 @@ | |||||
| 5 | package app | 5 | package app | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | + | "bufio" | |||
| 8 | "context" | 9 | "context" | ||
| 9 | "crypto/rand" | 10 | "crypto/rand" | ||
| 10 | "encoding/base64" | 11 | "encoding/base64" | ||
| @@ -16,6 +17,7 @@ import ( | |||||
| 16 | "net/http" | 17 | "net/http" | ||
| 17 | _ "net/http/pprof" | 18 | _ "net/http/pprof" | ||
| 18 | "os" | 19 | "os" | ||
| 20 | + | "os/exec" | |||
| 19 | "os/signal" | 21 | "os/signal" | ||
| 20 | "path/filepath" | 22 | "path/filepath" | ||
| 21 | "syscall" | 23 | "syscall" | ||
| @@ -28,6 +30,7 @@ import ( | |||||
| 28 | "github.com/bnema/vev/internal/adapters/dgram" | 30 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 29 | "github.com/bnema/vev/internal/adapters/ipc" | 31 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 30 | "github.com/bnema/vev/internal/adapters/pty" | 32 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 33 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |||
| 31 | "github.com/bnema/vev/internal/adapters/shellcmd" | 34 | "github.com/bnema/vev/internal/adapters/shellcmd" | ||
| 32 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | 35 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | ||
| 33 | "github.com/bnema/vev/internal/adapters/sshstdio" | 36 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| @@ -53,6 +56,7 @@ const ( | |||||
| 53 | kindDaemon | 56 | kindDaemon | ||
| 54 | kindStdio | 57 | kindStdio | ||
| 55 | kindUDPBootstrap | 58 | kindUDPBootstrap | ||
| 59 | + | kindUDPProxy | |||
| 56 | kindHelp | 60 | kindHelp | ||
| 57 | kindVersion | 61 | kindVersion | ||
| 58 | ) | 62 | ) | ||
| @@ -136,6 +140,15 @@ func parseArgs(args []string) (command, error) { | |||||
| 136 | cmd.name = args[1] | 140 | cmd.name = args[1] | ||
| 137 | } | 141 | } | ||
| 138 | return cmd, nil | 142 | return cmd, nil | ||
| 143 | + | case "_udp-proxy": | |||
| 144 | + | if len(args) > 2 { | |||
| 145 | + | return command{}, usagef("`_udp-proxy` accepts at most one session name") | |||
| 146 | + | } | |||
| 147 | + | cmd := command{kind: kindUDPProxy} | |||
| 148 | + | if len(args) == 2 { | |||
| 149 | + | cmd.name = args[1] | |||
| 150 | + | } | |||
| 151 | + | return cmd, nil | |||
| 139 | case "new": | 152 | case "new": | ||
| 140 | if len(args) < 2 || args[1] == "" { | 153 | if len(args) < 2 || args[1] == "" { | ||
| 141 | return command{}, usagef("`new` requires a session name") | 154 | return command{}, usagef("`new` requires a session name") | ||
| @@ -210,6 +223,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 210 | return runStdio(ctx) | 223 | return runStdio(ctx) | ||
| 211 | case kindUDPBootstrap: | 224 | case kindUDPBootstrap: | ||
| 212 | return runUDPBootstrap(ctx, cmd.name) | 225 | return runUDPBootstrap(ctx, cmd.name) | ||
| 226 | + | case kindUDPProxy: | |||
| 227 | + | return runUDPProxy(ctx, cmd.name, os.Stdout) | |||
| 213 | case kindList: | 228 | case kindList: | ||
| 214 | return runList(ctx) | 229 | return runList(ctx) | ||
| 215 | case kindKill: | 230 | case kindKill: | ||
| @@ -326,26 +341,30 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 326 | defer func() { _ = logCloser.Close() }() | 341 | defer func() { _ = logCloser.Close() }() | ||
| 327 | 342 | ||||
| 328 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | 343 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | ||
| 329 | - | localDialer: defaultLocalDialer, | 344 | + | localDialer: defaultLocalDialer, |
| 330 | - | remoteDialer: defaultRemoteDialer, | 345 | + | remoteDialerFactory: defaultRemoteDialerFactory(), |
| 331 | - | runClient: client.Run, | 346 | + | selectedRemoteTransport: os.Getenv(envRemoteTransport), |
| 332 | - | createDetached: createDetachedLocalSession, | 347 | + | runClient: client.Run, |
| 333 | - | clipboard: clipboard.New(), | 348 | + | createDetached: createDetachedLocalSession, |
| 349 | + | clipboard: clipboard.New(), | |||
| 334 | }) | 350 | }) | ||
| 335 | } | 351 | } | ||
| 336 | 352 | ||||
| 353 | + | const envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |||
| 354 | + | ||||
| 337 | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | 355 | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | ||
| 338 | 356 | ||||
| 339 | - | func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer { | 357 | + | func defaultRemoteDialerFactory() ports.RemoteDialerFactory { |
| 340 | - | return dgram.NewRemoteDialerWithLogger(target, session, log) | 358 | + | return remoteadapter.NewDialerFactory() |
| 341 | } | 359 | } | ||
| 342 | 360 | ||||
| 343 | type runAttachDeps struct { | 361 | type runAttachDeps struct { | ||
| 344 | - | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | 362 | + | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests |
| 345 | - | localDialer func() ports.Dialer | 363 | + | localDialer func() ports.Dialer |
| 346 | - | remoteDialer func(target, session string, log *slog.Logger) ports.Dialer | 364 | + | remoteDialerFactory ports.RemoteDialerFactory |
| 347 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | 365 | + | selectedRemoteTransport string |
| 348 | - | createDetached func(context.Context, string) error | 366 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error |
| 367 | + | createDetached func(context.Context, string) error | |||
| 349 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | 368 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | ||
| 350 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | 369 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | ||
| 351 | // Only used for the remote-dialer branch below; local attaches never | 370 | // Only used for the remote-dialer branch below; local attaches never | ||
| @@ -353,6 +372,17 @@ type runAttachDeps struct { | |||||
| 353 | clipboard ports.ClipboardReader | 372 | clipboard ports.ClipboardReader | ||
| 354 | } | 373 | } | ||
| 355 | 374 | ||||
| 375 | + | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | |||
| 376 | + | switch value { | |||
| 377 | + | case "", string(ports.RemoteTransportUDP): | |||
| 378 | + | return ports.RemoteTransportUDP, nil | |||
| 379 | + | case string(ports.RemoteTransportStdio): | |||
| 380 | + | return ports.RemoteTransportStdio, nil | |||
| 381 | + | default: | |||
| 382 | + | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 356 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | 386 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | ||
| 357 | if activeSession != "" { | 387 | if activeSession != "" { | ||
| 358 | if remoteTarget == "" && intent == ports.IntentNew { | 388 | if remoteTarget == "" && intent == ports.IntentNew { | ||
| @@ -366,14 +396,22 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 366 | runClient = client.Run | 396 | runClient = client.Run | ||
| 367 | } | 397 | } | ||
| 368 | if remoteTarget != "" { | 398 | if remoteTarget != "" { | ||
| 369 | - | remoteDialer := deps.remoteDialer | 399 | + | mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport) |
| 370 | - | if remoteDialer == nil { | 400 | + | if err != nil { |
| 371 | - | remoteDialer = defaultRemoteDialer | 401 | + | return err |
| 402 | + | } | |||
| 403 | + | factory := deps.remoteDialerFactory | |||
| 404 | + | if factory == nil { | |||
| 405 | + | factory = defaultRemoteDialerFactory() | |||
| 372 | } | 406 | } | ||
| 373 | if log != nil { | 407 | if log != nil { | ||
| 374 | - | log.Info("attaching to remote session", "target", remoteTarget, "name", name) | 408 | + | log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode)) |
| 375 | } | 409 | } | ||
| 376 | - | return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log) | 410 | + | dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log) |
| 411 | + | if err != nil { | |||
| 412 | + | return err | |||
| 413 | + | } | |||
| 414 | + | return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log) | |||
| 377 | } | 415 | } | ||
| 378 | 416 | ||||
| 379 | attachLocal := deps.attachLocal | 417 | attachLocal := deps.attachLocal | ||
| @@ -584,24 +622,91 @@ func runStdio(ctx context.Context) error { | |||||
| 584 | return proxyTransports(ctx, stdio, transport, log) | 622 | return proxyTransports(ctx, stdio, transport, log) | ||
| 585 | } | 623 | } | ||
| 586 | 624 | ||||
| 587 | - | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | 625 | + | var ( |
| 588 | - | // authenticated datagrams. It deliberately prints only an ephemeral UDP port and | 626 | + | udpBootstrapTimeout = 5 * time.Second |
| 589 | - | // random session key to stdout so callers can fall back to _stdio if setup fails. | 627 | + | udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd { |
| 628 | + | return exec.CommandContext(ctx, exe, args...) | |||
| 629 | + | } | |||
| 630 | + | ) | |||
| 631 | + | ||||
| 632 | + | // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness | |||
| 633 | + | // line, and exits so SSH stdio can close without owning the UDP proxy lifetime. | |||
| 590 | func runUDPBootstrap(ctx context.Context, session string) error { | 634 | func runUDPBootstrap(ctx context.Context, session string) error { | ||
| 591 | - | log, logCloser, err := configureLogging(logging.Stdio, false) | 635 | + | r, w, err := os.Pipe() |
| 592 | if err != nil { | 636 | if err != nil { | ||
| 593 | return err | 637 | return err | ||
| 594 | } | 638 | } | ||
| 595 | - | defer func() { _ = logCloser.Close() }() | 639 | + | defer func() { _ = r.Close() }() |
| 596 | - | log.Debug("udp bootstrap starting", "session", session) | 640 | + | |
| 641 | + | exe, err := os.Executable() | |||
| 642 | + | if err != nil { | |||
| 643 | + | _ = w.Close() | |||
| 644 | + | return err | |||
| 645 | + | } | |||
| 646 | + | args := []string{"_udp-proxy"} | |||
| 647 | + | if session != "" { | |||
| 648 | + | args = append(args, session) | |||
| 649 | + | } | |||
| 650 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |||
| 651 | + | if err != nil { | |||
| 652 | + | _ = w.Close() | |||
| 653 | + | return err | |||
| 654 | + | } | |||
| 655 | + | defer func() { _ = devNull.Close() }() | |||
| 656 | + | ||||
| 657 | + | cmd := udpProxyCommand(ctx, exe, args...) | |||
| 658 | + | // _udp-proxy writes diagnostics through configureLogging(logging.Stdio, false); | |||
| 659 | + | // stdio is detached here so the bootstrap SSH channel can close. | |||
| 660 | + | cmd.Stdin = devNull | |||
| 661 | + | cmd.Stdout = w | |||
| 662 | + | cmd.Stderr = devNull | |||
| 663 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |||
| 664 | + | if err := cmd.Start(); err != nil { | |||
| 665 | + | _ = w.Close() | |||
| 666 | + | return err | |||
| 667 | + | } | |||
| 668 | + | _ = w.Close() | |||
| 597 | 669 | ||||
| 670 | + | readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout) | |||
| 671 | + | defer cancel() | |||
| 672 | + | lineCh := make(chan string, 1) | |||
| 673 | + | errCh := make(chan error, 1) | |||
| 674 | + | go func() { | |||
| 675 | + | line, err := bufio.NewReader(r).ReadString('\n') | |||
| 676 | + | if err != nil { | |||
| 677 | + | errCh <- err | |||
| 678 | + | return | |||
| 679 | + | } | |||
| 680 | + | lineCh <- line | |||
| 681 | + | }() | |||
| 598 | select { | 682 | select { | ||
| 599 | - | case <-ctx.Done(): | 683 | + | case line := <-lineCh: |
| 600 | - | return ctx.Err() | 684 | + | if err := cmd.Process.Release(); err != nil { |
| 601 | - | default: | 685 | + | return err |
| 686 | + | } | |||
| 687 | + | _, err := fmt.Fprint(os.Stdout, line) | |||
| 688 | + | return err | |||
| 689 | + | case err := <-errCh: | |||
| 690 | + | _ = cmd.Wait() | |||
| 691 | + | return err | |||
| 692 | + | case <-readyCtx.Done(): | |||
| 693 | + | _ = cmd.Process.Kill() | |||
| 694 | + | _ = cmd.Wait() | |||
| 695 | + | return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err()) | |||
| 602 | } | 696 | } | ||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | // runUDPProxy is the detached long-lived remote-side datagram proxy. | |||
| 700 | + | func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 701 | + | log, logCloser, err := configureLogging(logging.Stdio, false) | |||
| 702 | + | if err != nil { | |||
| 703 | + | return err | |||
| 704 | + | } | |||
| 705 | + | defer func() { _ = logCloser.Close() }() | |||
| 706 | + | log.Debug("udp proxy starting", "session", session) | |||
| 707 | + | ||||
| 603 | var lc net.ListenConfig | 708 | var lc net.ListenConfig | ||
| 604 | - | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | 709 | + | conn, err := lc.ListenPacket(ctx, "udp", ":0") |
| 605 | if err != nil { | 710 | if err != nil { | ||
| 606 | return err | 711 | return err | ||
| 607 | } | 712 | } | ||
| @@ -623,12 +728,11 @@ func runUDPBootstrap(ctx context.Context, session string) error { | |||||
| 623 | 728 | ||||
| 624 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | 729 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | ||
| 625 | if !ok { | 730 | if !ok { | ||
| 626 | - | return errors.New("vev: udp bootstrap did not get a UDP address") | 731 | + | return errors.New("vev: udp proxy did not get a UDP address") |
| 627 | } | 732 | } | ||
| 628 | - | if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | 733 | + | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { |
| 629 | return err | 734 | return err | ||
| 630 | } | 735 | } | ||
| 631 | - | _ = os.Stdout.Sync() | |||
| 632 | 736 | ||||
| 633 | var dgramTr ports.Transport = dg | 737 | var dgramTr ports.Transport = dg | ||
| 634 | if session != "" { | 738 | if session != "" { | ||
M internal/app/run_test.go +160 -30
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | 9 | "os" | |
| 10 | + | "os/exec" | |
| 10 | 11 | "path/filepath" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| @@ -16,8 +17,10 @@ import ( | |||
| 16 | 17 | "github.com/bnema/vev/internal/domain" | |
| 17 | 18 | "github.com/bnema/vev/internal/persist" | |
| 18 | 19 | "github.com/bnema/vev/internal/ports" | |
| 20 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 21 | "github.com/bnema/vev/internal/usecase/client" | |
| 20 | 22 | "github.com/bnema/vev/internal/usecase/confirm" | |
| 23 | + | "github.com/stretchr/testify/mock" | |
| 21 | 24 | "github.com/stretchr/testify/require" | |
| 22 | 25 | ) | |
| 23 | 26 | ||
| @@ -64,6 +67,9 @@ func TestParseArgs(t *testing.T) { | |||
| 64 | 67 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | |
| 65 | 68 | {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"}, | |
| 66 | 69 | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | |
| 70 | + | {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"}, | |
| 71 | + | {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"}, | |
| 72 | + | {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true}, | |
| 67 | 73 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |
| 68 | 74 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |
| 69 | 75 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | |
| @@ -436,40 +442,97 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 436 | 442 | // never actually invoked in these wiring tests. | |
| 437 | 443 | type fakeClipboardReader struct{ ports.ClipboardReader } | |
| 438 | 444 | ||
| 439 | - | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |
| 440 | - | // The local client still owns terminal capability detection for remote attach; | |
| 441 | - | // sshstdio is only a framed transport to `vev _stdio` on the remote host. | |
| 442 | - | var gotTarget, gotSession, gotDialer string | |
| 443 | - | var gotRemote bool | |
| 444 | - | var gotClipboard ports.ClipboardReader | |
| 445 | - | clip := &fakeClipboardReader{} | |
| 446 | - | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 447 | - | remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer { | |
| 448 | - | gotTarget, gotSession = target, session | |
| 449 | - | return namedDialer{name: "remote"} | |
| 450 | - | }, | |
| 451 | - | clipboard: clip, | |
| 452 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |
| 453 | - | gotDialer = d.(namedDialer).name | |
| 454 | - | gotRemote = remote | |
| 455 | - | gotClipboard = clipboard | |
| 456 | - | if intent != ports.IntentAttach || name != "work" { | |
| 457 | - | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |
| 445 | + | func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |
| 446 | + | tests := []struct { | |
| 447 | + | name string | |
| 448 | + | selectedTransport string | |
| 449 | + | wantMode ports.RemoteTransportMode | |
| 450 | + | }{ | |
| 451 | + | {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP}, | |
| 452 | + | {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio}, | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | for _, tt := range tests { | |
| 456 | + | t.Run(tt.name, func(t *testing.T) { | |
| 457 | + | var gotDialer string | |
| 458 | + | var gotRemote bool | |
| 459 | + | var gotClipboard ports.ClipboardReader | |
| 460 | + | clip := &fakeClipboardReader{} | |
| 461 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 462 | + | factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 463 | + | ||
| 464 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 465 | + | remoteDialerFactory: factory, | |
| 466 | + | selectedRemoteTransport: tt.selectedTransport, | |
| 467 | + | clipboard: clip, | |
| 468 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |
| 469 | + | nd, ok := d.(namedDialer) | |
| 470 | + | if !ok { | |
| 471 | + | t.Fatalf("dialer type = %T, want namedDialer", d) | |
| 472 | + | } | |
| 473 | + | gotDialer = nd.name | |
| 474 | + | gotRemote = remote | |
| 475 | + | gotClipboard = clipboard | |
| 476 | + | if intent != ports.IntentAttach || name != "work" { | |
| 477 | + | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |
| 478 | + | } | |
| 479 | + | return nil | |
| 480 | + | }, | |
| 481 | + | }) | |
| 482 | + | if err != nil { | |
| 483 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |
| 484 | + | } | |
| 485 | + | if gotDialer != "remote" { | |
| 486 | + | t.Fatalf("remote dialer = %q, want remote", gotDialer) | |
| 458 | 487 | } | |
| 488 | + | if !gotRemote { | |
| 489 | + | t.Fatal("remote attach must pass remote=true to runClient") | |
| 490 | + | } | |
| 491 | + | if gotClipboard != ports.ClipboardReader(clip) { | |
| 492 | + | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |
| 493 | + | } | |
| 494 | + | }) | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) { | |
| 499 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 500 | + | runClientCalled := false | |
| 501 | + | ||
| 502 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 503 | + | remoteDialerFactory: factory, | |
| 504 | + | selectedRemoteTransport: "serial", | |
| 505 | + | runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error { | |
| 506 | + | runClientCalled = true | |
| 459 | 507 | return nil | |
| 460 | 508 | }, | |
| 461 | 509 | }) | |
| 462 | - | if err != nil { | |
| 463 | - | t.Fatalf("runAttachWithDeps returned error: %v", err) | |
| 510 | + | if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` { | |
| 511 | + | t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err) | |
| 464 | 512 | } | |
| 465 | - | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | |
| 466 | - | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | |
| 513 | + | if runClientCalled { | |
| 514 | + | t.Fatal("runClient called after invalid remote transport") | |
| 467 | 515 | } | |
| 468 | - | if !gotRemote { | |
| 469 | - | t.Fatal("remote attach must pass remote=true to runClient") | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |
| 519 | + | factoryErr := errors.New("factory failed") | |
| 520 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 521 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr) | |
| 522 | + | runClientCalled := false | |
| 523 | + | ||
| 524 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 525 | + | remoteDialerFactory: factory, | |
| 526 | + | runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error { | |
| 527 | + | runClientCalled = true | |
| 528 | + | return nil | |
| 529 | + | }, | |
| 530 | + | }) | |
| 531 | + | if !errors.Is(err, factoryErr) { | |
| 532 | + | t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr) | |
| 470 | 533 | } | |
| 471 | - | if gotClipboard != ports.ClipboardReader(clip) { | |
| 472 | - | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |
| 534 | + | if runClientCalled { | |
| 535 | + | t.Fatal("runClient called after remote factory error") | |
| 473 | 536 | } | |
| 474 | 537 | } | |
| 475 | 538 | ||
| @@ -477,11 +540,17 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 477 | 540 | var gotDialer string | |
| 478 | 541 | gotRemote := true | |
| 479 | 542 | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |
| 543 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 480 | 544 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 481 | - | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 482 | - | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | |
| 545 | + | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 546 | + | remoteDialerFactory: factory, | |
| 547 | + | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | |
| 483 | 548 | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |
| 484 | - | gotDialer = d.(namedDialer).name | |
| 549 | + | nd, ok := d.(namedDialer) | |
| 550 | + | if !ok { | |
| 551 | + | t.Fatalf("dialer type = %T, want namedDialer", d) | |
| 552 | + | } | |
| 553 | + | gotDialer = nd.name | |
| 485 | 554 | gotRemote = remote | |
| 486 | 555 | gotClipboard = clipboard | |
| 487 | 556 | if intent != ports.IntentEphemeral || name != "" { | |
| @@ -503,3 +572,64 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 503 | 572 | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |
| 504 | 573 | } | |
| 505 | 574 | } | |
| 575 | + | ||
| 576 | + | func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) { | |
| 577 | + | oldTimeout := udpBootstrapTimeout | |
| 578 | + | udpBootstrapTimeout = time.Second | |
| 579 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |
| 580 | + | oldCommand := udpProxyCommand | |
| 581 | + | var gotCmd *exec.Cmd | |
| 582 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |
| 583 | + | cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'") | |
| 584 | + | gotCmd = cmd | |
| 585 | + | return cmd | |
| 586 | + | } | |
| 587 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |
| 588 | + | ||
| 589 | + | got := captureStdout(t, func() { | |
| 590 | + | if err := runUDPBootstrap(context.Background(), "work"); err != nil { | |
| 591 | + | t.Fatalf("runUDPBootstrap() error = %v", err) | |
| 592 | + | } | |
| 593 | + | }) | |
| 594 | + | if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" { | |
| 595 | + | t.Fatalf("readiness = %q", got) | |
| 596 | + | } | |
| 597 | + | if gotCmd.Stdin == nil || gotCmd.Stderr == nil { | |
| 598 | + | t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr) | |
| 599 | + | } | |
| 600 | + | if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin { | |
| 601 | + | t.Fatalf("proxy inherited SSH stdio handles") | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) { | |
| 606 | + | oldTimeout := udpBootstrapTimeout | |
| 607 | + | udpBootstrapTimeout = time.Second | |
| 608 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |
| 609 | + | oldCommand := udpProxyCommand | |
| 610 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |
| 611 | + | return exec.Command("/bin/sh", "-c", "exit 7") | |
| 612 | + | } | |
| 613 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |
| 614 | + | ||
| 615 | + | err := runUDPBootstrap(context.Background(), "work") | |
| 616 | + | if !errors.Is(err, io.EOF) { | |
| 617 | + | t.Fatalf("runUDPBootstrap() error = %v, want EOF", err) | |
| 618 | + | } | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) { | |
| 622 | + | oldTimeout := udpBootstrapTimeout | |
| 623 | + | udpBootstrapTimeout = 10 * time.Millisecond | |
| 624 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |
| 625 | + | oldCommand := udpProxyCommand | |
| 626 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |
| 627 | + | return exec.Command("/bin/sh", "-c", "sleep 5") | |
| 628 | + | } | |
| 629 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |
| 630 | + | ||
| 631 | + | err := runUDPBootstrap(context.Background(), "work") | |
| 632 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 633 | + | t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err) | |
| 634 | + | } | |
| 635 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "log/slog" | 8 | "log/slog" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | + | "os/exec" | |||
| 10 | "path/filepath" | 11 | "path/filepath" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "testing" | 13 | "testing" | ||
| @@ -16,8 +17,10 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/persist" | 18 | "github.com/bnema/vev/internal/persist" | ||
| 18 | "github.com/bnema/vev/internal/ports" | 19 | "github.com/bnema/vev/internal/ports" | ||
| 20 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 19 | "github.com/bnema/vev/internal/usecase/client" | 21 | "github.com/bnema/vev/internal/usecase/client" | ||
| 20 | "github.com/bnema/vev/internal/usecase/confirm" | 22 | "github.com/bnema/vev/internal/usecase/confirm" | ||
| 23 | + | "github.com/stretchr/testify/mock" | |||
| 21 | "github.com/stretchr/testify/require" | 24 | "github.com/stretchr/testify/require" | ||
| 22 | ) | 25 | ) | ||
| 23 | 26 | ||||
| @@ -64,6 +67,9 @@ func TestParseArgs(t *testing.T) { | |||||
| 64 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | 67 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | ||
| 65 | {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"}, | 68 | {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"}, | ||
| 66 | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | 69 | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | ||
| 70 | + | {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"}, | |||
| 71 | + | {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"}, | |||
| 72 | + | {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true}, | |||
| 67 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | 73 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | ||
| 68 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | 74 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | ||
| 69 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | 75 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | ||
| @@ -436,40 +442,97 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||||
| 436 | // never actually invoked in these wiring tests. | 442 | // never actually invoked in these wiring tests. | ||
| 437 | type fakeClipboardReader struct{ ports.ClipboardReader } | 443 | type fakeClipboardReader struct{ ports.ClipboardReader } | ||
| 438 | 444 | ||||
| 439 | - | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | 445 | + | func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { |
| 440 | - | // The local client still owns terminal capability detection for remote attach; | 446 | + | tests := []struct { |
| 441 | - | // sshstdio is only a framed transport to `vev _stdio` on the remote host. | 447 | + | name string |
| 442 | - | var gotTarget, gotSession, gotDialer string | 448 | + | selectedTransport string |
| 443 | - | var gotRemote bool | 449 | + | wantMode ports.RemoteTransportMode |
| 444 | - | var gotClipboard ports.ClipboardReader | 450 | + | }{ |
| 445 | - | clip := &fakeClipboardReader{} | 451 | + | {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP}, |
| 446 | - | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | 452 | + | {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio}, |
| 447 | - | remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer { | 453 | + | } |
| 448 | - | gotTarget, gotSession = target, session | 454 | + | |
| 449 | - | return namedDialer{name: "remote"} | 455 | + | for _, tt := range tests { |
| 450 | - | }, | 456 | + | t.Run(tt.name, func(t *testing.T) { |
| 451 | - | clipboard: clip, | 457 | + | var gotDialer string |
| 452 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | 458 | + | var gotRemote bool |
| 453 | - | gotDialer = d.(namedDialer).name | 459 | + | var gotClipboard ports.ClipboardReader |
| 454 | - | gotRemote = remote | 460 | + | clip := &fakeClipboardReader{} |
| 455 | - | gotClipboard = clipboard | 461 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) |
| 456 | - | if intent != ports.IntentAttach || name != "work" { | 462 | + | factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) |
| 457 | - | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | 463 | + | |
| 464 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |||
| 465 | + | remoteDialerFactory: factory, | |||
| 466 | + | selectedRemoteTransport: tt.selectedTransport, | |||
| 467 | + | clipboard: clip, | |||
| 468 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | |||
| 469 | + | nd, ok := d.(namedDialer) | |||
| 470 | + | if !ok { | |||
| 471 | + | t.Fatalf("dialer type = %T, want namedDialer", d) | |||
| 472 | + | } | |||
| 473 | + | gotDialer = nd.name | |||
| 474 | + | gotRemote = remote | |||
| 475 | + | gotClipboard = clipboard | |||
| 476 | + | if intent != ports.IntentAttach || name != "work" { | |||
| 477 | + | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |||
| 478 | + | } | |||
| 479 | + | return nil | |||
| 480 | + | }, | |||
| 481 | + | }) | |||
| 482 | + | if err != nil { | |||
| 483 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |||
| 484 | + | } | |||
| 485 | + | if gotDialer != "remote" { | |||
| 486 | + | t.Fatalf("remote dialer = %q, want remote", gotDialer) | |||
| 458 | } | 487 | } | ||
| 488 | + | if !gotRemote { | |||
| 489 | + | t.Fatal("remote attach must pass remote=true to runClient") | |||
| 490 | + | } | |||
| 491 | + | if gotClipboard != ports.ClipboardReader(clip) { | |||
| 492 | + | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | |||
| 493 | + | } | |||
| 494 | + | }) | |||
| 495 | + | } | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) { | |||
| 499 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 500 | + | runClientCalled := false | |||
| 501 | + | ||||
| 502 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |||
| 503 | + | remoteDialerFactory: factory, | |||
| 504 | + | selectedRemoteTransport: "serial", | |||
| 505 | + | runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error { | |||
| 506 | + | runClientCalled = true | |||
| 459 | return nil | 507 | return nil | ||
| 460 | }, | 508 | }, | ||
| 461 | }) | 509 | }) | ||
| 462 | - | if err != nil { | 510 | + | if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` { |
| 463 | - | t.Fatalf("runAttachWithDeps returned error: %v", err) | 511 | + | t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err) |
| 464 | } | 512 | } | ||
| 465 | - | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | 513 | + | if runClientCalled { |
| 466 | - | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | 514 | + | t.Fatal("runClient called after invalid remote transport") |
| 467 | } | 515 | } | ||
| 468 | - | if !gotRemote { | 516 | + | } |
| 469 | - | t.Fatal("remote attach must pass remote=true to runClient") | 517 | + | |
| 518 | + | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |||
| 519 | + | factoryErr := errors.New("factory failed") | |||
| 520 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 521 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr) | |||
| 522 | + | runClientCalled := false | |||
| 523 | + | ||||
| 524 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |||
| 525 | + | remoteDialerFactory: factory, | |||
| 526 | + | runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error { | |||
| 527 | + | runClientCalled = true | |||
| 528 | + | return nil | |||
| 529 | + | }, | |||
| 530 | + | }) | |||
| 531 | + | if !errors.Is(err, factoryErr) { | |||
| 532 | + | t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr) | |||
| 470 | } | 533 | } | ||
| 471 | - | if gotClipboard != ports.ClipboardReader(clip) { | 534 | + | if runClientCalled { |
| 472 | - | t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard) | 535 | + | t.Fatal("runClient called after remote factory error") |
| 473 | } | 536 | } | ||
| 474 | } | 537 | } | ||
| 475 | 538 | ||||
| @@ -477,11 +540,17 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 477 | var gotDialer string | 540 | var gotDialer string | ||
| 478 | gotRemote := true | 541 | gotRemote := true | ||
| 479 | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | 542 | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | ||
| 543 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 480 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | 544 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | ||
| 481 | - | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | 545 | + | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, |
| 482 | - | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | 546 | + | remoteDialerFactory: factory, |
| 547 | + | clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach | |||
| 483 | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | 548 | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error { | ||
| 484 | - | gotDialer = d.(namedDialer).name | 549 | + | nd, ok := d.(namedDialer) |
| 550 | + | if !ok { | |||
| 551 | + | t.Fatalf("dialer type = %T, want namedDialer", d) | |||
| 552 | + | } | |||
| 553 | + | gotDialer = nd.name | |||
| 485 | gotRemote = remote | 554 | gotRemote = remote | ||
| 486 | gotClipboard = clipboard | 555 | gotClipboard = clipboard | ||
| 487 | if intent != ports.IntentEphemeral || name != "" { | 556 | if intent != ports.IntentEphemeral || name != "" { | ||
| @@ -503,3 +572,64 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 503 | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | 572 | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | ||
| 504 | } | 573 | } | ||
| 505 | } | 574 | } | ||
| 575 | + | ||||
| 576 | + | func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) { | |||
| 577 | + | oldTimeout := udpBootstrapTimeout | |||
| 578 | + | udpBootstrapTimeout = time.Second | |||
| 579 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |||
| 580 | + | oldCommand := udpProxyCommand | |||
| 581 | + | var gotCmd *exec.Cmd | |||
| 582 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |||
| 583 | + | cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'") | |||
| 584 | + | gotCmd = cmd | |||
| 585 | + | return cmd | |||
| 586 | + | } | |||
| 587 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |||
| 588 | + | ||||
| 589 | + | got := captureStdout(t, func() { | |||
| 590 | + | if err := runUDPBootstrap(context.Background(), "work"); err != nil { | |||
| 591 | + | t.Fatalf("runUDPBootstrap() error = %v", err) | |||
| 592 | + | } | |||
| 593 | + | }) | |||
| 594 | + | if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" { | |||
| 595 | + | t.Fatalf("readiness = %q", got) | |||
| 596 | + | } | |||
| 597 | + | if gotCmd.Stdin == nil || gotCmd.Stderr == nil { | |||
| 598 | + | t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr) | |||
| 599 | + | } | |||
| 600 | + | if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin { | |||
| 601 | + | t.Fatalf("proxy inherited SSH stdio handles") | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) { | |||
| 606 | + | oldTimeout := udpBootstrapTimeout | |||
| 607 | + | udpBootstrapTimeout = time.Second | |||
| 608 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |||
| 609 | + | oldCommand := udpProxyCommand | |||
| 610 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |||
| 611 | + | return exec.Command("/bin/sh", "-c", "exit 7") | |||
| 612 | + | } | |||
| 613 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |||
| 614 | + | ||||
| 615 | + | err := runUDPBootstrap(context.Background(), "work") | |||
| 616 | + | if !errors.Is(err, io.EOF) { | |||
| 617 | + | t.Fatalf("runUDPBootstrap() error = %v, want EOF", err) | |||
| 618 | + | } | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) { | |||
| 622 | + | oldTimeout := udpBootstrapTimeout | |||
| 623 | + | udpBootstrapTimeout = 10 * time.Millisecond | |||
| 624 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |||
| 625 | + | oldCommand := udpProxyCommand | |||
| 626 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |||
| 627 | + | return exec.Command("/bin/sh", "-c", "sleep 5") | |||
| 628 | + | } | |||
| 629 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |||
| 630 | + | ||||
| 631 | + | err := runUDPBootstrap(context.Background(), "work") | |||
| 632 | + | if !errors.Is(err, context.DeadlineExceeded) { | |||
| 633 | + | t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err) | |||
| 634 | + | } | |||
| 635 | + | } | |||
M internal/ports/mocks/mocks.go +108 -0
| @@ -7,6 +7,7 @@ package portsmocks | |||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | "io" | |
| 10 | + | "log/slog" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| @@ -2534,3 +2535,110 @@ func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context. | |||
| 2534 | 2535 | _c.Call.Return(run) | |
| 2535 | 2536 | return _c | |
| 2536 | 2537 | } | |
| 2538 | + | ||
| 2539 | + | // NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2540 | + | // The first argument is typically a *testing.T value. | |
| 2541 | + | func NewMockRemoteDialerFactory(t interface { | |
| 2542 | + | mock.TestingT | |
| 2543 | + | Cleanup(func()) | |
| 2544 | + | }) *MockRemoteDialerFactory { | |
| 2545 | + | mock := &MockRemoteDialerFactory{} | |
| 2546 | + | mock.Mock.Test(t) | |
| 2547 | + | ||
| 2548 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2549 | + | ||
| 2550 | + | return mock | |
| 2551 | + | } | |
| 2552 | + | ||
| 2553 | + | // MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type | |
| 2554 | + | type MockRemoteDialerFactory struct { | |
| 2555 | + | mock.Mock | |
| 2556 | + | } | |
| 2557 | + | ||
| 2558 | + | type MockRemoteDialerFactory_Expecter struct { | |
| 2559 | + | mock *mock.Mock | |
| 2560 | + | } | |
| 2561 | + | ||
| 2562 | + | func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter { | |
| 2563 | + | return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock} | |
| 2564 | + | } | |
| 2565 | + | ||
| 2566 | + | // DialerForRemote provides a mock function for the type MockRemoteDialerFactory | |
| 2567 | + | func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) { | |
| 2568 | + | ret := _mock.Called(target, session, mode, log) | |
| 2569 | + | ||
| 2570 | + | if len(ret) == 0 { | |
| 2571 | + | panic("no return value specified for DialerForRemote") | |
| 2572 | + | } | |
| 2573 | + | ||
| 2574 | + | var r0 ports.Dialer | |
| 2575 | + | var r1 error | |
| 2576 | + | if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok { | |
| 2577 | + | return returnFunc(target, session, mode, log) | |
| 2578 | + | } | |
| 2579 | + | if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok { | |
| 2580 | + | r0 = returnFunc(target, session, mode, log) | |
| 2581 | + | } else { | |
| 2582 | + | if ret.Get(0) != nil { | |
| 2583 | + | r0 = ret.Get(0).(ports.Dialer) | |
| 2584 | + | } | |
| 2585 | + | } | |
| 2586 | + | if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok { | |
| 2587 | + | r1 = returnFunc(target, session, mode, log) | |
| 2588 | + | } else { | |
| 2589 | + | r1 = ret.Error(1) | |
| 2590 | + | } | |
| 2591 | + | return r0, r1 | |
| 2592 | + | } | |
| 2593 | + | ||
| 2594 | + | // MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote' | |
| 2595 | + | type MockRemoteDialerFactory_DialerForRemote_Call struct { | |
| 2596 | + | *mock.Call | |
| 2597 | + | } | |
| 2598 | + | ||
| 2599 | + | // DialerForRemote is a helper method to define mock.On call | |
| 2600 | + | // - target string | |
| 2601 | + | // - session string | |
| 2602 | + | // - mode ports.RemoteTransportMode | |
| 2603 | + | // - log *slog.Logger | |
| 2604 | + | func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call { | |
| 2605 | + | return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)} | |
| 2606 | + | } | |
| 2607 | + | ||
| 2608 | + | func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call { | |
| 2609 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2610 | + | var arg0 string | |
| 2611 | + | if args[0] != nil { | |
| 2612 | + | arg0 = args[0].(string) | |
| 2613 | + | } | |
| 2614 | + | var arg1 string | |
| 2615 | + | if args[1] != nil { | |
| 2616 | + | arg1 = args[1].(string) | |
| 2617 | + | } | |
| 2618 | + | var arg2 ports.RemoteTransportMode | |
| 2619 | + | if args[2] != nil { | |
| 2620 | + | arg2 = args[2].(ports.RemoteTransportMode) | |
| 2621 | + | } | |
| 2622 | + | var arg3 *slog.Logger | |
| 2623 | + | if args[3] != nil { | |
| 2624 | + | arg3 = args[3].(*slog.Logger) | |
| 2625 | + | } | |
| 2626 | + | run( | |
| 2627 | + | arg0, | |
| 2628 | + | arg1, | |
| 2629 | + | arg2, | |
| 2630 | + | arg3, | |
| 2631 | + | ) | |
| 2632 | + | }) | |
| 2633 | + | return _c | |
| 2634 | + | } | |
| 2635 | + | ||
| 2636 | + | func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call { | |
| 2637 | + | _c.Call.Return(dialer, err) | |
| 2638 | + | return _c | |
| 2639 | + | } | |
| 2640 | + | ||
| 2641 | + | func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call { | |
| 2642 | + | _c.Call.Return(run) | |
| 2643 | + | return _c | |
| 2644 | + | } | |
| @@ -7,6 +7,7 @@ package portsmocks | |||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | + | "log/slog" | |||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| @@ -2534,3 +2535,110 @@ func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context. | |||||
| 2534 | _c.Call.Return(run) | 2535 | _c.Call.Return(run) | ||
| 2535 | return _c | 2536 | return _c | ||
| 2536 | } | 2537 | } | ||
| 2538 | + | ||||
| 2539 | + | // NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 2540 | + | // The first argument is typically a *testing.T value. | |||
| 2541 | + | func NewMockRemoteDialerFactory(t interface { | |||
| 2542 | + | mock.TestingT | |||
| 2543 | + | Cleanup(func()) | |||
| 2544 | + | }) *MockRemoteDialerFactory { | |||
| 2545 | + | mock := &MockRemoteDialerFactory{} | |||
| 2546 | + | mock.Mock.Test(t) | |||
| 2547 | + | ||||
| 2548 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 2549 | + | ||||
| 2550 | + | return mock | |||
| 2551 | + | } | |||
| 2552 | + | ||||
| 2553 | + | // MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type | |||
| 2554 | + | type MockRemoteDialerFactory struct { | |||
| 2555 | + | mock.Mock | |||
| 2556 | + | } | |||
| 2557 | + | ||||
| 2558 | + | type MockRemoteDialerFactory_Expecter struct { | |||
| 2559 | + | mock *mock.Mock | |||
| 2560 | + | } | |||
| 2561 | + | ||||
| 2562 | + | func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter { | |||
| 2563 | + | return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock} | |||
| 2564 | + | } | |||
| 2565 | + | ||||
| 2566 | + | // DialerForRemote provides a mock function for the type MockRemoteDialerFactory | |||
| 2567 | + | func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) { | |||
| 2568 | + | ret := _mock.Called(target, session, mode, log) | |||
| 2569 | + | ||||
| 2570 | + | if len(ret) == 0 { | |||
| 2571 | + | panic("no return value specified for DialerForRemote") | |||
| 2572 | + | } | |||
| 2573 | + | ||||
| 2574 | + | var r0 ports.Dialer | |||
| 2575 | + | var r1 error | |||
| 2576 | + | if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok { | |||
| 2577 | + | return returnFunc(target, session, mode, log) | |||
| 2578 | + | } | |||
| 2579 | + | if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok { | |||
| 2580 | + | r0 = returnFunc(target, session, mode, log) | |||
| 2581 | + | } else { | |||
| 2582 | + | if ret.Get(0) != nil { | |||
| 2583 | + | r0 = ret.Get(0).(ports.Dialer) | |||
| 2584 | + | } | |||
| 2585 | + | } | |||
| 2586 | + | if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok { | |||
| 2587 | + | r1 = returnFunc(target, session, mode, log) | |||
| 2588 | + | } else { | |||
| 2589 | + | r1 = ret.Error(1) | |||
| 2590 | + | } | |||
| 2591 | + | return r0, r1 | |||
| 2592 | + | } | |||
| 2593 | + | ||||
| 2594 | + | // MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote' | |||
| 2595 | + | type MockRemoteDialerFactory_DialerForRemote_Call struct { | |||
| 2596 | + | *mock.Call | |||
| 2597 | + | } | |||
| 2598 | + | ||||
| 2599 | + | // DialerForRemote is a helper method to define mock.On call | |||
| 2600 | + | // - target string | |||
| 2601 | + | // - session string | |||
| 2602 | + | // - mode ports.RemoteTransportMode | |||
| 2603 | + | // - log *slog.Logger | |||
| 2604 | + | func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call { | |||
| 2605 | + | return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)} | |||
| 2606 | + | } | |||
| 2607 | + | ||||
| 2608 | + | func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call { | |||
| 2609 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2610 | + | var arg0 string | |||
| 2611 | + | if args[0] != nil { | |||
| 2612 | + | arg0 = args[0].(string) | |||
| 2613 | + | } | |||
| 2614 | + | var arg1 string | |||
| 2615 | + | if args[1] != nil { | |||
| 2616 | + | arg1 = args[1].(string) | |||
| 2617 | + | } | |||
| 2618 | + | var arg2 ports.RemoteTransportMode | |||
| 2619 | + | if args[2] != nil { | |||
| 2620 | + | arg2 = args[2].(ports.RemoteTransportMode) | |||
| 2621 | + | } | |||
| 2622 | + | var arg3 *slog.Logger | |||
| 2623 | + | if args[3] != nil { | |||
| 2624 | + | arg3 = args[3].(*slog.Logger) | |||
| 2625 | + | } | |||
| 2626 | + | run( | |||
| 2627 | + | arg0, | |||
| 2628 | + | arg1, | |||
| 2629 | + | arg2, | |||
| 2630 | + | arg3, | |||
| 2631 | + | ) | |||
| 2632 | + | }) | |||
| 2633 | + | return _c | |||
| 2634 | + | } | |||
| 2635 | + | ||||
| 2636 | + | func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call { | |||
| 2637 | + | _c.Call.Return(dialer, err) | |||
| 2638 | + | return _c | |||
| 2639 | + | } | |||
| 2640 | + | ||||
| 2641 | + | func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call { | |||
| 2642 | + | _c.Call.Return(run) | |||
| 2643 | + | return _c | |||
| 2644 | + | } | |||
A internal/ports/remote.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import "log/slog" | |
| 4 | + | ||
| 5 | + | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | |
| 6 | + | type RemoteTransportMode string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | RemoteTransportUDP RemoteTransportMode = "udp" | |
| 10 | + | RemoteTransportStdio RemoteTransportMode = "stdio" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // RemoteDialerFactory builds the transport-specific dialer for a remote target. | |
| 14 | + | // Implementations live in adapters; app wiring consumes this interface. | |
| 15 | + | type RemoteDialerFactory interface { | |
| 16 | + | DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error) | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import "log/slog" | |||
| 4 | + | ||||
| 5 | + | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | |||
| 6 | + | type RemoteTransportMode string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | RemoteTransportUDP RemoteTransportMode = "udp" | |||
| 10 | + | RemoteTransportStdio RemoteTransportMode = "stdio" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // RemoteDialerFactory builds the transport-specific dialer for a remote target. | |||
| 14 | + | // Implementations live in adapters; app wiring consumes this interface. | |||
| 15 | + | type RemoteDialerFactory interface { | |||
| 16 | + | DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error) | |||
| 17 | + | } | |||
M internal/usecase/client/reconnect_toast_test.go +97 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "log/slog" | |
| 9 | 10 | "strings" | |
| @@ -75,6 +76,54 @@ type reconnectToastRecv struct { | |||
| 75 | 76 | err error | |
| 76 | 77 | } | |
| 77 | 78 | ||
| 79 | + | type reconnectToastSequenceDialer struct { | |
| 80 | + | transports []ports.Transport | |
| 81 | + | calls int | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) { | |
| 85 | + | if d.calls >= len(d.transports) { | |
| 86 | + | return nil, io.EOF | |
| 87 | + | } | |
| 88 | + | tr := d.transports[d.calls] | |
| 89 | + | d.calls++ | |
| 90 | + | return tr, nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | type reconnectToastRecordingTransport struct { | |
| 94 | + | recvs []reconnectToastRecv | |
| 95 | + | sends []ports.Frame | |
| 96 | + | closed bool | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error { | |
| 100 | + | t.sends = append(t.sends, f) | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) { | |
| 105 | + | if len(t.recvs) == 0 { | |
| 106 | + | return ports.Frame{}, io.EOF | |
| 107 | + | } | |
| 108 | + | recv := t.recvs[0] | |
| 109 | + | t.recvs = t.recvs[1:] | |
| 110 | + | return recv.frame, recv.err | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (t *reconnectToastRecordingTransport) Close() error { | |
| 114 | + | t.closed = true | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello { | |
| 119 | + | t.Helper() | |
| 120 | + | require.NotEmpty(t, tr.sends) | |
| 121 | + | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | |
| 122 | + | hello, err := ports.UnmarshalHello(tr.sends[0].Payload) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | return hello | |
| 125 | + | } | |
| 126 | + | ||
| 78 | 127 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 79 | 128 | var out bytes.Buffer | |
| 80 | 129 | size := domain.Size{Cols: 80, Rows: 24} | |
| @@ -132,6 +181,54 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) { | |||
| 132 | 181 | require.True(t, <-done) | |
| 133 | 182 | } | |
| 134 | 183 | ||
| 184 | + | func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |
| 185 | + | linkDead := errors.New("remote link dead") | |
| 186 | + | wrappedLinkDead := errors.Join( | |
| 187 | + | fmt.Errorf("remote transport receive failed: %w", io.EOF), | |
| 188 | + | linkDead, | |
| 189 | + | ) | |
| 190 | + | oldSleep := reconnectSleep | |
| 191 | + | oldSleepWithResize := reconnectSleepWithResize | |
| 192 | + | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true } | |
| 193 | + | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |
| 194 | + | return true | |
| 195 | + | } | |
| 196 | + | defer func() { | |
| 197 | + | reconnectSleep = oldSleep | |
| 198 | + | reconnectSleepWithResize = oldSleepWithResize | |
| 199 | + | }() | |
| 200 | + | ||
| 201 | + | term := newReconnectToastTerminalHarness(t) | |
| 202 | + | defer term.closeInput() | |
| 203 | + | tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{ | |
| 204 | + | {frame: reconnectToastWelcome(44)}, | |
| 205 | + | {err: wrappedLinkDead}, | |
| 206 | + | }} | |
| 207 | + | tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{ | |
| 208 | + | {frame: reconnectToastWelcome(55)}, | |
| 209 | + | {frame: reconnectToastDetach(ports.ReasonDetach)}, | |
| 210 | + | }} | |
| 211 | + | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |
| 212 | + | ||
| 213 | + | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | require.True(t, tr1.closed) | |
| 216 | + | require.True(t, tr2.closed) | |
| 217 | + | require.Equal(t, 2, dialer.calls) | |
| 218 | + | ||
| 219 | + | firstHello := reconnectToastHelloFromSend(t, tr1) | |
| 220 | + | resumeHello := reconnectToastHelloFromSend(t, tr2) | |
| 221 | + | require.Equal(t, ports.IntentAttach, firstHello.Intent) | |
| 222 | + | require.Zero(t, firstHello.ResumeToken) | |
| 223 | + | require.Equal(t, ports.IntentResume, resumeHello.Intent) | |
| 224 | + | require.Equal(t, uint64(44), resumeHello.ResumeToken) | |
| 225 | + | require.Equal(t, firstHello.ClientID, resumeHello.ClientID) | |
| 226 | + | ||
| 227 | + | out := term.out.String() | |
| 228 | + | require.Contains(t, out, reconnectToastMessage) | |
| 229 | + | require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width)) | |
| 230 | + | } | |
| 231 | + | ||
| 135 | 232 | func TestRemoteReconnectToastLifecycle(t *testing.T) { | |
| 136 | 233 | tests := []struct { | |
| 137 | 234 | name string | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "fmt" | |||
| 7 | "io" | 8 | "io" | ||
| 8 | "log/slog" | 9 | "log/slog" | ||
| 9 | "strings" | 10 | "strings" | ||
| @@ -75,6 +76,54 @@ type reconnectToastRecv struct { | |||||
| 75 | err error | 76 | err error | ||
| 76 | } | 77 | } | ||
| 77 | 78 | ||||
| 79 | + | type reconnectToastSequenceDialer struct { | |||
| 80 | + | transports []ports.Transport | |||
| 81 | + | calls int | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 85 | + | if d.calls >= len(d.transports) { | |||
| 86 | + | return nil, io.EOF | |||
| 87 | + | } | |||
| 88 | + | tr := d.transports[d.calls] | |||
| 89 | + | d.calls++ | |||
| 90 | + | return tr, nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | type reconnectToastRecordingTransport struct { | |||
| 94 | + | recvs []reconnectToastRecv | |||
| 95 | + | sends []ports.Frame | |||
| 96 | + | closed bool | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error { | |||
| 100 | + | t.sends = append(t.sends, f) | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) { | |||
| 105 | + | if len(t.recvs) == 0 { | |||
| 106 | + | return ports.Frame{}, io.EOF | |||
| 107 | + | } | |||
| 108 | + | recv := t.recvs[0] | |||
| 109 | + | t.recvs = t.recvs[1:] | |||
| 110 | + | return recv.frame, recv.err | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (t *reconnectToastRecordingTransport) Close() error { | |||
| 114 | + | t.closed = true | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello { | |||
| 119 | + | t.Helper() | |||
| 120 | + | require.NotEmpty(t, tr.sends) | |||
| 121 | + | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | |||
| 122 | + | hello, err := ports.UnmarshalHello(tr.sends[0].Payload) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | return hello | |||
| 125 | + | } | |||
| 126 | + | ||||
| 78 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | 127 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | ||
| 79 | var out bytes.Buffer | 128 | var out bytes.Buffer | ||
| 80 | size := domain.Size{Cols: 80, Rows: 24} | 129 | size := domain.Size{Cols: 80, Rows: 24} | ||
| @@ -132,6 +181,54 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) { | |||||
| 132 | require.True(t, <-done) | 181 | require.True(t, <-done) | ||
| 133 | } | 182 | } | ||
| 134 | 183 | ||||
| 184 | + | func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 185 | + | linkDead := errors.New("remote link dead") | |||
| 186 | + | wrappedLinkDead := errors.Join( | |||
| 187 | + | fmt.Errorf("remote transport receive failed: %w", io.EOF), | |||
| 188 | + | linkDead, | |||
| 189 | + | ) | |||
| 190 | + | oldSleep := reconnectSleep | |||
| 191 | + | oldSleepWithResize := reconnectSleepWithResize | |||
| 192 | + | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true } | |||
| 193 | + | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |||
| 194 | + | return true | |||
| 195 | + | } | |||
| 196 | + | defer func() { | |||
| 197 | + | reconnectSleep = oldSleep | |||
| 198 | + | reconnectSleepWithResize = oldSleepWithResize | |||
| 199 | + | }() | |||
| 200 | + | ||||
| 201 | + | term := newReconnectToastTerminalHarness(t) | |||
| 202 | + | defer term.closeInput() | |||
| 203 | + | tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{ | |||
| 204 | + | {frame: reconnectToastWelcome(44)}, | |||
| 205 | + | {err: wrappedLinkDead}, | |||
| 206 | + | }} | |||
| 207 | + | tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{ | |||
| 208 | + | {frame: reconnectToastWelcome(55)}, | |||
| 209 | + | {frame: reconnectToastDetach(ports.ReasonDetach)}, | |||
| 210 | + | }} | |||
| 211 | + | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |||
| 212 | + | ||||
| 213 | + | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | require.True(t, tr1.closed) | |||
| 216 | + | require.True(t, tr2.closed) | |||
| 217 | + | require.Equal(t, 2, dialer.calls) | |||
| 218 | + | ||||
| 219 | + | firstHello := reconnectToastHelloFromSend(t, tr1) | |||
| 220 | + | resumeHello := reconnectToastHelloFromSend(t, tr2) | |||
| 221 | + | require.Equal(t, ports.IntentAttach, firstHello.Intent) | |||
| 222 | + | require.Zero(t, firstHello.ResumeToken) | |||
| 223 | + | require.Equal(t, ports.IntentResume, resumeHello.Intent) | |||
| 224 | + | require.Equal(t, uint64(44), resumeHello.ResumeToken) | |||
| 225 | + | require.Equal(t, firstHello.ClientID, resumeHello.ClientID) | |||
| 226 | + | ||||
| 227 | + | out := term.out.String() | |||
| 228 | + | require.Contains(t, out, reconnectToastMessage) | |||
| 229 | + | require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width)) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 135 | func TestRemoteReconnectToastLifecycle(t *testing.T) { | 232 | func TestRemoteReconnectToastLifecycle(t *testing.T) { | ||
| 136 | tests := []struct { | 233 | tests := []struct { | ||
| 137 | name string | 234 | name string | ||