Dial services by name in Go tests and CI — with readiness built into the dial.
Register a name and dial it instead of hardcoding 127.0.0.1:8888 or racing to find a free port.
The dial blocks until the backend accepts, so a test dials a service that is still starting instead of polling for it, and backends self-heal across restarts.
Ports leave your vocabulary: the port-free backends never surface one.
Inspired by portless.sh, rebuilt for test infrastructure: zero-root, no /etc/hosts, no daemon required for in-process use.
Run a process on an assigned port and reach it by name — no port picked:
brew install --cask sanketsudake/tap/portless
# or:
go install github.com/sanketsudake/go-portless/cmd/portless@latest
portless run web -- go run ./cmd/server # binds $PORT, names it "web"
eval "$(portless env)" # export HTTP_PROXY / NO_PROXY
curl http://web/healthz # blocks until "web" is servingFrom Go, point any http.Client, gRPC, or websocket dialer at the registry:
reg := portless.New()
defer reg.Close()
reg.Add(ctx, "web", backend.Future()) // OS assigns the port later
// … start your server on net.Listen(":0"), then f.SetListener(l) …
resp, err := reg.HTTPClient().Get("http://web/healthz")Runnable examples and the full API are on pkg.go.dev.
Registry.DialContext has the same shape as net.Dialer.DialContext, so HTTP, WebSockets, gRPC, and raw TCP all route through one path.
WebSockets, both major libraries — with gorilla/websocket, inject the dialer:
d := websocket.Dialer{NetDialContext: reg.DialContext}
conn, _, err := d.Dial(portless.WSURL("web", 0, "/stream"), nil)coder/websocket has no dialer hook; inject the HTTP client instead:
conn, _, err := websocket.Dial(ctx, portless.WSURL("web", 0, "/stream"),
&websocket.DialOptions{HTTPClient: reg.DefaultClient()})The coder/websocket path works because Go's HTTP/1.1 101-upgrade response bodies are writable through a custom transport.
| Backend | Port | Use for |
|---|---|---|
backend.Future |
OS-assigned, supplied later | a server you start in the test |
backend.Listener |
OS-assigned (l.Addr()) |
an already-bound net.Listener |
backend.Mem |
none (net.Pipe) |
serve HTTP with zero TCP sockets |
k8s.PortForward |
none (pod stream) | a Kubernetes Service or pod |
backend.TCP / portless alias |
you supply it | escape hatch: name an already-running address |
For services you embed in the test process, backend.ListenAndAdd is the whole recipe in one call — bind 127.0.0.1:0, register the listener, hand it to the service's start options:
l, err := backend.ListenAndAdd(ctx, reg, "router")
// pass l to your service; consumers needing a real URL use Route.Addr()A backend.Listener route is dial-ready as soon as the socket is bound (kernel accept backlog), which can be earlier than the service behind it is serving — pair TLS servers with RouteWithTLSHealth.
For components that insist on port ints instead of listeners, backend.ReservePorts(n) returns n distinct free ports by holding all n listeners open before closing any; two sequential listen-:0-close calls can return the same port.
Some consumers cannot take a custom dialer or *http.Client: bare http.Post call sites, third-party SDKs, subprocesses, and URLs printed for humans to copy.
ListenLocal gives them a real dialable address that still rides the route's readiness loop and self-healing:
l, err := reg.ListenLocal("router")
url := "http://" + l.Addr().String() // reaches the route; each connection dials freshEvery accepted connection dials the backend independently, so a pod restart costs one retried dial instead of a dead tunnel.
The listener is closed by Close; process-lifetime registries own it for the life of the process.
AddReady registers, waits for readiness, and removes the route again on failure, so retries of the same name never hit ErrRouteExists:
rt, err := reg.AddReady(ctx, "controller", be)Some servers reject requests that arrive on a loopback connection with a non-loopback Host — exactly what name-based dialing over a port-forward produces — and answer 403.
Register the route with a Host rewrite so the server sees a loopback name:
reg.Add(ctx, "api", b, portless.RouteWithHostRewrite("127.0.0.1"))See writing-backends for the symptom and both fixes.
portless serve --tls terminates TLS so https://<name> verifies against a local CA:
portless serve --tls &
portless ca install # trust the CA once (asks first)
curl https://web/healthz- API reference (godoc) — types, examples
- CLI reference — every command and flag
- Architecture — the dial model, proxy, k8s backend, threat model
- Writing backends — custom backends and middleware
| Module | Path | Depends on |
|---|---|---|
| core | github.com/sanketsudake/go-portless |
stdlib only |
| k8s backend | github.com/sanketsudake/go-portless/k8s |
core + client-go |
| CLI | github.com/sanketsudake/go-portless/cmd/portless |
core + k8s |
go-portless grew out of the fission test suite — FindFreePort races, hardcoded addresses, self-healing kubectl port-forward loops, ws:// rewriting — but none of that is fission-specific.
Any Go project whose tests reach real services hits it.
