v0.2.0 80c61e3

ssh_dialer.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close