Back to Articles

Building Type-Safe Network Scanners in Go with Ullaakut/nmap

[ View on GitHub ]

Building Type-Safe Network Scanners in Go with Ullaakut/nmap

Hook

Nmap is arguably the most powerful network scanning tool ever created, but calling it from Go typically means string concatenation nightmares and regex parsing stderr output. There’s a better way.

Context

Network scanning is fundamental to security auditing, DevOps monitoring, and penetration testing. While nmap has been the gold standard since 1997, integrating it into modern Go applications has traditionally meant shelling out with os/exec, building command strings manually, and parsing unstructured output. This creates brittle code where typos in option flags only surface at runtime, where parsing errors fail silently, and where managing long-running scans requires custom process management.

The Ullaakut/nmap library solves this by providing an idiomatic Go wrapper that preserves nmap’s full power while adding type safety, structured error handling, and Go-native concurrency primitives. Rather than reimplementing nmap’s decades of network scanning expertise in pure Go, it takes a pragmatic approach: use the battle-tested nmap binary for what it does best, and wrap it in Go abstractions that developers actually want to use. The result is a library that lets you write scanner.WithPorts("22,80,443") instead of concatenating “-p 22,80,443” strings, and gives you strongly-typed Host structs instead of regex parsing XML output.

Technical Insight

Async Mode

WithTargets, WithPorts

Construct command

exec.Command

XML output

Unmarshal

Hosts, Ports, Services

concurrent

parse incrementally

push updates

Go Application

Scanner Builder

Command Executor

nmap Binary

Subprocess

XML Parser

Result Structs

Stdout/Stderr Stream

Go Channels

System architecture — auto-generated

At its core, Ullaakut/nmap uses the builder pattern to construct type-safe nmap commands, executes them as subprocesses, and unmarshals the XML output into Go structs. The library doesn’t reinvent scanning logic—it delegates to the nmap binary and focuses on providing excellent developer ergonomics around it.

Here’s a practical example scanning a subnet for web servers with service detection:

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/Ullaakut/nmap/v3"
)

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

    scanner, err := nmap.NewScanner(
        ctx,
        nmap.WithTargets("192.168.1.0/24"),
        nmap.WithPorts("80,443,8080"),
        nmap.WithServiceInfo(),
        nmap.WithTimingTemplate(nmap.TimingAggressive),
    )
    if err != nil {
        log.Fatalf("unable to create nmap scanner: %v", err)
    }

    result, warnings, err := scanner.Run()
    if err != nil {
        log.Fatalf("nmap scan failed: %v", err)
    }

    if len(warnings) > 0 {
        log.Printf("Warnings: %v", warnings)
    }

    for _, host := range result.Hosts {
        if len(host.Ports) == 0 {
            continue
        }

        fmt.Printf("Host: %s\n", host.Addresses[0])
        for _, port := range host.Ports {
            fmt.Printf("  Port %d/%s: %s (%s %s)\n",
                port.ID,
                port.Protocol,
                port.State.State,
                port.Service.Name,
                port.Service.Version,
            )
        }
    }
}

The WithServiceInfo() option maps to nmap’s -sV flag, but you get compile-time safety and IDE autocomplete. The context integration means you can cancel scans gracefully or enforce timeouts without manual process killing. The returned result object contains strongly-typed structs with fields like Host.OS, Port.Scripts, and Service.Version that are directly accessible without parsing.

For long-running scans across large networks, the library supports asynchronous execution with real-time progress updates. This is where the architecture gets interesting—it launches nmap with --stats-every flags and parses stdout continuously:

scanner, err := nmap.NewScanner(
    ctx,
    nmap.WithTargets("10.0.0.0/16"),
    nmap.WithMostCommonPorts(1000),
)

if err := scanner.RunAsync(); err != nil {
    log.Fatal(err)
}

for scanner.Scanner.Scan() {
    progress := scanner.GetProgress()
    fmt.Printf("Scanned: %.2f%% (%d hosts)\r",
        progress.Percent,
        len(progress.Hosts),
    )
}

result, warnings, err := scanner.Wait()

The async mode spawns a goroutine that reads nmap’s stdout/stderr streams, parses progress via terminal escape sequences, and makes results available through channels. This streaming architecture is critical for building responsive CLI tools or web dashboards that show scan progress in real-time.

Under the hood, all results are parsed from nmap’s XML output format using Go’s encoding/xml package. The library defines comprehensive struct types that map to nmap’s XML schema—Run, Host, Port, Service, OS, Script, etc. This means you get the full fidelity of nmap’s output in a structured format. Want OS detection results? They’re in host.OS.OSMatches[0].Name. Need NSE script output? It’s in port.Scripts[0].Output. The alternative—parsing nmap’s human-readable output with regex—would be a maintenance nightmare.

The builder pattern implementation is particularly elegant. Each WithX() function returns the scanner instance, allowing method chaining while maintaining immutability of the underlying options slice. Options are validated at scanner creation time, catching errors like invalid port ranges or conflicting scan types before subprocess execution. This fail-fast approach saves debugging time compared to discovering issues only after nmap starts.

Gotcha

The elephant in the room is the nmap binary dependency. This isn’t a pure Go implementation—it shells out to nmap, which must be installed and accessible on your system’s PATH. For containerized deployments, this means your Docker image needs nmap installed (adding ~40MB). Cross-compilation is straightforward for your Go binary, but you still need to ensure nmap exists on target systems. If you’re deploying to locked-down environments where installing system packages is difficult, this dependency becomes a blocker.

Privilege requirements are another gotcha that catches developers off guard. Many of nmap’s most powerful features—SYN scans, OS detection, some spoofing techniques—require raw socket access, which means root privileges on Linux or special capabilities. Your beautifully written Go program suddenly needs sudo or setcap, which complicates deployment and creates security considerations. The library doesn’t abstract this away; if nmap needs privileges, your program does too. During development, you’ll hit “permission denied” errors and need to rerun with elevated rights, which slows the feedback loop.

Progress reporting has platform-specific quirks. It relies on nmap’s ANSI escape sequences and --stats-every output, which only works cleanly when stdout is a TTY. If you’re logging to files, running in containers without TTY allocation, or piping output to systemd, progress updates become garbled or disappear entirely. You’ll need conditional logic to enable progress tracking only in interactive contexts, or accept that progress visibility varies by environment.

Verdict

Use if: You’re building security tools, network monitoring systems, or DevOps automation where nmap’s battle-tested scanning capabilities are essential, and you can accept the nmap binary dependency. This library excels when you need advanced features like service version detection, OS fingerprinting, or NSE script integration, and you want the type safety and concurrency primitives that Go provides. It’s particularly valuable for CLI tools where async scanning with progress updates creates excellent UX, or for backend services that need structured scan results without regex parsing fragility. Skip if: You need a pure Go solution for easy containerization or platforms where installing nmap is problematic, you’re only doing basic TCP connect scans where net.Dial would suffice, or you can’t run with elevated privileges in production. For Windows-heavy environments, the nmap installation overhead may not be worth it. Consider projectdiscovery/naabu for pure Go port scanning or gopacket if you need low-level packet control and can invest in custom scanner logic.

// ADD TO YOUR README
[![Featured on Starlog](https://starlog.is/api/badge/cybersecurity/ullaakut-nmap.svg)](https://starlog.is/api/badge-click/cybersecurity/ullaakut-nmap)