Learning Go

March 30, 2026

Go Learning Programming Cybersecurity

When you are learning a new programming language it is easy to fall into “traps”:

  • Tutorial Hell - watching tutorials and copying code without understanding or learning anything
  • Vibe Coding - letting LLMs write the code for you and getting a false sense of understanding

My goal is to learn Go through cybersecurity projects, without using tutorials, vibe coding, or copying code. Just the docs. Stdlib only, no external libraries.

Resources I’ve used:

Why this approach

When I learned Laravel, I found out that the most efficient way to learn a language is to:

  1. Read the docs top to bottom
  2. Build something you actually care about (an interest), so you don’t give up after a few hours
  3. Get stuck —> Go back to the docs

I am not a big fan of the “don’t reinvent the wheel” stuff, reinventing things is a great way to learn.


Part 1: TCP Port Scanner

Why a port scanner?

A port scanner is a fundamental concept in network reconnaissance. Before you can attack a system, you need to know what’s running on it. Building one teaches you:

  • How TCP connections work
  • How to handle concurrency (scanning ports one by one is slow)
  • How to write CLI tools in Go

How TCP connections work

At the network level, a TCP connection starts with a three-way handshake:

  1. Your machine sends a SYN packet
  2. If the port is open, the server replies with SYN-ACK
  3. Your machine responds with ACK

A port scanner just sends that first step to every port in a range. If you get to step two, the port is open.

My first learning moment

I went looking for a package to make TCP connections and found the net package. net.Dial and net.DialTimeout immediately caught my eye, so I looked into the difference.

At first I assumed that DialTimeout mattered because you don’t want to flood the network, I quickly found out that’s wrong. The real issue is filtered ports.

A port can be in one of three states:

  • Open — something is listening, the connection succeeds
  • Closed — nothing is listening, the connection is refused immediately
  • Filtered — a firewall is blocking it, the SYN packet just disappears

That third state is the problem. If a port is filtered and you use net.Dial, your function will hang indefinitely waiting for a response that will never come. net.DialTimeout lets you set a deadline, if nothing responds within that you can assume that port is either closed or filtered.

// Attempt a TCP connection to localhost:80.
// If it doesn't respond within a second, treat it as closed/filtered.
conn, err := net.DialTimeout("tcp", "127.0.0.1:80", time.Second)

Closed vs. filtered doesn’t matter much here since I’m only interested in open ports anyway.

The code

package main

import (
    "flag"
    "fmt"
    "net"
    "sync"
    "time"
)

func scanPort(host string, port int, timeout time.Duration) bool {
    // Port is an int but net.DialTimeout expects a string 
    // fmt.Sprintf handles the conversion.
    address := fmt.Sprintf("%s:%d", host, port)
    conn, err := net.DialTimeout("tcp", address, timeout)
    if err != nil {
        return false
    }
    conn.Close()
    return true
}

func main() {
    var wg sync.WaitGroup

    host := flag.String("host", "127.0.0.1", "target host")
    startPort := flag.Int("start", 1, "start port")
    endPort := flag.Int("end", 100, "end port")
    flag.Parse()

    for port := *startPort; port <= *endPort; port++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if scanPort(*host, port, time.Second) {
                fmt.Printf("Port %d is open\n", port)
            }
        }()
    }

    wg.Wait()
}

CLI flags

The flag package handles CLI arguments:

host := flag.String("host", "127.0.0.1", "target host")

This creates a --host flag with a default value and a description. One thing that tripped me up: flag.String() returns a pointer to a string, not the string itself. You have to dereference it with * to get the actual value.

fmt.Println(host)   // prints a memory address: 0x29b0cdaec0b0
fmt.Println(*host)  // prints the actual string: 127.0.0.1

After flag.Parse(), you can run the scanner like:

go run main.go --host 192.168.1.1 --start 1 --end 1000

Concurrency with goroutines

Scanning 1000 ports sequentially with a 1-second timeout would take over 16 minutes. The fix is goroutines, Go’s built-in concurrency primitive, and one of my main reasons for wanting to learn Go.

Prefix any function call with go and it runs concurrently:

go func() {
    // runs concurrently
}()

The catch: if main() finishes before the goroutines do, they get killed. sync.WaitGroup solves this, it’s a counter that lets you wait until all goroutines have finished.

var wg sync.WaitGroup
wg.Add(1)           // increment the counter
go func() {
    defer wg.Done() // decrement when done
    // ...
}()
wg.Wait()           // block until counter reaches 0

Next up: a banner grabber, an extension to this project that identifies what is running on the open ports, not just that they’re open.