1package dgram
2
3import (
4 "bufio"
5 "context"
6 "encoding/base64"
7 "fmt"
8 "io"
9 "log/slog"
10 "net"
11 "os/exec"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/bnema/vev/internal/adapters/sshstdio"
17 "github.com/bnema/vev/internal/ports"
18 pdgram "github.com/bnema/vev/pkg/dgram"
19)
20
21const (
22 maxBootstrapStderr = 64 * 1024
23 defaultBootstrapTimeout = 10 * time.Second
24 defaultProbeTimeout = 3 * time.Second
25)
26
27type bootstrapProcess interface {
28 StdoutPipe() (io.ReadCloser, error)
29 Start() error
30 Kill() error
31 Wait() error
32}
33
34type execBootstrapProcess struct{ cmd *exec.Cmd }
35
36func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
37func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
38func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
39func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40
41var (
42 startUDPBootstrap = func(ctx context.Context, target string, stderr io.Writer) bootstrapProcess {
43 spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
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) }
53)
54
55// RemoteDialFailureKind classifies which part of UDP remote bootstrap failed
56// so client-side recovery policy and tests do not have to parse human text.
57type RemoteDialFailureKind uint8
58
59const (
60 RemoteDialBootstrapUnavailable RemoteDialFailureKind = iota + 1
61 RemoteDialProbeUnreachable
62)
63
64// RemoteDialError wraps a UDP remote bootstrap failure with a stable machine
65// classification while preserving the existing human-facing error text.
66type RemoteDialError struct {
67 Kind RemoteDialFailureKind
68 Action string
69 Err error
70 Hint string
71}
72
73func (e *RemoteDialError) Error() string {
74 base := fmt.Sprintf("remote UDP transport unavailable: %s: %v", e.Action, e.Err)
75 if e.Hint != "" {
76 return base + e.Hint
77 }
78 return base
79}
80
81func (e *RemoteDialError) Unwrap() error { return e.Err }
82
83type limitedBuffer struct {
84 buf []byte
85}
86
87func (b *limitedBuffer) Write(p []byte) (int, error) {
88 if len(b.buf) < maxBootstrapStderr {
89 keep := min(len(p), maxBootstrapStderr-len(b.buf))
90 b.buf = append(b.buf, p[:keep]...)
91 }
92 return len(p), nil
93}
94
95func (b *limitedBuffer) String() string { return string(b.buf) }
96
97// RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
98// attach. SSH stdio is selected only through the explicit stdio transport mode.
99type RemoteDialer struct {
100 Target string
101 BootstrapTimeout time.Duration
102 ProbeTimeout time.Duration
103 Log *slog.Logger
104 RuntimeObserver ports.SerializedRuntimeObserver
105}
106
107func NewRemoteDialer(target, _ string) RemoteDialer {
108 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout}
109}
110
111func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer {
112 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
113}
114
115func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
116 if err := ctx.Err(); err != nil {
117 return nil, err
118 }
119 bootstrapTimeout := d.BootstrapTimeout
120 if bootstrapTimeout <= 0 {
121 bootstrapTimeout = defaultBootstrapTimeout
122 }
123 probeTimeout := d.ProbeTimeout
124 if probeTimeout <= 0 {
125 probeTimeout = defaultProbeTimeout
126 }
127 bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
128 defer bootstrapCancel()
129
130 var stderr limitedBuffer
131 proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr)
132 stdout, err := proc.StdoutPipe()
133 if err != nil {
134 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
135 }
136 if err := proc.Start(); err != nil {
137 _ = stdout.Close()
138 return nil, udpUnavailable("start bootstrap", err, &stderr)
139 }
140 cleanup := true
141 waited := false
142 defer func() {
143 if cleanup {
144 _ = proc.Kill()
145 }
146 if !waited {
147 _ = proc.Wait()
148 }
149 _ = stdout.Close()
150 }()
151
152 ready, err := readUDPReadyContext(bootstrapCtx, stdout)
153 if err != nil {
154 _ = proc.Kill()
155 _ = proc.Wait()
156 waited = true
157 cleanup = false
158 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
159 }
160 defer pdgram.Erase(ready.key)
161 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
162 waited = true
163 cleanup = false
164 return nil, udpUnavailable("wait bootstrap", err, &stderr)
165 }
166 waited = true
167 cleanup = false
168 peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
169 if err != nil {
170 return nil, udpUnavailable("resolve UDP peer", err, &stderr)
171 }
172 pc, err := listenUDPPacket(ctx)
173 if err != nil {
174 return nil, udpUnavailable("listen UDP", err, &stderr)
175 }
176 // RebindPacketConn heals NAT rebinds / stale conntrack paths by hopping to a
177 // fresh local UDP socket once the link is silent long enough to probe. The
178 // bind reuses listenUDPPacket so tests can stub it.
179 t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{
180 Observe: DiagnosticLogObserver(d.Log),
181 RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
182 return listenUDPPacket(ctx)
183 },
184 }, WithRuntimeObserver(d.RuntimeObserver))
185 pdgram.Erase(ready.key)
186 if err != nil {
187 _ = pc.Close()
188 return nil, udpUnavailable("create UDP transport", err, &stderr)
189 }
190 probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
191 defer cancel()
192 if err := probeUDPTransport(probeCtx, t); err != nil {
193 _ = t.Close()
194 return nil, udpProbeUnreachable(d.Target, err, &stderr)
195 }
196 cleanup = false
197 return t, nil
198}
199
200type udpReadyResult struct {
201 ready udpReady
202 err error
203}
204
205func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
206 select {
207 case ch <- res:
208 case <-ctx.Done():
209 pdgram.Erase(res.ready.key)
210 }
211}
212
213func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
214 ch := make(chan udpReadyResult)
215 go func() {
216 ready, err := readUDPReady(r)
217 deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
218 }()
219 select {
220 case res := <-ch:
221 return res.ready, res.err
222 case <-ctx.Done():
223 return udpReady{}, ctx.Err()
224 }
225}
226
227func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
228 ch := make(chan error, 1)
229 go func() { ch <- proc.Wait() }()
230 select {
231 case err := <-ch:
232 return err
233 case <-ctx.Done():
234 _ = proc.Kill()
235 return ctx.Err()
236 }
237}
238
239func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) {
240 host := sshTargetHost(target)
241 if ip := net.ParseIP(host); ip != nil {
242 return &net.UDPAddr{IP: ip, Port: port}, nil
243 }
244 addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
245 if err != nil {
246 return nil, err
247 }
248 if len(addrs) == 0 {
249 return nil, fmt.Errorf("no UDP peer addresses for %q", host)
250 }
251 return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil
252}
253
254type udpReady struct {
255 port int
256 key []byte
257}
258
259func readUDPReady(r io.Reader) (udpReady, error) {
260 out := make([]byte, pdgram.KeySize)
261 var (
262 port int
263 keyLen int
264 parseErr error
265 )
266 pdgram.SecretDo(func() {
267 line, err := bufio.NewReader(r).ReadString('\n')
268 if err != nil && line == "" {
269 parseErr = err
270 return
271 }
272
273 fields := strings.Fields(line)
274 if len(fields) != 3 || fields[0] != "VEV-UDP" {
275 parseErr = fmt.Errorf("malformed UDP readiness line")
276 return
277 }
278 port, parseErr = strconv.Atoi(fields[1])
279 if parseErr != nil || port < 1 || port > 65535 {
280 parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
281 return
282 }
283 key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
284 defer pdgram.Erase(key)
285 if decodeErr != nil {
286 parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
287 return
288 }
289 keyLen = len(key)
290 copy(out, key)
291 })
292 if parseErr != nil {
293 pdgram.Erase(out)
294 return udpReady{}, parseErr
295 }
296 if keyLen != pdgram.KeySize {
297 pdgram.Erase(out)
298 return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
299 }
300 return udpReady{port: port, key: out}, nil
301}
302
303func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
304 return udpDialFailure(RemoteDialBootstrapUnavailable, action, err, stderr, "")
305}
306
307func udpDialFailure(kind RemoteDialFailureKind, action string, err error, stderr fmt.Stringer, hint string) error {
308 if msg := strings.TrimSpace(stderr.String()); msg != "" {
309 hint = fmt.Sprintf(" (stderr: %s)%s", msg, hint)
310 }
311 return &RemoteDialError{Kind: kind, Action: action, Err: err, Hint: hint}
312}
313
314// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
315// probe almost always means a firewall on the remote is dropping datagrams to
316// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
317// surfacing a bare deadline error.
318func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
319 return udpDialFailure(RemoteDialProbeUnreachable, "probe UDP transport", err, stderr, fmt.Sprintf("\n"+
320 " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
321 " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`", target))
322}
323
324func sshTargetHost(target string) string {
325 if at := strings.LastIndexByte(target, '@'); at >= 0 {
326 target = target[at+1:]
327 }
328 if h, _, err := net.SplitHostPort(target); err == nil {
329 return h
330 }
331 if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
332 return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
333 }
334 return target
335}