Skip to content

Go library

The portcheck CLI is a thin layer over public packages you can import:

go get github.com/roystondwork-dev/portcheck@latest
Package Purpose
pkg/portspec parse and iterate port specs like 22,80,8000-8100,common
pkg/inspect list sockets with their owning processes
pkg/freeport check and find free ports
pkg/killer stop processes gracefully, with safety guards
pkg/scan rate-limited TCP connect scanning
pkg/waitfor poll until a port opens (or any condition holds)
pkg/containers attribute published ports to Docker/Podman containers
pkg/wsl attribute WSL-forwarded ports to distros (Windows)

The packages have no dependency on the CLI or TUI libraries (enforced by a linter rule), and use only the standard library plus gopsutil and golang.org/x/{sync,sys,time}. The examples below are based on the Example tests in each package (pkg/*/example_test.go), which CI compiles and, where their output is deterministic, runs.

Stability

Before v1.0.0, minor releases may change these APIs; changes are called out in the changelog.

Port specs

spec, err := portspec.Parse("22,80,8000-8002")
if err != nil {
    log.Fatal(err)
}
fmt.Println(spec.Len(), spec.Contains(8001)) // 5 true
for port := range spec.All() {
    fmt.Print(port, " ") // 22 80 8000 8001 8002
}

Who is listening on a port?

res, err := inspect.NewSystem().List(ctx, inspect.Filter{
    Ports:         portspec.Single(3000),
    ListeningOnly: true,
})
if err != nil {
    log.Fatal(err)
}
for _, s := range res.Sockets {
    if s.Process != nil {
        fmt.Printf("%s %s pid=%d %s\n", s.Proto, s.Local, s.PID, s.Process.Name)
    }
}
if res.Partial {
    fmt.Println(inspect.PrivilegeHint())
}

ProcessInfo.Ancestors lists who started the process, nearest first (up to inspect.MaxAncestors); ProcessInfo.Parent() returns the direct parent or nil.

To test code that uses inspect, pass an inspect.FakeProvider to inspect.New instead of using NewSystem.

To attribute container ports, add the container engine as an enricher:

var opts []inspect.Option
if engine, ok := containers.Discover(os.Getenv); ok {
    opts = append(opts, inspect.WithEnricher(engine))
}
res, err := inspect.NewSystem(opts...).List(ctx, inspect.Filter{ListeningOnly: true})
// s.Container is set for ports published by a container.

Free ports

f := freeport.New()
st, err := f.Check(ctx, 5432, inspect.TCP)
if err != nil {
    log.Fatal(err)
}
fmt.Println(st.Availability) // free, in-use or denied

ports, err := f.Find(ctx, portspec.MustParse("3000-3999"), inspect.TCP, 3)

Stopping a process

res, err := killer.New().Kill(ctx, pid, killer.Options{Grace: 5 * time.Second})
switch {
case errors.Is(err, killer.ErrProtected):
    // PID 0/1, ourselves, our parent, or a system process
case errors.Is(err, killer.ErrPermission):
    // needs root / Administrator
case err == nil:
    fmt.Println(res.Outcome) // terminated, killed or already-exited
}

Scanning

targets, err := scan.Expand(ctx, net.DefaultResolver, []string{"db.internal", "10.0.0.0/30"}, scan.DefaultMaxHosts)
if err != nil {
    log.Fatal(err)
}
for r := range scan.Scan(ctx, targets, portspec.MustParse("5432,6379"), scan.Options{}) {
    fmt.Println(r.Target, r.Port, r.State)
}

Waiting for a service

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
r := waitfor.Poll(ctx, 250*time.Millisecond,
    waitfor.DialCheck(&net.Dialer{}, "tcp", "localhost:5432", time.Second))
fmt.Println(r.Ready, r.Attempts, r.Waited)

scan.Options{} uses safe defaults: 100 concurrent connections, a 1s timeout and at most 500 connections per second. Results stream in completion order; the channel closes when the scan finishes or ctx is cancelled.