detached 52e0dc8

feat(remote): bind udp proxy in a fixed configurable port range

The remote UDP proxy bound a random ephemeral port (:0), so firewalls could
not allow it without opening the whole interface. Bind within a fixed range
instead (default 61000-61023), skipping busy ports, so users can
`ufw allow 61000:61023/udp`. Configurable per remote host via
VEV_UDP_PORT_RANGE (START-END, a single PORT, or 0 for ephemeral).
commit
52e0dc83261bc9f564c32a2b36ea35c13948e204
author
brice · · 2026-07-07 01:19 UTC
parent
5db9466
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +167 -3
M README.md +1 -1
@@ -122,7 +122,7 @@ vev attach user@host:session
122122
123123 Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
124124
125-The UDP proxy binds an ephemeral high port on the remote, so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP on the remote to fix it — for example, on a trusted Tailscale network, `sudo ufw allow in on tailscale0`.
125+The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
126126
127127 Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.
128128
@@ -122,7 +122,7 @@ vev attach user@host:session
122 122
123 Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.123 Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
124 124
125-The UDP proxy binds an ephemeral high port on the remote, so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP on the remote to fix it — for example, on a trusted Tailscale network, `sudo ufw allow in on tailscale0`.125+The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
126 126
127 Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.127 Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.
128 128
M internal/app/run.go +82 -2
@@ -20,6 +20,8 @@ import (
2020 "os/exec"
2121 "os/signal"
2222 "path/filepath"
23+ "strconv"
24+ "strings"
2325 "syscall"
2426 "text/tabwriter"
2527 "time"
@@ -705,8 +707,11 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
705707 defer func() { _ = logCloser.Close() }()
706708 log.Debug("udp proxy starting", "session", session)
707709
708- var lc net.ListenConfig
709- conn, err := lc.ListenPacket(ctx, "udp", ":0")
710+ portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
711+ if err != nil {
712+ return err
713+ }
714+ conn, err := listenUDPInRange(ctx, portRange)
710715 if err != nil {
711716 return err
712717 }
@@ -741,6 +746,81 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
741746 return proxyTransports(ctx, dgramTr, daemonTr, log)
742747 }
743748
749+const (
750+ // envUDPPortRange configures the remote UDP proxy's listen port range so a
751+ // host firewall can allow a known range instead of a random ephemeral port.
752+ // Format: "START-END" (inclusive), a single "PORT", or "0" for a random
753+ // ephemeral port. Unset uses the default range below.
754+ envUDPPortRange = "VEV_UDP_PORT_RANGE"
755+ defaultUDPPortStart = 61000
756+ defaultUDPPortEnd = 61023
757+)
758+
759+// udpPortRange is an inclusive [start, end] UDP port range. start == 0 means a
760+// random ephemeral port (the old ":0" behavior).
761+type udpPortRange struct {
762+ start int
763+ end int
764+}
765+
766+// parseUDPPortRange parses VEV_UDP_PORT_RANGE. Empty -> default range. "0" ->
767+// ephemeral. "N" -> single port N. "A-B" -> inclusive range. Ports must be in
768+// 1..65535 (or 0 for ephemeral) and end must be >= start.
769+func parseUDPPortRange(value string) (udpPortRange, error) {
770+ value = strings.TrimSpace(value)
771+ if value == "" {
772+ return udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}, nil
773+ }
774+ startStr, endStr, hasRange := strings.Cut(value, "-")
775+ start, err := parseUDPPort(startStr)
776+ if err != nil {
777+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
778+ }
779+ if start == 0 {
780+ return udpPortRange{start: 0, end: 0}, nil
781+ }
782+ end := start
783+ if hasRange {
784+ end, err = parseUDPPort(endStr)
785+ if err != nil {
786+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
787+ }
788+ }
789+ if end < start {
790+ return udpPortRange{}, fmt.Errorf("invalid %s %q: end %d before start %d", envUDPPortRange, value, end, start)
791+ }
792+ return udpPortRange{start: start, end: end}, nil
793+}
794+
795+func parseUDPPort(s string) (int, error) {
796+ p, err := strconv.Atoi(strings.TrimSpace(s))
797+ if err != nil {
798+ return 0, fmt.Errorf("port %q is not a number", strings.TrimSpace(s))
799+ }
800+ if p < 0 || p > 65535 {
801+ return 0, fmt.Errorf("port %d out of range 0-65535", p)
802+ }
803+ return p, nil
804+}
805+
806+// listenUDPInRange binds a UDP packet conn on the first free port in the range,
807+// skipping busy ports. A zero-start range binds a random ephemeral port.
808+func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, error) {
809+ var lc net.ListenConfig
810+ if r.start == 0 {
811+ return lc.ListenPacket(ctx, "udp", ":0")
812+ }
813+ var lastErr error
814+ for port := r.start; port <= r.end; port++ {
815+ conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
816+ if err == nil {
817+ return conn, nil
818+ }
819+ lastErr = err
820+ }
821+ return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
822+}
823+
744824 type preHelloNameTransport struct {
745825 ports.Transport
746826 session string
@@ -20,6 +20,8 @@ import (
20 "os/exec"20 "os/exec"
21 "os/signal"21 "os/signal"
22 "path/filepath"22 "path/filepath"
23+ "strconv"
24+ "strings"
23 "syscall"25 "syscall"
24 "text/tabwriter"26 "text/tabwriter"
25 "time"27 "time"
@@ -705,8 +707,11 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
705 defer func() { _ = logCloser.Close() }()707 defer func() { _ = logCloser.Close() }()
706 log.Debug("udp proxy starting", "session", session)708 log.Debug("udp proxy starting", "session", session)
707 709
708- var lc net.ListenConfig710+ portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
709- conn, err := lc.ListenPacket(ctx, "udp", ":0")711+ if err != nil {
712+ return err
713+ }
714+ conn, err := listenUDPInRange(ctx, portRange)
710 if err != nil {715 if err != nil {
711 return err716 return err
712 }717 }
@@ -741,6 +746,81 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
741 return proxyTransports(ctx, dgramTr, daemonTr, log)746 return proxyTransports(ctx, dgramTr, daemonTr, log)
742 }747 }
743 748
749+const (
750+ // envUDPPortRange configures the remote UDP proxy's listen port range so a
751+ // host firewall can allow a known range instead of a random ephemeral port.
752+ // Format: "START-END" (inclusive), a single "PORT", or "0" for a random
753+ // ephemeral port. Unset uses the default range below.
754+ envUDPPortRange = "VEV_UDP_PORT_RANGE"
755+ defaultUDPPortStart = 61000
756+ defaultUDPPortEnd = 61023
757+)
758+
759+// udpPortRange is an inclusive [start, end] UDP port range. start == 0 means a
760+// random ephemeral port (the old ":0" behavior).
761+type udpPortRange struct {
762+ start int
763+ end int
764+}
765+
766+// parseUDPPortRange parses VEV_UDP_PORT_RANGE. Empty -> default range. "0" ->
767+// ephemeral. "N" -> single port N. "A-B" -> inclusive range. Ports must be in
768+// 1..65535 (or 0 for ephemeral) and end must be >= start.
769+func parseUDPPortRange(value string) (udpPortRange, error) {
770+ value = strings.TrimSpace(value)
771+ if value == "" {
772+ return udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}, nil
773+ }
774+ startStr, endStr, hasRange := strings.Cut(value, "-")
775+ start, err := parseUDPPort(startStr)
776+ if err != nil {
777+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
778+ }
779+ if start == 0 {
780+ return udpPortRange{start: 0, end: 0}, nil
781+ }
782+ end := start
783+ if hasRange {
784+ end, err = parseUDPPort(endStr)
785+ if err != nil {
786+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
787+ }
788+ }
789+ if end < start {
790+ return udpPortRange{}, fmt.Errorf("invalid %s %q: end %d before start %d", envUDPPortRange, value, end, start)
791+ }
792+ return udpPortRange{start: start, end: end}, nil
793+}
794+
795+func parseUDPPort(s string) (int, error) {
796+ p, err := strconv.Atoi(strings.TrimSpace(s))
797+ if err != nil {
798+ return 0, fmt.Errorf("port %q is not a number", strings.TrimSpace(s))
799+ }
800+ if p < 0 || p > 65535 {
801+ return 0, fmt.Errorf("port %d out of range 0-65535", p)
802+ }
803+ return p, nil
804+}
805+
806+// listenUDPInRange binds a UDP packet conn on the first free port in the range,
807+// skipping busy ports. A zero-start range binds a random ephemeral port.
808+func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, error) {
809+ var lc net.ListenConfig
810+ if r.start == 0 {
811+ return lc.ListenPacket(ctx, "udp", ":0")
812+ }
813+ var lastErr error
814+ for port := r.start; port <= r.end; port++ {
815+ conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
816+ if err == nil {
817+ return conn, nil
818+ }
819+ lastErr = err
820+ }
821+ return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
822+}
823+
744 type preHelloNameTransport struct {824 type preHelloNameTransport struct {
745 ports.Transport825 ports.Transport
746 session string826 session string
M internal/app/run_test.go +84 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "net"
910 "os"
1011 "os/exec"
1112 "path/filepath"
@@ -633,3 +634,86 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
633634 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
634635 }
635636 }
637+
638+func TestParseUDPPortRange(t *testing.T) {
639+ tests := []struct {
640+ name string
641+ value string
642+ want udpPortRange
643+ wantError bool
644+ }{
645+ {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
646+ {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
647+ {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
648+ {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
649+ {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
650+ {name: "reversed range", value: "61023-61000", wantError: true},
651+ {name: "non-numeric", value: "abc", wantError: true},
652+ {name: "missing end", value: "61000-", wantError: true},
653+ {name: "port too high", value: "70000", wantError: true},
654+ }
655+ for _, tt := range tests {
656+ t.Run(tt.name, func(t *testing.T) {
657+ got, err := parseUDPPortRange(tt.value)
658+ if tt.wantError {
659+ if err == nil {
660+ t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
661+ }
662+ return
663+ }
664+ if err != nil {
665+ t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
666+ }
667+ if got != tt.want {
668+ t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
669+ }
670+ })
671+ }
672+}
673+
674+func TestListenUDPInRange(t *testing.T) {
675+ // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
676+ var lc net.ListenConfig
677+ occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
678+ if err != nil {
679+ t.Fatal(err)
680+ }
681+ defer func() { _ = occupied.Close() }()
682+ busyPort := occupied.LocalAddr().(*net.UDPAddr).Port
683+
684+ t.Run("skips busy port", func(t *testing.T) {
685+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
686+ if err != nil {
687+ t.Fatalf("listenUDPInRange error = %v", err)
688+ }
689+ defer func() { _ = conn.Close() }()
690+ got := conn.LocalAddr().(*net.UDPAddr).Port
691+ if got == busyPort {
692+ t.Fatalf("bound busy port %d, want a different port in range", busyPort)
693+ }
694+ if got < busyPort || got > busyPort+8 {
695+ t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
696+ }
697+ })
698+
699+ t.Run("exhausted range errors", func(t *testing.T) {
700+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
701+ if err == nil {
702+ t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
703+ }
704+ if !strings.Contains(err.Error(), "no free UDP port in range") {
705+ t.Fatalf("error = %v, want 'no free UDP port in range'", err)
706+ }
707+ })
708+
709+ t.Run("ephemeral binds", func(t *testing.T) {
710+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
711+ if err != nil {
712+ t.Fatalf("listenUDPInRange ephemeral error = %v", err)
713+ }
714+ defer func() { _ = conn.Close() }()
715+ if conn.LocalAddr().(*net.UDPAddr).Port == 0 {
716+ t.Fatal("ephemeral bind returned port 0")
717+ }
718+ })
719+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "net"
9 "os"10 "os"
10 "os/exec"11 "os/exec"
11 "path/filepath"12 "path/filepath"
@@ -633,3 +634,86 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
633 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)634 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
634 }635 }
635 }636 }
637+
638+func TestParseUDPPortRange(t *testing.T) {
639+ tests := []struct {
640+ name string
641+ value string
642+ want udpPortRange
643+ wantError bool
644+ }{
645+ {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
646+ {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
647+ {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
648+ {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
649+ {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
650+ {name: "reversed range", value: "61023-61000", wantError: true},
651+ {name: "non-numeric", value: "abc", wantError: true},
652+ {name: "missing end", value: "61000-", wantError: true},
653+ {name: "port too high", value: "70000", wantError: true},
654+ }
655+ for _, tt := range tests {
656+ t.Run(tt.name, func(t *testing.T) {
657+ got, err := parseUDPPortRange(tt.value)
658+ if tt.wantError {
659+ if err == nil {
660+ t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
661+ }
662+ return
663+ }
664+ if err != nil {
665+ t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
666+ }
667+ if got != tt.want {
668+ t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
669+ }
670+ })
671+ }
672+}
673+
674+func TestListenUDPInRange(t *testing.T) {
675+ // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
676+ var lc net.ListenConfig
677+ occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
678+ if err != nil {
679+ t.Fatal(err)
680+ }
681+ defer func() { _ = occupied.Close() }()
682+ busyPort := occupied.LocalAddr().(*net.UDPAddr).Port
683+
684+ t.Run("skips busy port", func(t *testing.T) {
685+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
686+ if err != nil {
687+ t.Fatalf("listenUDPInRange error = %v", err)
688+ }
689+ defer func() { _ = conn.Close() }()
690+ got := conn.LocalAddr().(*net.UDPAddr).Port
691+ if got == busyPort {
692+ t.Fatalf("bound busy port %d, want a different port in range", busyPort)
693+ }
694+ if got < busyPort || got > busyPort+8 {
695+ t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
696+ }
697+ })
698+
699+ t.Run("exhausted range errors", func(t *testing.T) {
700+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
701+ if err == nil {
702+ t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
703+ }
704+ if !strings.Contains(err.Error(), "no free UDP port in range") {
705+ t.Fatalf("error = %v, want 'no free UDP port in range'", err)
706+ }
707+ })
708+
709+ t.Run("ephemeral binds", func(t *testing.T) {
710+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
711+ if err != nil {
712+ t.Fatalf("listenUDPInRange ephemeral error = %v", err)
713+ }
714+ defer func() { _ = conn.Close() }()
715+ if conn.LocalAddr().(*net.UDPAddr).Port == 0 {
716+ t.Fatal("ephemeral bind returned port 0")
717+ }
718+ })
719+}

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