1package dgram
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "io"
8 "net"
9 "strings"
10 "testing"
11 "time"
12
13 pdgram "github.com/bnema/vev/pkg/dgram"
14)
15
16type fakeBootstrapProcess struct {
17 stdout io.ReadCloser
18 startErr error
19 waitErr error
20 killed bool
21 waited bool
22}
23
24type closeTrackReadCloser struct {
25 io.Reader
26 closed *bool
27}
28
29func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
30
31func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
32func (p *fakeBootstrapProcess) Start() error { return p.startErr }
33func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35
36func 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
43func 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
50func 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: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63 {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP 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 var dialErr *RemoteDialError
83 if !errors.As(err, &dialErr) {
84 t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
85 }
86 if dialErr.Kind != RemoteDialBootstrapUnavailable {
87 t.Fatalf("Dial() error kind = %v, want bootstrap unavailable", dialErr.Kind)
88 }
89 if tr != nil {
90 t.Fatalf("Dial() transport = %T, want nil", tr)
91 }
92 if proc.killed != tt.killed {
93 t.Fatalf("killed=%v, want %v", proc.killed, tt.killed)
94 }
95 if proc.waited != tt.waited {
96 t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
97 }
98 if stdoutClosed != tt.wantStdoutClosed {
99 t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
100 }
101 })
102 }
103}
104
105func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
106 pc, err := listenUDPPacket(context.Background())
107 if err != nil {
108 t.Fatal(err)
109 }
110 defer func() { _ = pc.Close() }()
111 addr, ok := pc.LocalAddr().(*net.UDPAddr)
112 if !ok {
113 t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr())
114 }
115 if !addr.IP.IsUnspecified() {
116 t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP)
117 }
118}
119
120func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123 withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
124 if target != "127.0.0.1" || session != "work" {
125 t.Fatalf("bootstrap target/session = %q/%q", target, session)
126 }
127 return proc
128 })
129 var lc net.ListenConfig
130 pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
131 if err != nil {
132 t.Fatal(err)
133 }
134 closed := false
135 withListenUDP(t, func(context.Context) (net.PacketConn, error) {
136 return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
137 })
138 d := NewRemoteDialer("127.0.0.1", "work")
139 d.ProbeTimeout = time.Nanosecond
140 tr, err := d.Dial(context.Background())
141 if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
142 t.Fatalf("Dial() error = %v, want probe unavailable", err)
143 }
144 var dialErr *RemoteDialError
145 if !errors.As(err, &dialErr) {
146 t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
147 }
148 if dialErr.Kind != RemoteDialProbeUnreachable {
149 t.Fatalf("Dial() error kind = %v, want probe unreachable", dialErr.Kind)
150 }
151 for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
152 if !strings.Contains(err.Error(), want) {
153 t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
154 }
155 }
156 if tr != nil {
157 t.Fatalf("transport=%T, want nil", tr)
158 }
159 if !closed {
160 t.Fatal("packet conn not closed after probe failure")
161 }
162 if proc.killed {
163 t.Fatal("bootstrap process killed after it was already waited")
164 }
165 if !proc.waited {
166 t.Fatal("bootstrap process not waited after probe failure")
167 }
168}
169
170type closeTrackPacketConn struct {
171 net.PacketConn
172 closed *bool
173}
174
175func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176
177func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
178 ctx, cancel := context.WithCancel(context.Background())
179 cancel()
180 key := make([]byte, pdgram.KeySize)
181 for i := range key {
182 key[i] = byte(i + 1)
183 }
184 ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
185 if err != nil {
186 t.Fatal(err)
187 }
188
189 deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
190
191 for i, b := range ready.key {
192 if b != 0 {
193 t.Fatalf("key[%d] = %d, want erased", i, b)
194 }
195 }
196}
197
198func TestReadUDPReady(t *testing.T) {
199 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
200 tests := []struct {
201 name, line string
202 wantPort int
203 wantErr string
204 exactErr bool
205 }{
206 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
207 {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
208 {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
209 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
210 }
211 for _, tt := range tests {
212 t.Run(tt.name, func(t *testing.T) {
213 got, err := readUDPReady(strings.NewReader(tt.line))
214 if tt.wantErr != "" {
215 if err == nil {
216 t.Fatalf("err=nil, want %q", tt.wantErr)
217 }
218 if tt.exactErr && err.Error() != tt.wantErr {
219 t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
220 }
221 if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
222 t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
223 }
224 return
225 }
226 if err != nil {
227 t.Fatal(err)
228 }
229 if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
230 t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
231 }
232 })
233 }
234}
235
236func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
237 var b limitedBuffer
238 chunk := strings.Repeat("x", maxBootstrapStderr+1024)
239 n, err := b.Write([]byte(chunk))
240 if err != nil {
241 t.Fatal(err)
242 }
243 if n != len(chunk) {
244 t.Fatalf("n=%d, want %d", n, len(chunk))
245 }
246 if got := len(b.String()); got != maxBootstrapStderr {
247 t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
248 }
249}
250
251func TestSSHTargetHost(t *testing.T) {
252 tests := []struct {
253 target string
254 want string
255 }{
256 {target: "example.com", want: "example.com"},
257 {target: "user@example.com", want: "example.com"},
258 {target: "example.com:2222", want: "example.com"},
259 {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
260 }
261 for _, tt := range tests {
262 t.Run(tt.target, func(t *testing.T) {
263 if got := sshTargetHost(tt.target); got != tt.want {
264 t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
265 }
266 })
267 }
268}