Building Network Scanners in Go: How Ullaakut/nmap Wraps a 25-Year-Old Binary
Hook
The most popular network scanning library for Go has exactly zero networking code in it. Instead, it shells out to a 1997 binary and parses XML—and that's precisely why it works so well.
Context
Network scanning is deceptively hard. What seems like a simple "ping this port" operation quickly spirals into handling TCP handshakes, ICMP packets, OS fingerprinting, service detection, and dozens of evasion techniques for firewalls. Nmap spent 25 years solving these problems, accumulating thousands of service signatures, OS detection fingerprints, and edge cases that only emerge when scanning the chaos of real-world networks.
When Go developers need network scanning capabilities—whether for security audits, infrastructure monitoring, or penetration testing tools—they face a choice: reimplement nmap's logic in pure Go, or wrap the existing binary. Ullaakut/nmap takes the pragmatic path. It treats nmap as a trusted subprocess, marshaling commands through os/exec and parsing the XML output into Go structs. This design acknowledges a truth that many libraries ignore: sometimes the best code is the code you don't write.
Technical Insight
The library's architecture centers on the Scanner type, which builds nmap commands using the functional options pattern. You configure scans by chaining methods that append flags to the underlying command. Want to scan ports 80 and 443 with OS detection? That translates directly to nmap flags:
import (
"context"
"fmt"
"log"
"time"
"github.com/Ullaakut/nmap"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
scanner, err := nmap.NewScanner(
nmap.WithTargets("192.168.1.0/24"),
nmap.WithPorts("80,443,22"),
nmap.WithServiceInfo(),
nmap.WithOSDetection(),
nmap.WithContext(ctx),
)
if err != nil {
log.Fatalf("unable to create scanner: %v", err)
}
result, warnings, err := scanner.Run()
if err != nil {
log.Fatalf("scan failed: %v", err)
}
for _, warning := range *warnings {
log.Printf("warning: %s", warning)
}
for _, host := range result.Hosts {
if len(host.Ports) == 0 || len(host.Addresses) == 0 {
continue
}
fmt.Printf("Host %s:\n", host.Addresses[0].Addr)
for _, port := range host.Ports {
fmt.Printf(" Port %d/%s: %s (%s)\n",
port.ID,
port.Protocol,
port.State.State,
port.Service.Name,
)
}
}
}
Under the hood, this generates an nmap command like nmap -p 80,443,22 -sV -O -oX - 192.168.1.0/24 and captures the XML output. The -oX - flag tells nmap to write machine-readable XML to stdout, which the library deserializes into strongly-typed structs. This XML parsing approach gives you access to nmap's full data model—not just open ports, but service versions, OS guesses with confidence percentages, timing information, and script output.
The async scanning mode showcases Go's concurrency primitives. Instead of blocking until completion, you can stream stdout and stderr through channels while the scan progresses:
scanner, err := nmap.NewScanner(
nmap.WithTargets("example.com"),
nmap.WithVerbosity(3),
)
if err := scanner.RunAsync(); err != nil {
log.Fatal(err)
}
stdout := scanner.GetStdout()
stderr := scanner.GetStderr()
go func() {
for line := range stdout {
fmt.Printf("[NMAP] %s\n", line)
}
}()
go func() {
for line := range stderr {
fmt.Fprintf(os.Stderr, "[ERROR] %s\n", line)
}
}()
result, warnings, err := scanner.Wait()
This pattern is particularly valuable for long-running scans where you want real-time feedback. The library also supports progress tracking through a callback mechanism that parses nmap's periodic status updates, though this requires TTY support and comes with caveats about non-monotonic progress values.
Context integration provides graceful cancellation. When your context times out or gets canceled, the library sends SIGTERM to the nmap process and cleans up resources. This makes it trivial to implement scan timeouts or user-initiated cancellation in web services or CLI tools.
The functional options pattern deserves attention. Rather than a massive constructor or builder with dozens of methods, each option is a function that modifies the scanner configuration. This approach scales beautifully—adding support for new nmap flags doesn't require API changes, just new option functions. It's a textbook example of idiomatic Go API design: flexible, composable, and backward-compatible.
Gotcha
The elephant in the room is the external dependency. Your Go binary won't work on systems without nmap installed, which complicates deployment. Docker containers need nmap in the base image. AWS Lambda functions require custom layers. Cross-compilation doesn't magically include nmap—you need separate installation mechanisms per platform. This isn't a static binary you can scp to a server and run.
Privilege requirements create operational friction. Many valuable scan types (SYN scans, OS detection, spoofing) require elevated privileges. You can't just run your Go service as a unprivileged user; you need either root access, sudo configuration, or Linux capabilities like CAP_NET_RAW. In Kubernetes, this means security contexts and pod policies. In serverless environments, it's often impossible. The library works around some of this by falling back to less privileged scan types, but you lose functionality. Progress tracking also has sharp edges—it only works in TTY environments, and nmap's progress estimates can decrease as the scan learns more, violating assumptions most progress bars make. The documentation warns about this, but it still catches developers off guard when their progress bar jumps backward.
Verdict
Use if: You're building security tools, network discovery services, or infrastructure auditing where nmap's comprehensiveness justifies the deployment complexity. The library excels when you control the deployment environment and can ensure nmap availability, or when you're already running in environments where nmap is standard (security-focused containers, pentesting distributions, infrastructure monitoring nodes). Its idiomatic Go design makes it the obvious choice for integrating nmap into larger Go codebases. Skip if: You need pure Go for easy cross-compilation, are working in restricted environments where external binaries aren't allowed, require privilege-free operation for all features, or only need basic TCP connectivity checks that the net package handles fine. For high-speed, simple port scanning across massive ranges, consider masscan with a similar wrapper approach. For complete control over packet crafting, gopacket gives you raw networking primitives without external dependencies, though you'll reimplement scan logic yourself.