Learning Go
March 30, 2026
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:
- Read the docs top to bottom
- Build something you actually care about (an interest), so you don’t give up after a few hours
- 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:
- Your machine sends a
SYNpacket - If the port is open, the server replies with
SYN-ACK - 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.