When a service won't connect
You're deploying a microservice and the dashboard shows it's down, but the container is running. Or you're building a network tool and need to check which ports a target host is listening on. You need a port scanner. In Go, this is straightforward because the standard library handles the heavy lifting of TCP connections. You don't need raw sockets or third-party libraries. You just ask the network to connect and see what happens.
The concept: knocking on doors
A port scanner is just a loop that tries to open a TCP connection to a specific address and port. If the connection succeeds, the port is open. If it fails, the port is closed or filtered. Think of it like knocking on doors in a hallway. You walk up to door 80, knock, and wait. If someone opens the door, you know someone is home. If you knock and hear nothing, or the door is locked tight, you move to the next one. Go's net package gives you the knock. net.Dial attempts the connection. The error tells you if the door opened.
TCP connections require a three-way handshake. The client sends a SYN packet. The server replies with SYN-ACK. The client finishes with ACK. Only then is the connection established. A port scanner exploits this protocol. An open port completes the handshake. A closed port sends a RST packet to reject the connection. A filtered port might drop the packet entirely, causing a timeout. Your scanner reads these outcomes and reports the status.
Minimal example
Here's the simplest port scan: dial a single port, check the error, close the connection.
package main
import (
"fmt"
"net"
"time"
)
// scanPort checks if a TCP port is open on the given host.
func scanPort(host string, port int) bool {
// Format address as "host:port" for the dialer.
addr := fmt.Sprintf("%s:%d", host, port)
// Dial with a timeout to avoid hanging on unresponsive hosts.
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
// Connection failed: port is closed, filtered, or host unreachable.
return false
}
// Close immediately; we only care if the handshake succeeded.
conn.Close()
return true
}
func main() {
if scanPort("127.0.0.1", 80) {
fmt.Println("Port 80 is open")
}
}
Close the connection immediately. Leaking connections wastes file descriptors and can trigger rate limiting on the target.
What happens under the hood
When you run this, net.DialTimeout initiates the TCP handshake. It sends a SYN packet to the target IP and port. If the target has a service listening, the kernel accepts the connection and replies with SYN-ACK. Go's runtime completes the handshake with ACK and returns a net.Conn object. The function returns nil for the error.
If the target has no listener, the kernel sends a RST packet. Go receives this and returns an error like dial tcp 127.0.0.1:80: connect: connection refused. If the target is behind a firewall that drops packets, the SYN never gets a reply. The dial blocks until the timeout expires, then returns an error like dial tcp 127.0.0.1:80: i/o timeout.
The conn.Close() call sends a FIN packet to tear down the connection cleanly. This matters because leaving connections open wastes file descriptors. Go's garbage collector will eventually reclaim the connection, but relying on GC for network resources is dangerous. Connections hold OS file descriptors. If you leak them, the OS will reject new connections with too many open files. Always close resources explicitly.
Modern Go code prefers net.DialContext over net.DialTimeout. DialContext allows you to pass a context.Context for cancellation. DialTimeout is a convenience wrapper, but DialContext is more flexible. Use DialContext when you need to integrate with request lifecycles. In production code, pass a context.Context as the first argument to your scanning functions. Use context.WithTimeout to cancel the entire scan if it takes too long. Functions that take a context should respect cancellation and deadlines.
Scanning a range concurrently
Scanning ports sequentially is slow. A realistic scanner uses goroutines to check multiple ports at once. This example scans a range, collects results, and handles timeouts.
The worker handles one port. It dials, checks the error, closes the connection, and sends the result back.
// Result holds the outcome of a single port scan.
type Result struct {
Port int
IsOpen bool
Err error
}
// worker scans a single port and sends the result to the channel.
func worker(host string, port int, results chan<- Result) {
addr := fmt.Sprintf("%s:%d", host, port)
// Short timeout keeps the scanner fast even against slow targets.
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
res := Result{Port: port, IsOpen: err == nil, Err: err}
if conn != nil {
// Close immediately to free resources and avoid half-open connections.
conn.Close()
}
results <- res
}
The scanner loops over ports, spawns a goroutine for each, and waits for all results.
// scanRange spawns workers for each port and collects results.
func scanRange(host string, ports []int) []Result {
results := make(chan Result, len(ports))
var wg sync.WaitGroup
for _, port := range ports {
wg.Add(1)
go func(p int) {
defer wg.Done()
worker(host, p, results)
}(port)
}
// Close channel once all workers finish.
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for res := range results {
collected = append(collected, res)
}
return collected
}
The goroutine that closes the channel is essential. If you forget to close the channel, the range loop in the caller will block forever, causing a goroutine leak. Always have a cancellation path or a close signal. The worst goroutine bug is the one that never logs.
The WaitGroup tracks active goroutines. Add(1) increments the counter. Done() decrements it. Wait() blocks until the counter hits zero. This pattern is standard for fan-out/fan-in concurrency. The Result type is exported because it crosses package boundaries if you extract the scanner into a library. Public names start with a capital letter. Private names start lowercase. No keywords like public or private.
Bound your concurrency. A scanner without limits is a denial-of-service attack waiting to happen. If you scan a large range, add a semaphore channel to limit concurrent dials. Spawning a goroutine for every port can crash the program with fatal error: all goroutines are asleep - deadlock! if the OS runs out of file descriptors.
The UDP trap
TCP scanners are easy. UDP scanners are hard. UDP has no handshake. You send a datagram and wait. If the port is closed, you might get an ICMP error, or nothing. Go's net package makes UDP scanning tricky because Dial on UDP doesn't establish a connection; it just binds. You have to use WriteTo and ReadFrom with timeouts.
Here's a UDP probe that sends a packet and waits for a response.
// probeUDP sends a datagram and waits for a response.
func probeUDP(host string, port int) bool {
addr := &net.UDPAddr{IP: net.ParseIP(host), Port: port}
// DialUDP binds a local socket; it does not connect to the remote host.
conn, err := net.DialUDP("udp", nil, addr)
if err != nil {
return false
}
defer conn.Close()
// Set deadline to prevent blocking forever on silent drops.
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
// Send a minimal probe; some services respond to any data.
_, err = conn.Write([]byte{0x00})
if err != nil {
return false
}
// Read any response; success means the port is likely open.
_, err = conn.Read(make([]byte, 1))
return err == nil
}
UDP scanners produce false negatives. Firewalls often drop ICMP unreachable messages. If you don't get a response, the port might be open but silent, or closed and filtered. You can't trust a UDP scanner as much as a TCP scanner. Some protocols like DNS or SNMP have specific probe packets that elicit responses. A generic UDP probe might not work for all services.
Trust TCP, doubt UDP. TCP gives you a reliable handshake. UDP gives you a best-effort guess.
Pitfalls and errors
Common mistakes include forgetting the timeout, which causes the scanner to hang on filtered ports. The compiler won't catch this; it's a runtime behavior. If you try to use a variable from the loop in a goroutine without capturing it, you get a bug. In Go 1.22+, the compiler rejects this with loop variable i captured by func literal. Before that, you'd get a race condition where all goroutines scan the last port. Always pass the loop variable as an argument to the goroutine closure.
Another pitfall is resource exhaustion. Spawning too many goroutines can crash the program. Use a semaphore or worker pool to bound concurrency. Also, net.Dial can return an error that isn't a simple "connection refused". Firewalls might drop packets silently. Your scanner should distinguish between "closed" and "timeout". A timeout doesn't always mean the port is closed; it might mean the packet was dropped.
The error handling here is explicit. Go requires you to check errors. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore a connection failure. If you try to use a value without checking the error, the compiler complains. Forget to use a variable and you get declared and not used. Forget to import a package and you get undefined: pkg. The compiler is your friend.
Run gofmt on this code. The indentation and spacing are standardized. Don't argue about formatting; let the tool decide. Most editors run it on save. Trust gofmt. Argue logic, not formatting.
Distinguish between closed and filtered. A timeout isn't always a closed port.
Decision matrix
Use net.DialTimeout for simple checks where you need a single port status with a hard deadline.
Use net.DialContext when you need to integrate with request lifecycles or cancel a scan from the caller.
Use a goroutine per port with a buffered channel when scanning a small range and you want to collect all results in memory.
Use a worker pool with a semaphore when scanning a large range to limit concurrency and protect the target from being overwhelmed.
Use context.WithTimeout when you need to cancel a long-running scan from the caller, such as in an HTTP handler or CLI with a cancel flag.
Use sequential scanning when you are rate-limited by the target or need to respect a strict order of operations.
Use net.DialUDP with WriteTo and ReadFrom when scanning UDP ports, but expect false negatives due to ICMP filtering.
Use a third-party library like gopacket when you need raw packet inspection or custom TCP flags, which requires handling connectionless protocols differently.