005 Project 5: Concurrent Port Scanner

Updated

September 8, 2026

005 Build a Concurrent Port Scanner

Build a CLI that scans a host across a TCP port range using a bounded worker pool, collects open ports, and prints them sorted. This is a classic fan-out/fan-in exercise: many dials in flight, controlled concurrency, clean shutdown.

ports range -> jobs channel -> workers dial tcp -> open ports channel -> sort -> print

Problem statement

Given -host, -from, -to, -w (workers), and -t (dial timeout), probe every port in range with net.DialTimeout("tcp", ...). Print open ports in ascending order. Exit non-zero on invalid flags.

Acceptance criteria

  • Invalid range (from > to, out of 1–65535) exits with code 2 and a clear message
  • Worker count bounds concurrency (not one goroutine per port without limit)
  • Open ports printed sorted; closed/filtered ports silent
  • Program terminates (no goroutine leak after range completes)
  • go build succeeds with stdlib only
  • Optional: -json or banner grab as stretch (not required for pass)

Setup

mkdir portscan && cd portscan
go mod init example.com/portscan
# go.mod
# module example.com/portscan
# go 1.27

Full main.go

package main

import (
    "flag"
    "fmt"
    "net"
    "os"
    "sort"
    "sync"
    "time"
)

func scan(host string, timeout time.Duration, jobs <-chan int, openPorts chan<- int) {
    for p := range jobs {
        addr := net.JoinHostPort(host, fmt.Sprintf("%d", p))
        conn, err := net.DialTimeout("tcp", addr, timeout)
        if err != nil {
            continue
        }
        _ = conn.Close()
        openPorts <- p
    }
}

func main() {
    host := flag.String("host", "127.0.0.1", "target host")
    from := flag.Int("from", 1, "start port")
    to := flag.Int("to", 1024, "end port")
    workers := flag.Int("w", 200, "worker count")
    timeout := flag.Duration("t", 300*time.Millisecond, "dial timeout")
    flag.Parse()

    if *from < 1 || *to > 65535 || *from > *to {
        fmt.Fprintln(os.Stderr, "invalid port range: need 1 <= from <= to <= 65535")
        os.Exit(2)
    }
    if *workers < 1 {
        fmt.Fprintln(os.Stderr, "workers must be >= 1")
        os.Exit(2)
    }

    jobs := make(chan int, *workers)
    openPorts := make(chan int, 256)
    var wg sync.WaitGroup

    for i := 0; i < *workers; i++ {
        wg.Go(func() {
            scan(*host, *timeout, jobs, openPorts)
        })
    }

    go func() {
        for p := *from; p <= *to; p++ {
            jobs <- p
        }
        close(jobs)
        wg.Wait()
        close(openPorts)
    }()

    var open []int
    for p := range openPorts {
        open = append(open, p)
    }
    sort.Ints(open)

    fmt.Printf("open ports on %s (%d-%d):\n", *host, *from, *to)
    for _, p := range open {
        fmt.Println(p)
    }
    fmt.Printf("total open: %d\n", len(open))
}

Step-by-step build path

  1. Validate flags before starting any goroutine.
  2. Create job and result channels; buffer jobs roughly to worker count to reduce scheduling chatter.
  3. Start N workers that only dial and optionally send open ports—no shared mutable state except channels.
  4. Producer goroutine pushes ports, closes jobs, waits for workers, then closes results (fan-in join).
  5. Main aggregates, sorts, prints. Prefer sync.WaitGroup.Go (Go 1.25+) for worker startup.
sequence (top → bottom):
  actors: main, producer, workers, openCh
  main --> workers  : start N
  main --> producer : go fill jobs
  producer --> workers : jobs
  workers --> openCh : open ports
  producer --> openCh : close after Wait
  main --> openCh : range + sort

Run and verification

# Local: open something you control, e.g. a local HTTP server on :8080
go run . -host 127.0.0.1 -from 1 -to 9000 -w 300 -t 150ms

# Public lab target (be polite; small range)
go run . -host scanme.nmap.org -from 20 -to 100 -w 50 -t 500ms

go build -o portscan .
./portscan -host 127.0.0.1 -from 1 -to 100

Quick self-check with a known listener:

# terminal A
python3 -m http.server 8765
# terminal B
go run . -host 127.0.0.1 -from 8760 -to 8770 -w 20 -t 200ms
# expect 8765 in the list

Race-friendly structure (channels only for shared coordination):

go run -race . -host 127.0.0.1 -from 1 -to 200 -w 50 -t 50ms

Tests (optional unit)

Extract dial behind an interface for pure tests, or keep a smoke test:

// scan_test.go — integration-style, skip in short mode
package main

import (
    "net"
    "testing"
    "time"
)

func TestDialLocalListener(t *testing.T) {
    ln, err := net.Listen("tcp", "127.0.0.1:0")
    if err != nil {
        t.Fatal(err)
    }
    defer ln.Close()
    _, port, _ := net.SplitHostPort(ln.Addr().String())

    conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), time.Second)
    if err != nil {
        t.Fatal(err)
    }
    _ = conn.Close()
}
go test -race ./...

Stretch goals

  1. CIDR / multi-host: expand 10.0.0.0/30 into hosts; nest host×port jobs.
  2. Banner grab: after dial, set read deadline and print first line.
  3. Rate limit: token bucket so -w high does not flood a single target.
  4. UDP probe (best-effort) or service name map for common ports.
  5. JSON output for CI: {"host":"...","open":[22,80]}.

Pitfalls

Pitfall Why it hurts Fix
Unbounded goroutines file descriptor / memory blowup worker pool
No timeout hangs on filtered ports DialTimeout
Shared slice append without sync data race send on channel, append in one owner
Forgetting to close openPorts main blocks forever close after Wait
Scanning without permission legal/ToS issues only scan hosts you own or have OK for

Code anatomy

  • Producer pushes port numbers into jobs.
  • Workers consume jobs and emit open ports.
  • Aggregator (main) merges results and prints a deterministic summary.

Learning goals

  • Build leak-free fan-out/fan-in with WaitGroup + channel close.
  • Balance throughput (-w) against timeout and target load.
  • Keep coordination in channels; avoid shared mutable maps without locks.