gui: drop freePort helper, use libhttp port binding for the RC server

Bind the RC server to localhost:0 and read the bound URL back via a
new rcserver.Server.URLs() accessor instead of pre-allocating a port
in cmd/gui. This removes the small TOCTOU race window between
freePort() closing its listener and rcserver claiming the same port.
This commit is contained in:
Nick Craig-Wood
2026-04-11 13:19:11 +01:00
parent 55afa13921
commit 8ddccc1285
3 changed files with 10 additions and 26 deletions

View File

@@ -8,7 +8,6 @@ import (
"flag"
"fmt"
iofs "io/fs"
"net"
"net/http"
"net/url"
"os"
@@ -134,11 +133,7 @@ Use --no-auth to disable authentication entirely:
if command.Flags().Changed("api-addr") {
opt.HTTP.ListenAddr = apiAddr
} else {
port, err := freePort()
if err != nil {
return fmt.Errorf("failed to find a free port for RC: %w", err)
}
opt.HTTP.ListenAddr = []string{fmt.Sprintf("localhost:%d", port)}
opt.HTTP.ListenAddr = []string{"localhost:0"}
}
// CORS: allow the GUI origin to make cross-port API requests.
@@ -181,9 +176,9 @@ Use --no-auth to disable authentication entirely:
return fmt.Errorf("failed to start RC server: %w", err)
}
// Build the RC URL from the address we configured (rcserver.Server
// does not expose URLs, and we know the address we passed in).
rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/"
// Read the bound RC URL back from rcserver, in case we asked
// libhttp to pick a free port (localhost:0).
rcURL := rcServer.URLs()[0]
// Mount the GUI handler and start serving
spaHandler, err := guiHandler(srcFS)
@@ -219,16 +214,6 @@ Use --no-auth to disable authentication entirely:
},
}
// freePort asks the OS for a free TCP port on localhost.
func freePort() (int, error) {
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
// originFromURL extracts the origin (scheme://host) from a URL string,
// stripping any path or trailing slash.
func originFromURL(rawURL string) string {

View File

@@ -96,13 +96,6 @@ func TestOriginFromURL(t *testing.T) {
}
}
func TestFreePort(t *testing.T) {
port, err := freePort()
assert.NoError(t, err)
assert.Greater(t, port, 0)
assert.Less(t, port, 65536)
}
// newTestHandler returns a guiHandler backed by the embedded GUI
// bundle, or skips the test if it is not present (i.e. `make fetch-gui`
// has not been run).

View File

@@ -409,6 +409,12 @@ func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, path string)
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
// URLs returns the URLs the server is listening on. Only valid after
// Serve has been called (which Start does internally before returning).
func (s *Server) URLs() []string {
return s.server.URLs()
}
// Wait blocks while the server is serving requests
func (s *Server) Wait() {
s.server.Wait()