feat(remote): add ssh stdio attach
- commit
- 28e6f55b50d09d553bef4969e19c6bc1b096f6fa
- author
- brice · · 2026-07-02 16:56 UTC
- parent
- 00a0215
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +377 -7
A internal/adapters/sshstdio/transport.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | // Package sshstdio adapts an ssh subprocess' stdin/stdout into vev's framed | |
| 2 | + | // Transport interface. It intentionally builds argv slices for os/exec instead | |
| 3 | + | // of shell command strings so remote targets and session names are never shell- | |
| 4 | + | // interpolated locally. | |
| 5 | + | package sshstdio | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "encoding/binary" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "os/exec" | |
| 13 | + | "sync" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | maxFrameLen = 16 << 20 | |
| 20 | + | frameHeaderLen = 4 | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | var ( | |
| 24 | + | ErrZeroLengthFrame = errors.New("sshstdio: zero-length frame") | |
| 25 | + | ErrFrameTooLarge = errors.New("sshstdio: frame exceeds maximum length") | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | type closeFunc func() error | |
| 29 | + | ||
| 30 | + | // NewTransport wraps separate reader/writer streams as a framed Transport. | |
| 31 | + | func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport { | |
| 32 | + | if close == nil { | |
| 33 | + | close = func() error { return nil } | |
| 34 | + | } | |
| 35 | + | return &transport{r: r, w: w, close: close} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type transport struct { | |
| 39 | + | r io.Reader | |
| 40 | + | w io.Writer | |
| 41 | + | close closeFunc | |
| 42 | + | ||
| 43 | + | mu sync.Mutex | |
| 44 | + | readBuf []byte | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (t *transport) Send(f ports.Frame) error { | |
| 48 | + | n := 1 + len(f.Payload) | |
| 49 | + | if n > maxFrameLen { | |
| 50 | + | return ErrFrameTooLarge | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | buf := make([]byte, frameHeaderLen+n) | |
| 54 | + | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | |
| 55 | + | buf[frameHeaderLen] = byte(f.Type) | |
| 56 | + | copy(buf[frameHeaderLen+1:], f.Payload) | |
| 57 | + | ||
| 58 | + | t.mu.Lock() | |
| 59 | + | defer t.mu.Unlock() | |
| 60 | + | _, err := t.w.Write(buf) | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (t *transport) Recv() (ports.Frame, error) { | |
| 65 | + | var hdr [frameHeaderLen]byte | |
| 66 | + | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | |
| 67 | + | return ports.Frame{}, err | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | n := binary.BigEndian.Uint32(hdr[:]) | |
| 71 | + | if n == 0 { | |
| 72 | + | return ports.Frame{}, ErrZeroLengthFrame | |
| 73 | + | } | |
| 74 | + | if n > maxFrameLen { | |
| 75 | + | return ports.Frame{}, ErrFrameTooLarge | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if cap(t.readBuf) < int(n) { | |
| 79 | + | t.readBuf = make([]byte, n) | |
| 80 | + | } else { | |
| 81 | + | t.readBuf = t.readBuf[:n] | |
| 82 | + | } | |
| 83 | + | if _, err := io.ReadFull(t.r, t.readBuf); err != nil { | |
| 84 | + | return ports.Frame{}, err | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | payload := append([]byte(nil), t.readBuf[1:]...) | |
| 88 | + | return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (t *transport) Close() error { return t.close() } | |
| 92 | + | ||
| 93 | + | // CommandSpec is the exact ssh argv vev will execute locally. | |
| 94 | + | type CommandSpec struct { | |
| 95 | + | Path string | |
| 96 | + | Args []string | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // BuildCommand constructs the ssh subprocess argv without invoking a shell. | |
| 100 | + | func BuildCommand(target, session string) CommandSpec { | |
| 101 | + | args := []string{target, "vev", "_stdio"} | |
| 102 | + | if session != "" { | |
| 103 | + | args = append(args, session) | |
| 104 | + | } | |
| 105 | + | return CommandSpec{Path: "ssh", Args: args} | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Dial starts ssh target vev _stdio [session] and returns a Transport over the | |
| 109 | + | // child process' stdio. The subprocess is started with exec.Command argv, never | |
| 110 | + | // through a shell. | |
| 111 | + | func Dial(target, session string) (ports.Transport, error) { | |
| 112 | + | spec := BuildCommand(target, session) | |
| 113 | + | cmd := exec.Command(spec.Path, spec.Args...) | |
| 114 | + | stdin, err := cmd.StdinPipe() | |
| 115 | + | if err != nil { | |
| 116 | + | return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err) | |
| 117 | + | } | |
| 118 | + | stdout, err := cmd.StdoutPipe() | |
| 119 | + | if err != nil { | |
| 120 | + | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | |
| 121 | + | } | |
| 122 | + | if err := cmd.Start(); err != nil { | |
| 123 | + | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | return NewTransport(stdout, stdin, func() error { | |
| 127 | + | _ = stdin.Close() | |
| 128 | + | err := cmd.Wait() | |
| 129 | + | if err != nil { | |
| 130 | + | return fmt.Errorf("sshstdio: ssh exited: %w", err) | |
| 131 | + | } | |
| 132 | + | return nil | |
| 133 | + | }), nil | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | // Package sshstdio adapts an ssh subprocess' stdin/stdout into vev's framed | |||
| 2 | + | // Transport interface. It intentionally builds argv slices for os/exec instead | |||
| 3 | + | // of shell command strings so remote targets and session names are never shell- | |||
| 4 | + | // interpolated locally. | |||
| 5 | + | package sshstdio | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "encoding/binary" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "sync" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | maxFrameLen = 16 << 20 | |||
| 20 | + | frameHeaderLen = 4 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | var ( | |||
| 24 | + | ErrZeroLengthFrame = errors.New("sshstdio: zero-length frame") | |||
| 25 | + | ErrFrameTooLarge = errors.New("sshstdio: frame exceeds maximum length") | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | type closeFunc func() error | |||
| 29 | + | ||||
| 30 | + | // NewTransport wraps separate reader/writer streams as a framed Transport. | |||
| 31 | + | func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport { | |||
| 32 | + | if close == nil { | |||
| 33 | + | close = func() error { return nil } | |||
| 34 | + | } | |||
| 35 | + | return &transport{r: r, w: w, close: close} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type transport struct { | |||
| 39 | + | r io.Reader | |||
| 40 | + | w io.Writer | |||
| 41 | + | close closeFunc | |||
| 42 | + | ||||
| 43 | + | mu sync.Mutex | |||
| 44 | + | readBuf []byte | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (t *transport) Send(f ports.Frame) error { | |||
| 48 | + | n := 1 + len(f.Payload) | |||
| 49 | + | if n > maxFrameLen { | |||
| 50 | + | return ErrFrameTooLarge | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | buf := make([]byte, frameHeaderLen+n) | |||
| 54 | + | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | |||
| 55 | + | buf[frameHeaderLen] = byte(f.Type) | |||
| 56 | + | copy(buf[frameHeaderLen+1:], f.Payload) | |||
| 57 | + | ||||
| 58 | + | t.mu.Lock() | |||
| 59 | + | defer t.mu.Unlock() | |||
| 60 | + | _, err := t.w.Write(buf) | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (t *transport) Recv() (ports.Frame, error) { | |||
| 65 | + | var hdr [frameHeaderLen]byte | |||
| 66 | + | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | |||
| 67 | + | return ports.Frame{}, err | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | n := binary.BigEndian.Uint32(hdr[:]) | |||
| 71 | + | if n == 0 { | |||
| 72 | + | return ports.Frame{}, ErrZeroLengthFrame | |||
| 73 | + | } | |||
| 74 | + | if n > maxFrameLen { | |||
| 75 | + | return ports.Frame{}, ErrFrameTooLarge | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | if cap(t.readBuf) < int(n) { | |||
| 79 | + | t.readBuf = make([]byte, n) | |||
| 80 | + | } else { | |||
| 81 | + | t.readBuf = t.readBuf[:n] | |||
| 82 | + | } | |||
| 83 | + | if _, err := io.ReadFull(t.r, t.readBuf); err != nil { | |||
| 84 | + | return ports.Frame{}, err | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | payload := append([]byte(nil), t.readBuf[1:]...) | |||
| 88 | + | return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (t *transport) Close() error { return t.close() } | |||
| 92 | + | ||||
| 93 | + | // CommandSpec is the exact ssh argv vev will execute locally. | |||
| 94 | + | type CommandSpec struct { | |||
| 95 | + | Path string | |||
| 96 | + | Args []string | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // BuildCommand constructs the ssh subprocess argv without invoking a shell. | |||
| 100 | + | func BuildCommand(target, session string) CommandSpec { | |||
| 101 | + | args := []string{target, "vev", "_stdio"} | |||
| 102 | + | if session != "" { | |||
| 103 | + | args = append(args, session) | |||
| 104 | + | } | |||
| 105 | + | return CommandSpec{Path: "ssh", Args: args} | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Dial starts ssh target vev _stdio [session] and returns a Transport over the | |||
| 109 | + | // child process' stdio. The subprocess is started with exec.Command argv, never | |||
| 110 | + | // through a shell. | |||
| 111 | + | func Dial(target, session string) (ports.Transport, error) { | |||
| 112 | + | spec := BuildCommand(target, session) | |||
| 113 | + | cmd := exec.Command(spec.Path, spec.Args...) | |||
| 114 | + | stdin, err := cmd.StdinPipe() | |||
| 115 | + | if err != nil { | |||
| 116 | + | return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err) | |||
| 117 | + | } | |||
| 118 | + | stdout, err := cmd.StdoutPipe() | |||
| 119 | + | if err != nil { | |||
| 120 | + | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | |||
| 121 | + | } | |||
| 122 | + | if err := cmd.Start(); err != nil { | |||
| 123 | + | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return NewTransport(stdout, stdin, func() error { | |||
| 127 | + | _ = stdin.Close() | |||
| 128 | + | err := cmd.Wait() | |||
| 129 | + | if err != nil { | |||
| 130 | + | return fmt.Errorf("sshstdio: ssh exited: %w", err) | |||
| 131 | + | } | |||
| 132 | + | return nil | |||
| 133 | + | }), nil | |||
| 134 | + | } | |||
A internal/adapters/sshstdio/transport_test.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | package sshstdio | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestBuildCommandUsesExecArgs(t *testing.T) { | |
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | target string | |
| 16 | + | session string | |
| 17 | + | want []string | |
| 18 | + | }{ | |
| 19 | + | {name: "no session", target: "user@example.com", want: []string{"user@example.com", "vev", "_stdio"}}, | |
| 20 | + | {name: "session kept as single arg", target: "user@example.com", session: "work; rm -rf /", want: []string{"user@example.com", "vev", "_stdio", "work; rm -rf /"}}, | |
| 21 | + | {name: "target kept as single arg", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"user@host; touch /tmp/pwn", "vev", "_stdio", "work"}}, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | got := BuildCommand(tt.target, tt.session) | |
| 27 | + | if got.Path != "ssh" { | |
| 28 | + | t.Fatalf("Path = %q, want ssh", got.Path) | |
| 29 | + | } | |
| 30 | + | if len(got.Args) != len(tt.want) { | |
| 31 | + | t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args) | |
| 32 | + | } | |
| 33 | + | for i := range tt.want { | |
| 34 | + | if got.Args[i] != tt.want[i] { | |
| 35 | + | t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) { | |
| 43 | + | clientRead, serverWrite := io.Pipe() | |
| 44 | + | serverRead, clientWrite := io.Pipe() | |
| 45 | + | client := NewTransport(clientRead, clientWrite, func() error { | |
| 46 | + | _ = clientRead.Close() | |
| 47 | + | return clientWrite.Close() | |
| 48 | + | }) | |
| 49 | + | server := NewTransport(serverRead, serverWrite, func() error { | |
| 50 | + | _ = serverRead.Close() | |
| 51 | + | return serverWrite.Close() | |
| 52 | + | }) | |
| 53 | + | defer func() { _ = client.Close() }() | |
| 54 | + | defer func() { _ = server.Close() }() | |
| 55 | + | ||
| 56 | + | hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work"} | |
| 57 | + | go func() { | |
| 58 | + | f, err := server.Recv() | |
| 59 | + | if err != nil { | |
| 60 | + | t.Errorf("server Recv: %v", err) | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | got, err := ports.UnmarshalHello(f.Payload) | |
| 64 | + | if err != nil { | |
| 65 | + | t.Errorf("UnmarshalHello: %v", err) | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | if got.Version == ports.ProtocolVersion { | |
| 69 | + | t.Errorf("test did not send a mismatched version") | |
| 70 | + | } | |
| 71 | + | _ = server.Send(ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})}) | |
| 72 | + | }() | |
| 73 | + | ||
| 74 | + | if err := client.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 75 | + | t.Fatalf("client Send: %v", err) | |
| 76 | + | } | |
| 77 | + | reply, err := client.Recv() | |
| 78 | + | if err != nil { | |
| 79 | + | t.Fatalf("client Recv: %v", err) | |
| 80 | + | } | |
| 81 | + | if reply.Type != ports.MsgError { | |
| 82 | + | t.Fatalf("reply type = %d, want MsgError", reply.Type) | |
| 83 | + | } | |
| 84 | + | em, err := ports.UnmarshalErrorMsg(reply.Payload) | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatalf("UnmarshalErrorMsg: %v", err) | |
| 87 | + | } | |
| 88 | + | if em.Code != ports.ErrVersionMismatch { | |
| 89 | + | t.Fatalf("error code = %d, want ErrVersionMismatch", em.Code) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |
| 94 | + | tr := NewTransport(strings.NewReader("\x00\x00\x00\x00"), io.Discard, nil) | |
| 95 | + | _, err := tr.Recv() | |
| 96 | + | if !errors.Is(err, ErrZeroLengthFrame) { | |
| 97 | + | t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err) | |
| 98 | + | } | |
| 99 | + | } | |
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | package sshstdio | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "io" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestBuildCommandUsesExecArgs(t *testing.T) { | |||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | target string | |||
| 16 | + | session string | |||
| 17 | + | want []string | |||
| 18 | + | }{ | |||
| 19 | + | {name: "no session", target: "user@example.com", want: []string{"user@example.com", "vev", "_stdio"}}, | |||
| 20 | + | {name: "session kept as single arg", target: "user@example.com", session: "work; rm -rf /", want: []string{"user@example.com", "vev", "_stdio", "work; rm -rf /"}}, | |||
| 21 | + | {name: "target kept as single arg", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"user@host; touch /tmp/pwn", "vev", "_stdio", "work"}}, | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | got := BuildCommand(tt.target, tt.session) | |||
| 27 | + | if got.Path != "ssh" { | |||
| 28 | + | t.Fatalf("Path = %q, want ssh", got.Path) | |||
| 29 | + | } | |||
| 30 | + | if len(got.Args) != len(tt.want) { | |||
| 31 | + | t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args) | |||
| 32 | + | } | |||
| 33 | + | for i := range tt.want { | |||
| 34 | + | if got.Args[i] != tt.want[i] { | |||
| 35 | + | t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) { | |||
| 43 | + | clientRead, serverWrite := io.Pipe() | |||
| 44 | + | serverRead, clientWrite := io.Pipe() | |||
| 45 | + | client := NewTransport(clientRead, clientWrite, func() error { | |||
| 46 | + | _ = clientRead.Close() | |||
| 47 | + | return clientWrite.Close() | |||
| 48 | + | }) | |||
| 49 | + | server := NewTransport(serverRead, serverWrite, func() error { | |||
| 50 | + | _ = serverRead.Close() | |||
| 51 | + | return serverWrite.Close() | |||
| 52 | + | }) | |||
| 53 | + | defer func() { _ = client.Close() }() | |||
| 54 | + | defer func() { _ = server.Close() }() | |||
| 55 | + | ||||
| 56 | + | hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work"} | |||
| 57 | + | go func() { | |||
| 58 | + | f, err := server.Recv() | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Errorf("server Recv: %v", err) | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | got, err := ports.UnmarshalHello(f.Payload) | |||
| 64 | + | if err != nil { | |||
| 65 | + | t.Errorf("UnmarshalHello: %v", err) | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | if got.Version == ports.ProtocolVersion { | |||
| 69 | + | t.Errorf("test did not send a mismatched version") | |||
| 70 | + | } | |||
| 71 | + | _ = server.Send(ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})}) | |||
| 72 | + | }() | |||
| 73 | + | ||||
| 74 | + | if err := client.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |||
| 75 | + | t.Fatalf("client Send: %v", err) | |||
| 76 | + | } | |||
| 77 | + | reply, err := client.Recv() | |||
| 78 | + | if err != nil { | |||
| 79 | + | t.Fatalf("client Recv: %v", err) | |||
| 80 | + | } | |||
| 81 | + | if reply.Type != ports.MsgError { | |||
| 82 | + | t.Fatalf("reply type = %d, want MsgError", reply.Type) | |||
| 83 | + | } | |||
| 84 | + | em, err := ports.UnmarshalErrorMsg(reply.Payload) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatalf("UnmarshalErrorMsg: %v", err) | |||
| 87 | + | } | |||
| 88 | + | if em.Code != ports.ErrVersionMismatch { | |||
| 89 | + | t.Fatalf("error code = %d, want ErrVersionMismatch", em.Code) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |||
| 94 | + | tr := NewTransport(strings.NewReader("\x00\x00\x00\x00"), io.Discard, nil) | |||
| 95 | + | _, err := tr.Recv() | |||
| 96 | + | if !errors.Is(err, ErrZeroLengthFrame) { | |||
| 97 | + | t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err) | |||
| 98 | + | } | |||
| 99 | + | } | |||
A internal/app/remote.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // parseRemoteAttachTarget distinguishes remote attach syntax from ordinary | |
| 6 | + | // local session names. Only values with a user@host prefix are remote; local | |
| 7 | + | // names may still contain ':' when they do not contain '@'. | |
| 8 | + | func parseRemoteAttachTarget(s string) (target, session string, ok bool) { | |
| 9 | + | at := strings.LastIndexByte(s, '@') | |
| 10 | + | if at <= 0 || at == len(s)-1 { | |
| 11 | + | return "", "", false | |
| 12 | + | } | |
| 13 | + | afterAt := s[at+1:] | |
| 14 | + | colon := strings.LastIndexByte(afterAt, ':') | |
| 15 | + | if colon < 0 { | |
| 16 | + | return s, "", true | |
| 17 | + | } | |
| 18 | + | if colon == 0 { | |
| 19 | + | return "", "", false | |
| 20 | + | } | |
| 21 | + | return s[:at+1+colon], afterAt[colon+1:], true | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // parseRemoteAttachTarget distinguishes remote attach syntax from ordinary | |||
| 6 | + | // local session names. Only values with a user@host prefix are remote; local | |||
| 7 | + | // names may still contain ':' when they do not contain '@'. | |||
| 8 | + | func parseRemoteAttachTarget(s string) (target, session string, ok bool) { | |||
| 9 | + | at := strings.LastIndexByte(s, '@') | |||
| 10 | + | if at <= 0 || at == len(s)-1 { | |||
| 11 | + | return "", "", false | |||
| 12 | + | } | |||
| 13 | + | afterAt := s[at+1:] | |||
| 14 | + | colon := strings.LastIndexByte(afterAt, ':') | |||
| 15 | + | if colon < 0 { | |||
| 16 | + | return s, "", true | |||
| 17 | + | } | |||
| 18 | + | if colon == 0 { | |||
| 19 | + | return "", "", false | |||
| 20 | + | } | |||
| 21 | + | return s[:at+1+colon], afterAt[colon+1:], true | |||
| 22 | + | } | |||
A internal/app/remote_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestParseRemoteAttachTarget(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | input string | |
| 9 | + | wantTarget string | |
| 10 | + | wantSession string | |
| 11 | + | wantOK bool | |
| 12 | + | }{ | |
| 13 | + | {name: "local plain", input: "work"}, | |
| 14 | + | {name: "local with colon", input: "work:logs"}, | |
| 15 | + | {name: "missing user", input: "@host"}, | |
| 16 | + | {name: "missing host", input: "user@"}, | |
| 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}, | |
| 19 | + | {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true}, | |
| 20 | + | {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true}, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | gotTarget, gotSession, gotOK := parseRemoteAttachTarget(tt.input) | |
| 26 | + | if gotOK != tt.wantOK { | |
| 27 | + | t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) | |
| 28 | + | } | |
| 29 | + | if gotTarget != tt.wantTarget { | |
| 30 | + | t.Fatalf("target = %q, want %q", gotTarget, tt.wantTarget) | |
| 31 | + | } | |
| 32 | + | if gotSession != tt.wantSession { | |
| 33 | + | t.Fatalf("session = %q, want %q", gotSession, tt.wantSession) | |
| 34 | + | } | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestParseRemoteAttachTarget(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | input string | |||
| 9 | + | wantTarget string | |||
| 10 | + | wantSession string | |||
| 11 | + | wantOK bool | |||
| 12 | + | }{ | |||
| 13 | + | {name: "local plain", input: "work"}, | |||
| 14 | + | {name: "local with colon", input: "work:logs"}, | |||
| 15 | + | {name: "missing user", input: "@host"}, | |||
| 16 | + | {name: "missing host", input: "user@"}, | |||
| 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}, | |||
| 19 | + | {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true}, | |||
| 20 | + | {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true}, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | gotTarget, gotSession, gotOK := parseRemoteAttachTarget(tt.input) | |||
| 26 | + | if gotOK != tt.wantOK { | |||
| 27 | + | t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) | |||
| 28 | + | } | |||
| 29 | + | if gotTarget != tt.wantTarget { | |||
| 30 | + | t.Fatalf("target = %q, want %q", gotTarget, tt.wantTarget) | |||
| 31 | + | } | |||
| 32 | + | if gotSession != tt.wantSession { | |||
| 33 | + | t.Fatalf("session = %q, want %q", gotSession, tt.wantSession) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M internal/app/run.go +76 -7
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "github.com/bnema/vev/internal/adapters/clock" | |
| 21 | 21 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 22 | 22 | "github.com/bnema/vev/internal/adapters/pty" | |
| 23 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 23 | 24 | "github.com/bnema/vev/internal/adapters/term" | |
| 24 | 25 | "github.com/bnema/vev/internal/ports" | |
| 25 | 26 | "github.com/bnema/vev/internal/usecase/client" | |
| @@ -34,6 +35,7 @@ const ( | |||
| 34 | 35 | kindList | |
| 35 | 36 | kindKill | |
| 36 | 37 | kindDaemon | |
| 38 | + | kindStdio | |
| 37 | 39 | kindHelp | |
| 38 | 40 | kindVersion | |
| 39 | 41 | ) | |
| @@ -41,9 +43,10 @@ const ( | |||
| 41 | 43 | // command is the parsed CLI invocation: what to do, plus the attach intent | |
| 42 | 44 | // and session name where relevant. | |
| 43 | 45 | type command struct { | |
| 44 | - | kind cmdKind | |
| 45 | - | intent uint8 | |
| 46 | - | name string | |
| 46 | + | kind cmdKind | |
| 47 | + | intent uint8 | |
| 48 | + | name string | |
| 49 | + | remoteTarget string | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -62,6 +65,8 @@ usage: | |||
| 62 | 65 | vev attach to (or create) an ephemeral session | |
| 63 | 66 | vev new <name> create and attach to a named session | |
| 64 | 67 | vev attach <name> attach to an existing named session (alias: a) | |
| 68 | + | vev attach user@host[:session] | |
| 69 | + | attach through SSH to a remote vev daemon | |
| 65 | 70 | vev ls list sessions | |
| 66 | 71 | vev kill <name> kill a named session | |
| 67 | 72 | vev --help show this help | |
| @@ -92,6 +97,15 @@ func parseArgs(args []string) (command, error) { | |||
| 92 | 97 | switch args[0] { | |
| 93 | 98 | case "--daemon": | |
| 94 | 99 | return command{kind: kindDaemon}, nil | |
| 100 | + | case "_stdio": | |
| 101 | + | if len(args) > 2 { | |
| 102 | + | return command{}, usagef("`_stdio` accepts at most one session name") | |
| 103 | + | } | |
| 104 | + | cmd := command{kind: kindStdio} | |
| 105 | + | if len(args) == 2 { | |
| 106 | + | cmd.name = args[1] | |
| 107 | + | } | |
| 108 | + | return cmd, nil | |
| 95 | 109 | case "new": | |
| 96 | 110 | if len(args) < 2 || args[1] == "" { | |
| 97 | 111 | return command{}, usagef("`new` requires a session name") | |
| @@ -101,7 +115,12 @@ func parseArgs(args []string) (command, error) { | |||
| 101 | 115 | if len(args) < 2 || args[1] == "" { | |
| 102 | 116 | return command{}, usagef("`attach` requires a session name") | |
| 103 | 117 | } | |
| 104 | - | return command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}, nil | |
| 118 | + | cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]} | |
| 119 | + | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | |
| 120 | + | cmd.remoteTarget = target | |
| 121 | + | cmd.name = session | |
| 122 | + | } | |
| 123 | + | return cmd, nil | |
| 105 | 124 | case "ls", "list": | |
| 106 | 125 | return command{kind: kindList}, nil | |
| 107 | 126 | case "kill": | |
| @@ -129,12 +148,14 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 129 | 148 | return nil | |
| 130 | 149 | case kindDaemon: | |
| 131 | 150 | return runDaemon() | |
| 151 | + | case kindStdio: | |
| 152 | + | return runStdio(ctx) | |
| 132 | 153 | case kindList: | |
| 133 | 154 | return runList(ctx) | |
| 134 | 155 | case kindKill: | |
| 135 | 156 | return runKill(ctx, cmd.name) | |
| 136 | 157 | case kindAttach: | |
| 137 | - | return runAttach(ctx, cmd.intent, cmd.name) | |
| 158 | + | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | |
| 138 | 159 | default: | |
| 139 | 160 | return usagef("unhandled command") | |
| 140 | 161 | } | |
| @@ -182,14 +203,19 @@ func runDaemon() error { | |||
| 182 | 203 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |
| 183 | 204 | // attach loop. Logging goes to the shared file: the client must never write | |
| 184 | 205 | // to the console while the terminal is raw. | |
| 185 | - | func runAttach(ctx context.Context, intent uint8, name string) error { | |
| 206 | + | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | |
| 186 | 207 | logFile, err := setupLogging() | |
| 187 | 208 | if err != nil { | |
| 188 | 209 | return err | |
| 189 | 210 | } | |
| 190 | 211 | defer func() { _ = logFile.Close() }() | |
| 191 | 212 | ||
| 192 | - | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 213 | + | var transport ports.Transport | |
| 214 | + | if remoteTarget != "" { | |
| 215 | + | transport, err = sshstdio.Dial(remoteTarget, name) | |
| 216 | + | } else { | |
| 217 | + | transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 218 | + | } | |
| 193 | 219 | if err != nil { | |
| 194 | 220 | return err | |
| 195 | 221 | } | |
| @@ -197,6 +223,49 @@ func runAttach(ctx context.Context, intent uint8, name string) error { | |||
| 197 | 223 | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 198 | 224 | } | |
| 199 | 225 | ||
| 226 | + | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | |
| 227 | + | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | |
| 228 | + | // framed protocol between process stdio and the daemon socket. | |
| 229 | + | func runStdio(ctx context.Context) error { | |
| 230 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 231 | + | if err != nil { | |
| 232 | + | return err | |
| 233 | + | } | |
| 234 | + | defer func() { _ = transport.Close() }() | |
| 235 | + | ||
| 236 | + | stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil) | |
| 237 | + | return proxyTransports(ctx, stdio, transport) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func proxyTransports(ctx context.Context, a, b ports.Transport) error { | |
| 241 | + | errCh := make(chan error, 2) | |
| 242 | + | copyFrames := func(dst, src ports.Transport) { | |
| 243 | + | for { | |
| 244 | + | f, err := src.Recv() | |
| 245 | + | if err != nil { | |
| 246 | + | errCh <- err | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | if err := dst.Send(f); err != nil { | |
| 250 | + | errCh <- err | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | go copyFrames(b, a) | |
| 256 | + | go copyFrames(a, b) | |
| 257 | + | ||
| 258 | + | select { | |
| 259 | + | case <-ctx.Done(): | |
| 260 | + | return ctx.Err() | |
| 261 | + | case err := <-errCh: | |
| 262 | + | if errors.Is(err, io.EOF) { | |
| 263 | + | return nil | |
| 264 | + | } | |
| 265 | + | return err | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 200 | 269 | // runList prints the daemon's session listing. With no daemon running there | |
| 201 | 270 | // are, by definition, no sessions. | |
| 202 | 271 | func runList(_ context.Context) error { | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "github.com/bnema/vev/internal/adapters/clock" | 20 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 21 | "github.com/bnema/vev/internal/adapters/ipc" | 21 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 22 | "github.com/bnema/vev/internal/adapters/pty" | 22 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 23 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 23 | "github.com/bnema/vev/internal/adapters/term" | 24 | "github.com/bnema/vev/internal/adapters/term" | ||
| 24 | "github.com/bnema/vev/internal/ports" | 25 | "github.com/bnema/vev/internal/ports" | ||
| 25 | "github.com/bnema/vev/internal/usecase/client" | 26 | "github.com/bnema/vev/internal/usecase/client" | ||
| @@ -34,6 +35,7 @@ const ( | |||||
| 34 | kindList | 35 | kindList | ||
| 35 | kindKill | 36 | kindKill | ||
| 36 | kindDaemon | 37 | kindDaemon | ||
| 38 | + | kindStdio | |||
| 37 | kindHelp | 39 | kindHelp | ||
| 38 | kindVersion | 40 | kindVersion | ||
| 39 | ) | 41 | ) | ||
| @@ -41,9 +43,10 @@ const ( | |||||
| 41 | // command is the parsed CLI invocation: what to do, plus the attach intent | 43 | // command is the parsed CLI invocation: what to do, plus the attach intent | ||
| 42 | // and session name where relevant. | 44 | // and session name where relevant. | ||
| 43 | type command struct { | 45 | type command struct { | ||
| 44 | - | kind cmdKind | 46 | + | kind cmdKind |
| 45 | - | intent uint8 | 47 | + | intent uint8 |
| 46 | - | name string | 48 | + | name string |
| 49 | + | remoteTarget string | |||
| 47 | } | 50 | } | ||
| 48 | 51 | ||||
| 49 | // usageError is a user-facing argument error; the app prints it (with usage) | 52 | // usageError is a user-facing argument error; the app prints it (with usage) | ||
| @@ -62,6 +65,8 @@ usage: | |||||
| 62 | vev attach to (or create) an ephemeral session | 65 | vev attach to (or create) an ephemeral session | ||
| 63 | vev new <name> create and attach to a named session | 66 | vev new <name> create and attach to a named session | ||
| 64 | vev attach <name> attach to an existing named session (alias: a) | 67 | vev attach <name> attach to an existing named session (alias: a) | ||
| 68 | + | vev attach user@host[:session] | |||
| 69 | + | attach through SSH to a remote vev daemon | |||
| 65 | vev ls list sessions | 70 | vev ls list sessions | ||
| 66 | vev kill <name> kill a named session | 71 | vev kill <name> kill a named session | ||
| 67 | vev --help show this help | 72 | vev --help show this help | ||
| @@ -92,6 +97,15 @@ func parseArgs(args []string) (command, error) { | |||||
| 92 | switch args[0] { | 97 | switch args[0] { | ||
| 93 | case "--daemon": | 98 | case "--daemon": | ||
| 94 | return command{kind: kindDaemon}, nil | 99 | return command{kind: kindDaemon}, nil | ||
| 100 | + | case "_stdio": | |||
| 101 | + | if len(args) > 2 { | |||
| 102 | + | return command{}, usagef("`_stdio` accepts at most one session name") | |||
| 103 | + | } | |||
| 104 | + | cmd := command{kind: kindStdio} | |||
| 105 | + | if len(args) == 2 { | |||
| 106 | + | cmd.name = args[1] | |||
| 107 | + | } | |||
| 108 | + | return cmd, nil | |||
| 95 | case "new": | 109 | case "new": | ||
| 96 | if len(args) < 2 || args[1] == "" { | 110 | if len(args) < 2 || args[1] == "" { | ||
| 97 | return command{}, usagef("`new` requires a session name") | 111 | return command{}, usagef("`new` requires a session name") | ||
| @@ -101,7 +115,12 @@ func parseArgs(args []string) (command, error) { | |||||
| 101 | if len(args) < 2 || args[1] == "" { | 115 | if len(args) < 2 || args[1] == "" { | ||
| 102 | return command{}, usagef("`attach` requires a session name") | 116 | return command{}, usagef("`attach` requires a session name") | ||
| 103 | } | 117 | } | ||
| 104 | - | return command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}, nil | 118 | + | cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]} |
| 119 | + | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | |||
| 120 | + | cmd.remoteTarget = target | |||
| 121 | + | cmd.name = session | |||
| 122 | + | } | |||
| 123 | + | return cmd, nil | |||
| 105 | case "ls", "list": | 124 | case "ls", "list": | ||
| 106 | return command{kind: kindList}, nil | 125 | return command{kind: kindList}, nil | ||
| 107 | case "kill": | 126 | case "kill": | ||
| @@ -129,12 +148,14 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 129 | return nil | 148 | return nil | ||
| 130 | case kindDaemon: | 149 | case kindDaemon: | ||
| 131 | return runDaemon() | 150 | return runDaemon() | ||
| 151 | + | case kindStdio: | |||
| 152 | + | return runStdio(ctx) | |||
| 132 | case kindList: | 153 | case kindList: | ||
| 133 | return runList(ctx) | 154 | return runList(ctx) | ||
| 134 | case kindKill: | 155 | case kindKill: | ||
| 135 | return runKill(ctx, cmd.name) | 156 | return runKill(ctx, cmd.name) | ||
| 136 | case kindAttach: | 157 | case kindAttach: | ||
| 137 | - | return runAttach(ctx, cmd.intent, cmd.name) | 158 | + | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) |
| 138 | default: | 159 | default: | ||
| 139 | return usagef("unhandled command") | 160 | return usagef("unhandled command") | ||
| 140 | } | 161 | } | ||
| @@ -182,14 +203,19 @@ func runDaemon() error { | |||||
| 182 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | 203 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | ||
| 183 | // attach loop. Logging goes to the shared file: the client must never write | 204 | // attach loop. Logging goes to the shared file: the client must never write | ||
| 184 | // to the console while the terminal is raw. | 205 | // to the console while the terminal is raw. | ||
| 185 | - | func runAttach(ctx context.Context, intent uint8, name string) error { | 206 | + | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { |
| 186 | logFile, err := setupLogging() | 207 | logFile, err := setupLogging() | ||
| 187 | if err != nil { | 208 | if err != nil { | ||
| 188 | return err | 209 | return err | ||
| 189 | } | 210 | } | ||
| 190 | defer func() { _ = logFile.Close() }() | 211 | defer func() { _ = logFile.Close() }() | ||
| 191 | 212 | ||||
| 192 | - | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | 213 | + | var transport ports.Transport |
| 214 | + | if remoteTarget != "" { | |||
| 215 | + | transport, err = sshstdio.Dial(remoteTarget, name) | |||
| 216 | + | } else { | |||
| 217 | + | transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 218 | + | } | |||
| 193 | if err != nil { | 219 | if err != nil { | ||
| 194 | return err | 220 | return err | ||
| 195 | } | 221 | } | ||
| @@ -197,6 +223,49 @@ func runAttach(ctx context.Context, intent uint8, name string) error { | |||||
| 197 | return client.Attach(ctx, transport, term.New(), intent, name) | 223 | return client.Attach(ctx, transport, term.New(), intent, name) | ||
| 198 | } | 224 | } | ||
| 199 | 225 | ||||
| 226 | + | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | |||
| 227 | + | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | |||
| 228 | + | // framed protocol between process stdio and the daemon socket. | |||
| 229 | + | func runStdio(ctx context.Context) error { | |||
| 230 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 231 | + | if err != nil { | |||
| 232 | + | return err | |||
| 233 | + | } | |||
| 234 | + | defer func() { _ = transport.Close() }() | |||
| 235 | + | ||||
| 236 | + | stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil) | |||
| 237 | + | return proxyTransports(ctx, stdio, transport) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func proxyTransports(ctx context.Context, a, b ports.Transport) error { | |||
| 241 | + | errCh := make(chan error, 2) | |||
| 242 | + | copyFrames := func(dst, src ports.Transport) { | |||
| 243 | + | for { | |||
| 244 | + | f, err := src.Recv() | |||
| 245 | + | if err != nil { | |||
| 246 | + | errCh <- err | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | if err := dst.Send(f); err != nil { | |||
| 250 | + | errCh <- err | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | go copyFrames(b, a) | |||
| 256 | + | go copyFrames(a, b) | |||
| 257 | + | ||||
| 258 | + | select { | |||
| 259 | + | case <-ctx.Done(): | |||
| 260 | + | return ctx.Err() | |||
| 261 | + | case err := <-errCh: | |||
| 262 | + | if errors.Is(err, io.EOF) { | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 200 | // runList prints the daemon's session listing. With no daemon running there | 269 | // runList prints the daemon's session listing. With no daemon running there | ||
| 201 | // are, by definition, no sessions. | 270 | // are, by definition, no sessions. | ||
| 202 | func runList(_ context.Context) error { | 271 | func runList(_ context.Context) error { | ||
M internal/app/run_test.go +9 -0
| @@ -14,6 +14,7 @@ func TestParseArgs(t *testing.T) { | |||
| 14 | 14 | wantKind cmdKind | |
| 15 | 15 | wantIntent uint8 | |
| 16 | 16 | wantName string | |
| 17 | + | wantRemote string | |
| 17 | 18 | wantErr bool | |
| 18 | 19 | }{ | |
| 19 | 20 | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | |
| @@ -23,12 +24,17 @@ func TestParseArgs(t *testing.T) { | |||
| 23 | 24 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | |
| 24 | 25 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 25 | 26 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 27 | + | {name: "attach remote without session", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantRemote: "user@example.com"}, | |
| 28 | + | {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"}, | |
| 26 | 29 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | |
| 27 | 30 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 28 | 31 | {name: "list", args: []string{"list"}, wantKind: kindList}, | |
| 29 | 32 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | |
| 30 | 33 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | |
| 31 | 34 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 35 | + | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | |
| 36 | + | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | |
| 37 | + | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | |
| 32 | 38 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |
| 33 | 39 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |
| 34 | 40 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | |
| @@ -60,6 +66,9 @@ func TestParseArgs(t *testing.T) { | |||
| 60 | 66 | if got.name != tt.wantName { | |
| 61 | 67 | t.Errorf("name = %q, want %q", got.name, tt.wantName) | |
| 62 | 68 | } | |
| 69 | + | if got.remoteTarget != tt.wantRemote { | |
| 70 | + | t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote) | |
| 71 | + | } | |
| 63 | 72 | }) | |
| 64 | 73 | } | |
| 65 | 74 | } | |
| @@ -14,6 +14,7 @@ func TestParseArgs(t *testing.T) { | |||||
| 14 | wantKind cmdKind | 14 | wantKind cmdKind | ||
| 15 | wantIntent uint8 | 15 | wantIntent uint8 | ||
| 16 | wantName string | 16 | wantName string | ||
| 17 | + | wantRemote string | |||
| 17 | wantErr bool | 18 | wantErr bool | ||
| 18 | }{ | 19 | }{ | ||
| 19 | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | 20 | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | ||
| @@ -23,12 +24,17 @@ func TestParseArgs(t *testing.T) { | |||||
| 23 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | 24 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | ||
| 24 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | 25 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | ||
| 25 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | 26 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | ||
| 27 | + | {name: "attach remote without session", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantRemote: "user@example.com"}, | |||
| 28 | + | {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"}, | |||
| 26 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | 29 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | ||
| 27 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | 30 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | ||
| 28 | {name: "list", args: []string{"list"}, wantKind: kindList}, | 31 | {name: "list", args: []string{"list"}, wantKind: kindList}, | ||
| 29 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | 32 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | ||
| 30 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | 33 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | ||
| 31 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | 34 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | ||
| 35 | + | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | |||
| 36 | + | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | |||
| 37 | + | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | |||
| 32 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | 38 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | ||
| 33 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | 39 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | ||
| 34 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | 40 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | ||
| @@ -60,6 +66,9 @@ func TestParseArgs(t *testing.T) { | |||||
| 60 | if got.name != tt.wantName { | 66 | if got.name != tt.wantName { | ||
| 61 | t.Errorf("name = %q, want %q", got.name, tt.wantName) | 67 | t.Errorf("name = %q, want %q", got.name, tt.wantName) | ||
| 62 | } | 68 | } | ||
| 69 | + | if got.remoteTarget != tt.wantRemote { | |||
| 70 | + | t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote) | |||
| 71 | + | } | |||
| 63 | }) | 72 | }) | ||
| 64 | } | 73 | } | ||
| 65 | } | 74 | } | ||