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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 122 | 122 | ||
| 123 | 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 | 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 | ||
| @@ -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 ( | |||
| 20 | 20 | "os/exec" | |
| 21 | 21 | "os/signal" | |
| 22 | 22 | "path/filepath" | |
| 23 | + | "strconv" | |
| 24 | + | "strings" | |
| 23 | 25 | "syscall" | |
| 24 | 26 | "text/tabwriter" | |
| 25 | 27 | "time" | |
| @@ -705,8 +707,11 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 705 | 707 | defer func() { _ = logCloser.Close() }() | |
| 706 | 708 | log.Debug("udp proxy starting", "session", session) | |
| 707 | 709 | ||
| 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) | |
| 710 | 715 | if err != nil { | |
| 711 | 716 | return err | |
| 712 | 717 | } | |
| @@ -741,6 +746,81 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 741 | 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 | 824 | type preHelloNameTransport struct { | |
| 745 | 825 | ports.Transport | |
| 746 | 826 | 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.ListenConfig | 710 | + | 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 err | 716 | 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.Transport | 825 | ports.Transport | ||
| 746 | session string | 826 | session string | ||
M internal/app/run_test.go +84 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "net" | |
| 9 | 10 | "os" | |
| 10 | 11 | "os/exec" | |
| 11 | 12 | "path/filepath" | |
| @@ -633,3 +634,86 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) { | |||
| 633 | 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 | + | } | |
| @@ -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 | + | } | |||