DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Network Port Scanner with Go: Concurrency Patterns That Actually Scale

Port scanning is bread-and-butter for network security work — auditing your own infrastructure, running authorized penetration tests, or building automated inventory tools. The naive approach, a sequential for-loop of TCP dials, is correct but unusable in practice. A 65535-port scan with a 1-second timeout takes over 18 hours. Go's goroutines, channels, and the x/time/rate package give you a correct, fast, and polite solution in under 150 lines.

The naive approach and where it breaks

package main

import (
    "fmt"
    "net"
    "time"
)

func scanPort(host string, port int) bool {
    address := fmt.Sprintf("%s:%d", host, port)
    conn, err := net.DialTimeout("tcp", address, time.Second)
    if err != nil {
        return false
    }
    conn.Close()
    return true
}

func main() {
    host := "192.168.1.1"
    for port := 1; port <= 1024; port++ {
        if scanPort(host, port) {
            fmt.Printf("Port %d is open\n", port)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This scans 1024 ports in 1024 serial attempts. With a 1-second timeout, you are looking at roughly 17 minutes minimum. Closed ports return ECONNREFUSED almost instantly on local networks, so it is faster in practice — but filtered ports (which return nothing and wait for the full timeout) kill you. A single firewall silently dropping packets instead of sending RSTs can push a scan to its theoretical worst-case duration.

The fix is obvious: run dials concurrently. The correct way to do that in Go is a worker pool.

Worker pool: bounded concurrency with goroutines and channels

The worker pool pattern uses N goroutines reading from a shared jobs channel. You control parallelism entirely by controlling N, which means you can tune for network conditions without restructuring anything else.

package main

import (
    "context"
    "fmt"
    "net"
    "sort"
    "sync"
    "time"
)

type Result struct {
    Port int
    Open bool
}

func worker(ctx context.Context, host string, timeout time.Duration, jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()
    for port := range jobs {
        select {
        case <-ctx.Done():
            return
        default:
        }
        addr := fmt.Sprintf("%s:%d", host, port)
        dialCtx, cancel := context.WithTimeout(ctx, timeout)
        conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr)
        cancel()
        open := err == nil
        if open {
            conn.Close()
        }
        results <- Result{Port: port, Open: open}
    }
}

func Scan(ctx context.Context, host string, startPort, endPort, numWorkers int, timeout time.Duration) []int {
    jobs := make(chan int, numWorkers)
    results := make(chan Result, numWorkers)

    var workerWg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        workerWg.Add(1)
        go worker(ctx, host, timeout, jobs, results, &workerWg)
    }

    var open []int
    var collectWg sync.WaitGroup
    collectWg.Add(1)
    go func() {
        defer collectWg.Done()
        for r := range results {
            if r.Open {
                open = append(open, r.Port)
            }
        }
    }()

    for port := startPort; port <= endPort; port++ {
        select {
        case <-ctx.Done():
            goto done
        case jobs <- port:
        }
    }
done:
    close(jobs)
    workerWg.Wait()
    close(results)
    collectWg.Wait()

    sort.Ints(open)
    return open
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    open := Scan(ctx, "192.168.1.1", 1, 1024, 500, time.Second)
    for _, port := range open {
        fmt.Printf("%d/tcp open\n", port)
    }
}
Enter fullscreen mode Exit fullscreen mode

There are a few structural choices worth understanding:

Buffered channels on both jobs and results (size = numWorkers) prevent the feeding goroutine and worker goroutines from blocking each other unnecessarily. Without buffering, each jobs <- port blocks until a worker is free to receive, creating a throughput bottleneck at the producer.

Closing in the right order matters: close(jobs) signals workers that there is no more work, causing them to exit their range loop cleanly. workerWg.Wait() blocks until all workers finish. Only then do we close(results) — the collector goroutine exits when results is drained and closed. Reversing this order causes a panic.

Context propagation per dial (context.WithTimeout(ctx, timeout)) means if the outer context is cancelled — by a scan-level deadline or a signal handler — all in-flight dials abort immediately. Without it, cancelling the outer context would leave active goroutines hanging for up to timeout seconds each.

With 500 workers and a 1-second timeout, a 1024-port scan of a local target completes in 3-5 seconds. The bottleneck shifts from serial execution to network latency.

Rate limiting with a token bucket

Firing 500 concurrent TCP SYNs at a target is fast and highly detectable. Any reasonable IDS picks it up within the first few seconds. Authorized internal audits are one thing — doing this against a remote target in a pentest engagement, without reviewing the rules of engagement first, is how you accidentally trigger an incident response.

The golang.org/x/time/rate package gives you a token bucket limiter in one line:

import (
    "context"
    "golang.org/x/time/rate"
)

// 100 connection attempts per second, burst of 10
limiter := rate.NewLimiter(rate.Limit(100), 10)

// In the job feeding loop, before sending to jobs:
for port := startPort; port <= endPort; port++ {
    if err := limiter.Wait(ctx); err != nil {
        break // context cancelled
    }
    select {
    case jobs <- port:
    case <-ctx.Done():
        goto done
    }
}
Enter fullscreen mode Exit fullscreen mode

rate.Limit(100) means 100 tokens per second refill. A burst of 10 allows brief spikes before throttling kicks in. Practical tuning: 200-500 r/s for LAN targets, 50-100 r/s for external hosts. Check engagement rules before going higher — some clients specify maximum packets-per-second explicitly in their scope documents.

Banner grabbing: from open port to service fingerprint

An open port tells you a service is listening. A banner tells you what service and often its version. After a successful TCP connect, read the first few bytes before closing:

func grabBanner(addr string, timeout time.Duration) string {
    conn, err := net.DialTimeout("tcp", addr, timeout)
    if err != nil {
        return ""
    }
    defer conn.Close()
    conn.SetReadDeadline(time.Now().Add(timeout))
    buf := make([]byte, 256)
    n, _ := conn.Read(buf)
    return strings.TrimSpace(string(buf[:n]))
}
Enter fullscreen mode Exit fullscreen mode

Integrate this into the worker after a successful dial. SSH, SMTP, FTP, and many custom services send a greeting immediately on connect. HTTP servers usually do not — you would need to send HEAD / HTTP/1.0\r\n\r\n as a probe first. For passive banner collection, this function catches a meaningful percentage of what you will encounter on real infrastructure.

Running responsibly

Port scanning systems you do not own, without written authorization, is illegal in most jurisdictions regardless of intent. If you are embedding this in internal tooling, add an allowlist of permitted CIDR ranges baked into the binary and write every scan to a structured log with operator identity, timestamp, target, and scope. Audit trails matter for compliance sign-off.

If you are doing authorized security assessments and want a reference for what a complete network security review looks like procedurally, our network security hardening checklists cover internal scanning policies including documentation requirements for different compliance frameworks.

The takeaway

The worker pool is the right abstraction for I/O-bound network tools in Go: clean cancellation via context, predictable resource consumption via bounded goroutine count, and code that scales from 10 workers to 1000 without restructuring. The patterns here — bounded goroutine pools, ordered channel closing, per-dial context propagation, token bucket rate limiting — apply directly to any network tool you build next: TLS certificate auditors, HTTP header scanners, service fingerprinters.

Start with 200-500 workers for LAN targets and add the rate limiter whenever you go external. Context propagation through every dial is non-optional if the scanner will run as part of a larger pipeline or long-lived service — it is what separates "works in a demo" from "works in production."


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)