|
| 1 | +package workspace |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "context" |
| 6 | + "os/exec" |
| 7 | + "strconv" |
| 8 | + "strings" |
| 9 | + "sync" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/open-source-cloud/devstack/internal/xdg" |
| 13 | +) |
| 14 | + |
| 15 | +// netshTimeout bounds the netsh.exe call so a wedged Windows side never hangs |
| 16 | +// the CLI; on timeout we fall back to "no exclusions" (the bind-test still runs). |
| 17 | +const netshTimeout = 3 * time.Second |
| 18 | + |
| 19 | +// portRange is an inclusive [start,end] host-port range that cannot be published. |
| 20 | +type portRange struct{ start, end int } |
| 21 | + |
| 22 | +// netshRunner returns the raw `netsh int ipv4 show excludedportrange` output. |
| 23 | +// Overridable in tests; nil in production means "shell out to netsh.exe". |
| 24 | +var netshRunner func() string |
| 25 | + |
| 26 | +// wsl2Detect gates the exclusion query to WSL2. A package var so tests can force |
| 27 | +// the WSL2 path deterministically on any platform. |
| 28 | +var wsl2Detect = xdg.IsWSL2 |
| 29 | + |
| 30 | +var ( |
| 31 | + excludedMu sync.Mutex |
| 32 | + excludedComputed bool |
| 33 | + excludedCache []portRange |
| 34 | +) |
| 35 | + |
| 36 | +// excludedPortRanges returns the host-port ranges that cannot be bound as a |
| 37 | +// published Docker port on this host. On WSL2 with Docker Desktop, Windows and |
| 38 | +// Hyper-V DYNAMICALLY reserve TCP port ranges (`netsh int ipv4 show |
| 39 | +// excludedportrange protocol=tcp`); the ranges change on every Windows reboot. |
| 40 | +// A bind-test inside the Linux distro does NOT see them, so Docker Desktop's |
| 41 | +// Windows-side port forward fails with: |
| 42 | +// |
| 43 | +// ports are not available: exposing port TCP 127.0.0.1:X -> 127.0.0.1:0: |
| 44 | +// /forwards/expose returned unexpected status: 500 |
| 45 | +// |
| 46 | +// Treating these ranges as unavailable during allocation is what keeps `expose` |
| 47 | +// (and any other host-published port) working on WSL2. Non-WSL2 hosts have no |
| 48 | +// such exclusions and return nil. Result is cached for this process' lifetime |
| 49 | +// (the CLI is short-lived; ranges are stable within a boot). |
| 50 | +func excludedPortRanges() []portRange { |
| 51 | + excludedMu.Lock() |
| 52 | + defer excludedMu.Unlock() |
| 53 | + if !excludedComputed { |
| 54 | + excludedComputed = true |
| 55 | + if wsl2Detect() { |
| 56 | + run := netshRunner |
| 57 | + if run == nil { |
| 58 | + run = runNetshExcluded |
| 59 | + } |
| 60 | + excludedCache = parseExcludedPortRanges(run()) |
| 61 | + } |
| 62 | + } |
| 63 | + return excludedCache |
| 64 | +} |
| 65 | + |
| 66 | +// resetExcludedCache clears the memoized ranges so a later call recomputes. |
| 67 | +// Used by tests that inject a fake netsh output; a no-op cost in production. |
| 68 | +func resetExcludedCache() { |
| 69 | + excludedMu.Lock() |
| 70 | + excludedComputed = false |
| 71 | + excludedCache = nil |
| 72 | + excludedMu.Unlock() |
| 73 | +} |
| 74 | + |
| 75 | +// runNetshExcluded shells out to the Windows netsh.exe (reachable from WSL2) for |
| 76 | +// the TCP excluded-port-range table. A failure (netsh missing, non-Desktop WSL2) |
| 77 | +// yields no exclusions rather than an error — the bind-test remains the backstop. |
| 78 | +func runNetshExcluded() string { |
| 79 | + ctx, cancel := context.WithTimeout(context.Background(), netshTimeout) |
| 80 | + defer cancel() |
| 81 | + out, err := exec.CommandContext(ctx, "netsh.exe", "int", "ipv4", |
| 82 | + "show", "excludedportrange", "protocol=tcp").Output() |
| 83 | + if err != nil { |
| 84 | + return "" |
| 85 | + } |
| 86 | + return string(out) |
| 87 | +} |
| 88 | + |
| 89 | +// parseExcludedPortRanges extracts inclusive [start,end] pairs from netsh's |
| 90 | +// excluded-port-range table. Each data row is two integers (start, end) with an |
| 91 | +// optional trailing "*" note; the title, header, and separator lines have no |
| 92 | +// leading integer pair. Matching on the "two integers begin the line" shape (not |
| 93 | +// on column headings) keeps it locale-independent — netsh localizes its headers. |
| 94 | +func parseExcludedPortRanges(text string) []portRange { |
| 95 | + var ranges []portRange |
| 96 | + sc := bufio.NewScanner(strings.NewReader(text)) |
| 97 | + for sc.Scan() { |
| 98 | + fields := strings.Fields(sc.Text()) |
| 99 | + if len(fields) < 2 { |
| 100 | + continue |
| 101 | + } |
| 102 | + start, err1 := strconv.Atoi(fields[0]) |
| 103 | + end, err2 := strconv.Atoi(fields[1]) |
| 104 | + if err1 != nil || err2 != nil || start <= 0 || end < start { |
| 105 | + continue |
| 106 | + } |
| 107 | + ranges = append(ranges, portRange{start, end}) |
| 108 | + } |
| 109 | + return ranges |
| 110 | +} |
| 111 | + |
| 112 | +// portExcluded reports whether p falls inside any host-reserved excluded range. |
| 113 | +func portExcluded(p int) bool { |
| 114 | + for _, r := range excludedPortRanges() { |
| 115 | + if p >= r.start && p <= r.end { |
| 116 | + return true |
| 117 | + } |
| 118 | + } |
| 119 | + return false |
| 120 | +} |
0 commit comments