feat(remote): detach udp proxy from ssh bootstrap
- commit
- 283db0fdafb1d585811f261c7079db4b1a430dcb
- author
- brice · · 2026-07-06 21:13 UTC
- parent
- 380112a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +132 -19
M internal/adapters/dgram/ssh_dialer.go +14 -3
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 17 | 17 | "github.com/bnema/vev/internal/ports" | |
| 18 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | const ( | |
| @@ -41,7 +42,11 @@ var ( | |||
| 41 | 42 | cmd.Stderr = stderr | |
| 42 | 43 | return execBootstrapProcess{cmd: cmd} | |
| 43 | 44 | } | |
| 44 | - | listenUDPPacket = func() (net.PacketConn, error) { return net.ListenPacket("udp", ":0") } | |
| 45 | + | listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) { | |
| 46 | + | var lc net.ListenConfig | |
| 47 | + | return lc.ListenPacket(ctx, "udp", ":0") | |
| 48 | + | } | |
| 49 | + | probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) } | |
| 45 | 50 | ) | |
| 46 | 51 | ||
| 47 | 52 | type limitedBuffer struct { | |
| @@ -108,7 +113,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 108 | 113 | if err != nil { | |
| 109 | 114 | return nil, udpUnavailable("resolve UDP peer", err, &stderr) | |
| 110 | 115 | } | |
| 111 | - | pc, err := listenUDPPacket() | |
| 116 | + | pc, err := listenUDPPacket(ctx) | |
| 112 | 117 | if err != nil { | |
| 113 | 118 | return nil, udpUnavailable("listen UDP", err, &stderr) | |
| 114 | 119 | } | |
| @@ -119,7 +124,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 119 | 124 | } | |
| 120 | 125 | probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) | |
| 121 | 126 | defer cancel() | |
| 122 | - | if err := t.Probe(probeCtx); err != nil { | |
| 127 | + | if err := probeUDPTransport(probeCtx, t); err != nil { | |
| 123 | 128 | _ = t.Close() | |
| 124 | 129 | return nil, udpUnavailable("probe UDP transport", err, &stderr) | |
| 125 | 130 | } | |
| @@ -149,6 +154,9 @@ func readUDPReady(r io.Reader) (udpReady, error) { | |||
| 149 | 154 | if err != nil { | |
| 150 | 155 | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | |
| 151 | 156 | } | |
| 157 | + | if len(key) != pdgram.KeySize { | |
| 158 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key)) | |
| 159 | + | } | |
| 152 | 160 | return udpReady{port: port, key: key}, nil | |
| 153 | 161 | } | |
| 154 | 162 | ||
| @@ -167,5 +175,8 @@ func sshTargetHost(target string) string { | |||
| 167 | 175 | if h, _, err := net.SplitHostPort(target); err == nil { | |
| 168 | 176 | return h | |
| 169 | 177 | } | |
| 178 | + | if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { | |
| 179 | + | return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[") | |
| 180 | + | } | |
| 170 | 181 | return target | |
| 171 | 182 | } | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 18 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | const ( | 21 | const ( | ||
| @@ -41,7 +42,11 @@ var ( | |||||
| 41 | cmd.Stderr = stderr | 42 | cmd.Stderr = stderr | ||
| 42 | return execBootstrapProcess{cmd: cmd} | 43 | return execBootstrapProcess{cmd: cmd} | ||
| 43 | } | 44 | } | ||
| 44 | - | listenUDPPacket = func() (net.PacketConn, error) { return net.ListenPacket("udp", ":0") } | 45 | + | listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) { |
| 46 | + | var lc net.ListenConfig | |||
| 47 | + | return lc.ListenPacket(ctx, "udp", ":0") | |||
| 48 | + | } | |||
| 49 | + | probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) } | |||
| 45 | ) | 50 | ) | ||
| 46 | 51 | ||||
| 47 | type limitedBuffer struct { | 52 | type limitedBuffer struct { | ||
| @@ -108,7 +113,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 108 | if err != nil { | 113 | if err != nil { | ||
| 109 | return nil, udpUnavailable("resolve UDP peer", err, &stderr) | 114 | return nil, udpUnavailable("resolve UDP peer", err, &stderr) | ||
| 110 | } | 115 | } | ||
| 111 | - | pc, err := listenUDPPacket() | 116 | + | pc, err := listenUDPPacket(ctx) |
| 112 | if err != nil { | 117 | if err != nil { | ||
| 113 | return nil, udpUnavailable("listen UDP", err, &stderr) | 118 | return nil, udpUnavailable("listen UDP", err, &stderr) | ||
| 114 | } | 119 | } | ||
| @@ -119,7 +124,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 119 | } | 124 | } | ||
| 120 | probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) | 125 | probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) | ||
| 121 | defer cancel() | 126 | defer cancel() | ||
| 122 | - | if err := t.Probe(probeCtx); err != nil { | 127 | + | if err := probeUDPTransport(probeCtx, t); err != nil { |
| 123 | _ = t.Close() | 128 | _ = t.Close() | ||
| 124 | return nil, udpUnavailable("probe UDP transport", err, &stderr) | 129 | return nil, udpUnavailable("probe UDP transport", err, &stderr) | ||
| 125 | } | 130 | } | ||
| @@ -149,6 +154,9 @@ func readUDPReady(r io.Reader) (udpReady, error) { | |||||
| 149 | if err != nil { | 154 | if err != nil { | ||
| 150 | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | 155 | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | ||
| 151 | } | 156 | } | ||
| 157 | + | if len(key) != pdgram.KeySize { | |||
| 158 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key)) | |||
| 159 | + | } | |||
| 152 | return udpReady{port: port, key: key}, nil | 160 | return udpReady{port: port, key: key}, nil | ||
| 153 | } | 161 | } | ||
| 154 | 162 | ||||
| @@ -167,5 +175,8 @@ func sshTargetHost(target string) string { | |||||
| 167 | if h, _, err := net.SplitHostPort(target); err == nil { | 175 | if h, _, err := net.SplitHostPort(target); err == nil { | ||
| 168 | return h | 176 | return h | ||
| 169 | } | 177 | } | ||
| 178 | + | if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { | |||
| 179 | + | return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[") | |||
| 180 | + | } | |||
| 170 | return target | 181 | return target | ||
| 171 | } | 182 | } | ||
M internal/adapters/dgram/ssh_dialer_test.go +5 -3
| @@ -99,7 +99,7 @@ func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, | |||
| 99 | 99 | t.Cleanup(func() { startUDPBootstrap = old }) | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | func withListenUDP(t *testing.T, fn func() (net.PacketConn, error)) { | |
| 102 | + | func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) { | |
| 103 | 103 | t.Helper() | |
| 104 | 104 | old := listenUDPPacket | |
| 105 | 105 | listenUDPPacket = fn | |
| @@ -125,7 +125,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |||
| 125 | 125 | proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start} | |
| 126 | 126 | withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc }) | |
| 127 | 127 | if tt.listen != nil { | |
| 128 | - | withListenUDP(t, func() (net.PacketConn, error) { return nil, tt.listen }) | |
| 128 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen }) | |
| 129 | 129 | } | |
| 130 | 130 | tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background()) | |
| 131 | 131 | if err == nil || !strings.Contains(err.Error(), tt.want) { | |
| @@ -155,7 +155,9 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) { | |||
| 155 | 155 | t.Fatal(err) | |
| 156 | 156 | } | |
| 157 | 157 | closed := false | |
| 158 | - | withListenUDP(t, func() (net.PacketConn, error) { return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil }) | |
| 158 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { | |
| 159 | + | return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil | |
| 160 | + | }) | |
| 159 | 161 | d := NewRemoteDialer("127.0.0.1", "work") | |
| 160 | 162 | d.ProbeTimeout = time.Nanosecond | |
| 161 | 163 | tr, err := d.Dial(context.Background()) | |
| @@ -99,7 +99,7 @@ func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, | |||||
| 99 | t.Cleanup(func() { startUDPBootstrap = old }) | 99 | t.Cleanup(func() { startUDPBootstrap = old }) | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | - | func withListenUDP(t *testing.T, fn func() (net.PacketConn, error)) { | 102 | + | func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) { |
| 103 | t.Helper() | 103 | t.Helper() | ||
| 104 | old := listenUDPPacket | 104 | old := listenUDPPacket | ||
| 105 | listenUDPPacket = fn | 105 | listenUDPPacket = fn | ||
| @@ -125,7 +125,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |||||
| 125 | proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start} | 125 | proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start} | ||
| 126 | withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc }) | 126 | withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc }) | ||
| 127 | if tt.listen != nil { | 127 | if tt.listen != nil { | ||
| 128 | - | withListenUDP(t, func() (net.PacketConn, error) { return nil, tt.listen }) | 128 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen }) |
| 129 | } | 129 | } | ||
| 130 | tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background()) | 130 | tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background()) | ||
| 131 | if err == nil || !strings.Contains(err.Error(), tt.want) { | 131 | if err == nil || !strings.Contains(err.Error(), tt.want) { | ||
| @@ -155,7 +155,9 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) { | |||||
| 155 | t.Fatal(err) | 155 | t.Fatal(err) | ||
| 156 | } | 156 | } | ||
| 157 | closed := false | 157 | closed := false | ||
| 158 | - | withListenUDP(t, func() (net.PacketConn, error) { return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil }) | 158 | + | withListenUDP(t, func(context.Context) (net.PacketConn, error) { |
| 159 | + | return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil | |||
| 160 | + | }) | |||
| 159 | d := NewRemoteDialer("127.0.0.1", "work") | 161 | d := NewRemoteDialer("127.0.0.1", "work") | ||
| 160 | d.ProbeTimeout = time.Nanosecond | 162 | d.ProbeTimeout = time.Nanosecond | ||
| 161 | tr, err := d.Dial(context.Background()) | 163 | tr, err := d.Dial(context.Background()) | ||
M internal/app/run.go +81 -13
| @@ -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" | |
| @@ -53,6 +55,7 @@ const ( | |||
| 53 | 55 | kindDaemon | |
| 54 | 56 | kindStdio | |
| 55 | 57 | kindUDPBootstrap | |
| 58 | + | kindUDPProxy | |
| 56 | 59 | kindHelp | |
| 57 | 60 | kindVersion | |
| 58 | 61 | ) | |
| @@ -136,6 +139,15 @@ func parseArgs(args []string) (command, error) { | |||
| 136 | 139 | cmd.name = args[1] | |
| 137 | 140 | } | |
| 138 | 141 | return cmd, nil | |
| 142 | + | case "_udp-proxy": | |
| 143 | + | if len(args) > 2 { | |
| 144 | + | return command{}, usagef("`_udp-proxy` accepts at most one session name") | |
| 145 | + | } | |
| 146 | + | cmd := command{kind: kindUDPProxy} | |
| 147 | + | if len(args) == 2 { | |
| 148 | + | cmd.name = args[1] | |
| 149 | + | } | |
| 150 | + | return cmd, nil | |
| 139 | 151 | case "new": | |
| 140 | 152 | if len(args) < 2 || args[1] == "" { | |
| 141 | 153 | return command{}, usagef("`new` requires a session name") | |
| @@ -210,6 +222,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 210 | 222 | return runStdio(ctx) | |
| 211 | 223 | case kindUDPBootstrap: | |
| 212 | 224 | return runUDPBootstrap(ctx, cmd.name) | |
| 225 | + | case kindUDPProxy: | |
| 226 | + | return runUDPProxy(ctx, cmd.name, os.Stdout) | |
| 213 | 227 | case kindList: | |
| 214 | 228 | return runList(ctx) | |
| 215 | 229 | case kindKill: | |
| @@ -607,23 +621,78 @@ func runStdio(ctx context.Context) error { | |||
| 607 | 621 | return proxyTransports(ctx, stdio, transport, log) | |
| 608 | 622 | } | |
| 609 | 623 | ||
| 610 | - | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | |
| 611 | - | // authenticated datagrams. It prints only an ephemeral UDP port and random | |
| 612 | - | // session key to stdout; stdio remains available only as an explicit remote | |
| 613 | - | // transport mode. | |
| 624 | + | var ( | |
| 625 | + | udpBootstrapTimeout = 5 * time.Second | |
| 626 | + | udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd { | |
| 627 | + | return exec.CommandContext(ctx, exe, args...) | |
| 628 | + | } | |
| 629 | + | ) | |
| 630 | + | ||
| 631 | + | // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness | |
| 632 | + | // line, and exits so SSH stdio can close without owning the UDP proxy lifetime. | |
| 614 | 633 | func runUDPBootstrap(ctx context.Context, session string) error { | |
| 615 | - | log, logCloser, err := configureLogging(logging.Stdio, false) | |
| 634 | + | r, w, err := os.Pipe() | |
| 616 | 635 | if err != nil { | |
| 617 | 636 | return err | |
| 618 | 637 | } | |
| 619 | - | defer func() { _ = logCloser.Close() }() | |
| 620 | - | log.Debug("udp bootstrap starting", "session", session) | |
| 638 | + | defer func() { _ = r.Close() }() | |
| 639 | + | ||
| 640 | + | exe, err := os.Executable() | |
| 641 | + | if err != nil { | |
| 642 | + | _ = w.Close() | |
| 643 | + | return err | |
| 644 | + | } | |
| 645 | + | args := []string{"_udp-proxy"} | |
| 646 | + | if session != "" { | |
| 647 | + | args = append(args, session) | |
| 648 | + | } | |
| 649 | + | cmd := udpProxyCommand(ctx, exe, args...) | |
| 650 | + | cmd.Stdin = nil | |
| 651 | + | cmd.Stdout = w | |
| 652 | + | cmd.Stderr = os.Stderr | |
| 653 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 654 | + | if err := cmd.Start(); err != nil { | |
| 655 | + | _ = w.Close() | |
| 656 | + | return err | |
| 657 | + | } | |
| 658 | + | _ = w.Close() | |
| 621 | 659 | ||
| 660 | + | readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout) | |
| 661 | + | defer cancel() | |
| 662 | + | lineCh := make(chan string, 1) | |
| 663 | + | errCh := make(chan error, 1) | |
| 664 | + | go func() { | |
| 665 | + | line, err := bufio.NewReader(r).ReadString('\n') | |
| 666 | + | if err != nil { | |
| 667 | + | errCh <- err | |
| 668 | + | return | |
| 669 | + | } | |
| 670 | + | lineCh <- line | |
| 671 | + | }() | |
| 622 | 672 | select { | |
| 623 | - | case <-ctx.Done(): | |
| 624 | - | return ctx.Err() | |
| 625 | - | default: | |
| 673 | + | case line := <-lineCh: | |
| 674 | + | if err := cmd.Process.Release(); err != nil { | |
| 675 | + | return err | |
| 676 | + | } | |
| 677 | + | _, err := fmt.Fprint(os.Stdout, line) | |
| 678 | + | return err | |
| 679 | + | case err := <-errCh: | |
| 680 | + | return err | |
| 681 | + | case <-readyCtx.Done(): | |
| 682 | + | _ = cmd.Process.Kill() | |
| 683 | + | return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err()) | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // runUDPProxy is the detached long-lived remote-side datagram proxy. | |
| 688 | + | func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |
| 689 | + | log, logCloser, err := configureLogging(logging.Stdio, false) | |
| 690 | + | if err != nil { | |
| 691 | + | return err | |
| 626 | 692 | } | |
| 693 | + | defer func() { _ = logCloser.Close() }() | |
| 694 | + | log.Debug("udp proxy starting", "session", session) | |
| 695 | + | ||
| 627 | 696 | var lc net.ListenConfig | |
| 628 | 697 | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | |
| 629 | 698 | if err != nil { | |
| @@ -647,12 +716,11 @@ func runUDPBootstrap(ctx context.Context, session string) error { | |||
| 647 | 716 | ||
| 648 | 717 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | |
| 649 | 718 | if !ok { | |
| 650 | - | return errors.New("vev: udp bootstrap did not get a UDP address") | |
| 719 | + | return errors.New("vev: udp proxy did not get a UDP address") | |
| 651 | 720 | } | |
| 652 | - | if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | |
| 721 | + | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | |
| 653 | 722 | return err | |
| 654 | 723 | } | |
| 655 | - | _ = os.Stdout.Sync() | |
| 656 | 724 | ||
| 657 | 725 | var dgramTr ports.Transport = dg | |
| 658 | 726 | 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" | ||
| @@ -53,6 +55,7 @@ const ( | |||||
| 53 | kindDaemon | 55 | kindDaemon | ||
| 54 | kindStdio | 56 | kindStdio | ||
| 55 | kindUDPBootstrap | 57 | kindUDPBootstrap | ||
| 58 | + | kindUDPProxy | |||
| 56 | kindHelp | 59 | kindHelp | ||
| 57 | kindVersion | 60 | kindVersion | ||
| 58 | ) | 61 | ) | ||
| @@ -136,6 +139,15 @@ func parseArgs(args []string) (command, error) { | |||||
| 136 | cmd.name = args[1] | 139 | cmd.name = args[1] | ||
| 137 | } | 140 | } | ||
| 138 | return cmd, nil | 141 | return cmd, nil | ||
| 142 | + | case "_udp-proxy": | |||
| 143 | + | if len(args) > 2 { | |||
| 144 | + | return command{}, usagef("`_udp-proxy` accepts at most one session name") | |||
| 145 | + | } | |||
| 146 | + | cmd := command{kind: kindUDPProxy} | |||
| 147 | + | if len(args) == 2 { | |||
| 148 | + | cmd.name = args[1] | |||
| 149 | + | } | |||
| 150 | + | return cmd, nil | |||
| 139 | case "new": | 151 | case "new": | ||
| 140 | if len(args) < 2 || args[1] == "" { | 152 | if len(args) < 2 || args[1] == "" { | ||
| 141 | return command{}, usagef("`new` requires a session name") | 153 | return command{}, usagef("`new` requires a session name") | ||
| @@ -210,6 +222,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 210 | return runStdio(ctx) | 222 | return runStdio(ctx) | ||
| 211 | case kindUDPBootstrap: | 223 | case kindUDPBootstrap: | ||
| 212 | return runUDPBootstrap(ctx, cmd.name) | 224 | return runUDPBootstrap(ctx, cmd.name) | ||
| 225 | + | case kindUDPProxy: | |||
| 226 | + | return runUDPProxy(ctx, cmd.name, os.Stdout) | |||
| 213 | case kindList: | 227 | case kindList: | ||
| 214 | return runList(ctx) | 228 | return runList(ctx) | ||
| 215 | case kindKill: | 229 | case kindKill: | ||
| @@ -607,23 +621,78 @@ func runStdio(ctx context.Context) error { | |||||
| 607 | return proxyTransports(ctx, stdio, transport, log) | 621 | return proxyTransports(ctx, stdio, transport, log) | ||
| 608 | } | 622 | } | ||
| 609 | 623 | ||||
| 610 | - | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | 624 | + | var ( |
| 611 | - | // authenticated datagrams. It prints only an ephemeral UDP port and random | 625 | + | udpBootstrapTimeout = 5 * time.Second |
| 612 | - | // session key to stdout; stdio remains available only as an explicit remote | 626 | + | udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd { |
| 613 | - | // transport mode. | 627 | + | return exec.CommandContext(ctx, exe, args...) |
| 628 | + | } | |||
| 629 | + | ) | |||
| 630 | + | ||||
| 631 | + | // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness | |||
| 632 | + | // line, and exits so SSH stdio can close without owning the UDP proxy lifetime. | |||
| 614 | func runUDPBootstrap(ctx context.Context, session string) error { | 633 | func runUDPBootstrap(ctx context.Context, session string) error { | ||
| 615 | - | log, logCloser, err := configureLogging(logging.Stdio, false) | 634 | + | r, w, err := os.Pipe() |
| 616 | if err != nil { | 635 | if err != nil { | ||
| 617 | return err | 636 | return err | ||
| 618 | } | 637 | } | ||
| 619 | - | defer func() { _ = logCloser.Close() }() | 638 | + | defer func() { _ = r.Close() }() |
| 620 | - | log.Debug("udp bootstrap starting", "session", session) | 639 | + | |
| 640 | + | exe, err := os.Executable() | |||
| 641 | + | if err != nil { | |||
| 642 | + | _ = w.Close() | |||
| 643 | + | return err | |||
| 644 | + | } | |||
| 645 | + | args := []string{"_udp-proxy"} | |||
| 646 | + | if session != "" { | |||
| 647 | + | args = append(args, session) | |||
| 648 | + | } | |||
| 649 | + | cmd := udpProxyCommand(ctx, exe, args...) | |||
| 650 | + | cmd.Stdin = nil | |||
| 651 | + | cmd.Stdout = w | |||
| 652 | + | cmd.Stderr = os.Stderr | |||
| 653 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |||
| 654 | + | if err := cmd.Start(); err != nil { | |||
| 655 | + | _ = w.Close() | |||
| 656 | + | return err | |||
| 657 | + | } | |||
| 658 | + | _ = w.Close() | |||
| 621 | 659 | ||||
| 660 | + | readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout) | |||
| 661 | + | defer cancel() | |||
| 662 | + | lineCh := make(chan string, 1) | |||
| 663 | + | errCh := make(chan error, 1) | |||
| 664 | + | go func() { | |||
| 665 | + | line, err := bufio.NewReader(r).ReadString('\n') | |||
| 666 | + | if err != nil { | |||
| 667 | + | errCh <- err | |||
| 668 | + | return | |||
| 669 | + | } | |||
| 670 | + | lineCh <- line | |||
| 671 | + | }() | |||
| 622 | select { | 672 | select { | ||
| 623 | - | case <-ctx.Done(): | 673 | + | case line := <-lineCh: |
| 624 | - | return ctx.Err() | 674 | + | if err := cmd.Process.Release(); err != nil { |
| 625 | - | default: | 675 | + | return err |
| 676 | + | } | |||
| 677 | + | _, err := fmt.Fprint(os.Stdout, line) | |||
| 678 | + | return err | |||
| 679 | + | case err := <-errCh: | |||
| 680 | + | return err | |||
| 681 | + | case <-readyCtx.Done(): | |||
| 682 | + | _ = cmd.Process.Kill() | |||
| 683 | + | return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err()) | |||
| 684 | + | } | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | // runUDPProxy is the detached long-lived remote-side datagram proxy. | |||
| 688 | + | func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 689 | + | log, logCloser, err := configureLogging(logging.Stdio, false) | |||
| 690 | + | if err != nil { | |||
| 691 | + | return err | |||
| 626 | } | 692 | } | ||
| 693 | + | defer func() { _ = logCloser.Close() }() | |||
| 694 | + | log.Debug("udp proxy starting", "session", session) | |||
| 695 | + | ||||
| 627 | var lc net.ListenConfig | 696 | var lc net.ListenConfig | ||
| 628 | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | 697 | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | ||
| 629 | if err != nil { | 698 | if err != nil { | ||
| @@ -647,12 +716,11 @@ func runUDPBootstrap(ctx context.Context, session string) error { | |||||
| 647 | 716 | ||||
| 648 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | 717 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | ||
| 649 | if !ok { | 718 | if !ok { | ||
| 650 | - | return errors.New("vev: udp bootstrap did not get a UDP address") | 719 | + | return errors.New("vev: udp proxy did not get a UDP address") |
| 651 | } | 720 | } | ||
| 652 | - | if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | 721 | + | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { |
| 653 | return err | 722 | return err | ||
| 654 | } | 723 | } | ||
| 655 | - | _ = os.Stdout.Sync() | |||
| 656 | 724 | ||||
| 657 | var dgramTr ports.Transport = dg | 725 | var dgramTr ports.Transport = dg | ||
| 658 | if session != "" { | 726 | if session != "" { | ||
M internal/app/run_test.go +32 -0
| @@ -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" | |
| @@ -66,6 +67,9 @@ func TestParseArgs(t *testing.T) { | |||
| 66 | 67 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | |
| 67 | 68 | {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"}, | |
| 68 | 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}, | |
| 69 | 73 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |
| 70 | 74 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |
| 71 | 75 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | |
| @@ -560,3 +564,31 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 560 | 564 | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | |
| 561 | 565 | } | |
| 562 | 566 | } | |
| 567 | + | ||
| 568 | + | func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) { | |
| 569 | + | oldTimeout := udpBootstrapTimeout | |
| 570 | + | udpBootstrapTimeout = time.Second | |
| 571 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |
| 572 | + | oldCommand := udpProxyCommand | |
| 573 | + | r, w, err := os.Pipe() | |
| 574 | + | if err != nil { | |
| 575 | + | t.Fatal(err) | |
| 576 | + | } | |
| 577 | + | defer func() { _ = r.Close() }() | |
| 578 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |
| 579 | + | cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'") | |
| 580 | + | cmd.Stdout = w | |
| 581 | + | return cmd | |
| 582 | + | } | |
| 583 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |
| 584 | + | defer func() { _ = w.Close() }() | |
| 585 | + | ||
| 586 | + | got := captureStdout(t, func() { | |
| 587 | + | if err := runUDPBootstrap(context.Background(), "work"); err != nil { | |
| 588 | + | t.Fatalf("runUDPBootstrap() error = %v", err) | |
| 589 | + | } | |
| 590 | + | }) | |
| 591 | + | if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" { | |
| 592 | + | t.Fatalf("readiness = %q", got) | |
| 593 | + | } | |
| 594 | + | } | |
| @@ -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" | ||
| @@ -66,6 +67,9 @@ func TestParseArgs(t *testing.T) { | |||||
| 66 | {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"}, | ||
| 67 | {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"}, | ||
| 68 | {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}, | |||
| 69 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | 73 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | ||
| 70 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | 74 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | ||
| 71 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | 75 | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | ||
| @@ -560,3 +564,31 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 560 | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | 564 | t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard) | ||
| 561 | } | 565 | } | ||
| 562 | } | 566 | } | ||
| 567 | + | ||||
| 568 | + | func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) { | |||
| 569 | + | oldTimeout := udpBootstrapTimeout | |||
| 570 | + | udpBootstrapTimeout = time.Second | |||
| 571 | + | t.Cleanup(func() { udpBootstrapTimeout = oldTimeout }) | |||
| 572 | + | oldCommand := udpProxyCommand | |||
| 573 | + | r, w, err := os.Pipe() | |||
| 574 | + | if err != nil { | |||
| 575 | + | t.Fatal(err) | |||
| 576 | + | } | |||
| 577 | + | defer func() { _ = r.Close() }() | |||
| 578 | + | udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd { | |||
| 579 | + | cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'") | |||
| 580 | + | cmd.Stdout = w | |||
| 581 | + | return cmd | |||
| 582 | + | } | |||
| 583 | + | t.Cleanup(func() { udpProxyCommand = oldCommand }) | |||
| 584 | + | defer func() { _ = w.Close() }() | |||
| 585 | + | ||||
| 586 | + | got := captureStdout(t, func() { | |||
| 587 | + | if err := runUDPBootstrap(context.Background(), "work"); err != nil { | |||
| 588 | + | t.Fatalf("runUDPBootstrap() error = %v", err) | |||
| 589 | + | } | |||
| 590 | + | }) | |||
| 591 | + | if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" { | |||
| 592 | + | t.Fatalf("readiness = %q", got) | |||
| 593 | + | } | |||
| 594 | + | } | |||