005 Project 5: Concurrent Port Scanner
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 buildsucceeds with stdlib only- Optional:
-jsonor 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.27Full 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
- Validate flags before starting any goroutine.
- Create job and result channels; buffer jobs roughly to worker count to reduce scheduling chatter.
- Start N workers that only dial and optionally send open ports—no shared mutable state except channels.
- Producer goroutine pushes ports, closes jobs, waits for workers, then closes results (fan-in join).
- 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 100Quick 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 listRace-friendly structure (channels only for shared coordination):
go run -race . -host 127.0.0.1 -from 1 -to 200 -w 50 -t 50msTests (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
- CIDR / multi-host: expand
10.0.0.0/30into hosts; nest host×port jobs. - Banner grab: after dial, set read deadline and print first line.
- Rate limit: token bucket so
-whigh does not flood a single target. - UDP probe (best-effort) or service name map for common ports.
- 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.