Julien Sellier

Last update: 2026-09-20

TCP Port Scanning in Go

package tcputil

import (
	"bufio"
	"context"
	"errors"
	"io"
	"net"
	"os"
	"strings"
	"syscall"
	"time"
)

type PortStatus string

const (
	PortStatusUnknown            PortStatus = "unknown"             // Ex: context was cancelled before handshake could be performed.
	PortStatusOpen               PortStatus = "open"                // Explicitly open (= received SYN-ACK packet).
	PortStatusClosed             PortStatus = "closed"              // Explicitly closed (= received RST packet).
	PortStatusHostUnreachable    PortStatus = "host unreachable"    // No known route to that specific host.
	PortStatusNetworkUnreachable PortStatus = "network unreachable" // No route to the entire network/subnet.
	PortStatusTimeout            PortStatus = "timeout"             // No response was received.
)

func DialErrorPortStatus(err error) PortStatus {
	switch {
	default:
		return PortStatusUnknown
	case errors.Is(err, syscall.ECONNREFUSED):
		return PortStatusClosed
	case errors.Is(err, syscall.EHOSTUNREACH):
		return PortStatusHostUnreachable
	case errors.Is(err, syscall.ENETUNREACH):
		return PortStatusNetworkUnreachable
	case errors.Is(err, syscall.ETIMEDOUT), errors.Is(err, os.ErrDeadlineExceeded), errors.Is(err, context.DeadlineExceeded):
		return PortStatusTimeout
	}
}

func ScanPort(ctx context.Context, addr string) (status PortStatus, banner string, err error) {
	ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
	defer cancel()
	conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
	status = DialErrorPortStatus(err)
	if err != nil && status != PortStatusUnknown {
		return status, "", nil
	} else if err != nil {
		return status, "", err
	}
	defer conn.Close()

	deadline, _ := ctx.Deadline()
	conn.SetReadDeadline(deadline)

	done := make(chan struct{})
	defer close(done)
	go func() {
		select {
		case <-done:
		case <-ctx.Done():
			conn.Close()
		}
	}()

	banner, _ = bufio.NewReader(io.LimitReader(conn, 255)).ReadString('\n')
	return PortStatusOpen, strings.TrimSpace(banner), nil
}