NovaRadar scans Cloudflare IP ranges using Go's net and crypto/tls packages. Here's how it works.

NovaRadar: How a Go + React IP Scanner Does Two-Phase Verification


NovaRadar is a desktop IP scanner that finds working Cloudflare IPs. It pulls IP ranges from multiple sources, checks each IP with a TCP connection followed by a TLS handshake, and sorts the results by latency. Go backend, React frontend, connected through Wails — a framework for building desktop apps with Go backends and web frontends.

What I find worth digging into is the scanning architecture. The project is small, just a handful of Go files, but it packs in real patterns for concurrent network I/O, TLS handshakes, and structuring a Wails app that you can reuse in your own tools.

The Wails application structure

NovaRadar follows the standard Wails layout. main.go sets up the Wails application and binds the Go backend to the frontend. The core application struct lives in app.go, where Wails lifecycle methods like startup and shutdown are defined. This struct’s methods get exposed to the React frontend via Wails bindings.

If you’ve used Wails before, none of this is surprising. You define a struct, attach methods, pass it to wails.Run() in the options. The framework generates TypeScript bindings so React can call Go functions directly. No REST API, no WebSocket plumbing. Just method calls.

This is worth knowing if you’re building desktop tools in Go. It keeps things simple. Your Go code does the hard work (network scanning, concurrency), and the frontend handles presentation. If you’ve worked with the context package before, you’ll recognize how Wails passes a context.Context into the startup method, which the app stores and uses throughout its lifecycle.

How the scanner works

The scanning logic lives in scanner.go. This is where things get interesting from a Go standpoint.

NovaRadar performs what it calls “two-phase verification.” For each candidate IP, it does two things in sequence:

First, a TCP connect. Open a raw TCP connection to the IP on the target port. This confirms the host is reachable and accepting connections.

Second, a TLS handshake. Perform a TLS handshake over that connection using the configured SNI. In the current source, the deep test sets InsecureSkipVerify: true, so this proves the endpoint can complete a TLS handshake for that flow; it does not prove the certificate chains cleanly to the expected hostname.

Why bother with two phases? A TCP connect alone only proves the port accepted a connection. The TLS phase is a stronger signal that the IP can participate in the intended Cloudflare-fronted flow. In Go, this maps directly to two standard library packages: net for TCP and crypto/tls for the handshake.

The TCP phase uses net.DialTimeout to connect. If the connection succeeds, the scanner wraps it with tls.Client and calls Handshake(). The tls.Config supplies the SNI value and minimum TLS version.

Here’s a standalone example of this two-phase pattern:

package main

import (
	"crypto/tls"
	"fmt"
	"net"
	"time"
)

func verifyIP(ip string, port string, hostname string, timeout time.Duration) (time.Duration, error) {
	addr := net.JoinHostPort(ip, port)

	// Phase 1: TCP connect
	start := time.Now()
	conn, err := net.DialTimeout("tcp", addr, timeout)
	if err != nil {
		return 0, fmt.Errorf("tcp connect failed: %w", err)
	}
	defer conn.Close()

	// Phase 2: TLS handshake. NovaRadar skips certificate verification here;
	// do not copy that setting into code that needs real server identity checks.
	tlsConn := tls.Client(conn, &tls.Config{
		ServerName:         hostname,
		InsecureSkipVerify: true,
	})
	if err := tlsConn.Handshake(); err != nil {
		return 0, fmt.Errorf("tls handshake failed: %w", err)
	}
	latency := time.Since(start)

	return latency, nil
}

The latency measurement covers both phases, giving you an end-to-end number for how fast each IP responds and completes a TLS handshake. NovaRadar sorts results by this value.

Concurrent scanning with goroutines

Scanning thousands of IPs one at a time? You’d be waiting all day. scanner.go handles this concurrently, as you’d expect from Go.

The pattern is the classic bounded worker pool: spin up goroutines, use a buffered channel as a semaphore to cap how many run at once, collect results into a shared slice protected by a mutex.

package main

import (
	"fmt"
	"sync"
	"time"
)

type ScanResult struct {
	IP      string
	Latency time.Duration
	Err     error
}

func scanIPs(ips []string, concurrency int) []ScanResult {
	sem := make(chan struct{}, concurrency)
	var mu sync.Mutex
	var results []ScanResult

	var wg sync.WaitGroup
	for _, ip := range ips {
		wg.Add(1)
		go func(ip string) {
			defer wg.Done()
			sem <- struct{}{}        // acquire
			defer func() { <-sem }() // release

			latency, err := verifyIP(ip, "443", "example.com", 3*time.Second)

			mu.Lock()
			results = append(results, ScanResult{IP: ip, Latency: latency, Err: err})
			mu.Unlock()
		}(ip)
	}
	wg.Wait()

	return results
}

func main() {
	ips := []string{"1.0.0.1", "1.1.1.1", "104.16.0.1"}
	results := scanIPs(ips, 50)
	for _, r := range results {
		if r.Err == nil {
			fmt.Printf("%s: %v\n", r.IP, r.Latency)
		}
	}
}

The buffer size on the channel controls parallelism. sync.WaitGroup makes sure we don’t return before every goroutine finishes. The sync.Mutex protects the results slice from concurrent writes. If you’re building any network scanner or bulk HTTP checker in Go, this pattern transfers well.

For a deeper look at Go’s concurrency primitives, the post on the sync package covers WaitGroup and Mutex thoroughly.

IP range sources

sources.go handles fetching Cloudflare IP ranges. NovaRadar supports multiple selectable sources — different URLs or providers that publish Cloudflare’s IP ranges. This file defines those sources and parses the CIDR blocks.

Go’s net package has built-in CIDR support through net.ParseCIDR and the net.IPNet type. Expanding a CIDR block like 104.16.0.0/12 into individual IPs is straightforward:

func expandCIDR(cidr string) ([]string, error) {
	ip, ipNet, err := net.ParseCIDR(cidr)
	if err != nil {
		return nil, err
	}

	var ips []string
	for ip := ip.Mask(ipNet.Mask); ipNet.Contains(ip); incrementIP(ip) {
		ips = append(ips, ip.String())
	}
	return ips, nil
}

func incrementIP(ip net.IP) {
	for j := len(ip) - 1; j >= 0; j-- {
		ip[j]++
		if ip[j] > 0 {
			break
		}
	}
}

One thing to watch out for: a /12 block contains over a million IPs. In practice you’d want to sample or limit the range. But the parsing itself is clean — Go’s standard library does the heavy lifting.

Why Go fits here

Network scanners and Go are a natural match. Goroutines are cheap enough to run thousands of concurrent connections without managing thread pools yourself. The net and crypto/tls packages give you low-level control without dropping into C. And Wails lets you ship a desktop app with a Go backend without manually embedding a browser engine.

NovaRadar is a small project, but it brings together a useful combination: Wails for the desktop shell, goroutines for concurrent I/O, standard library packages for TCP/TLS verification. If you’re building a port scanner, a health checker, a latency monitor, the patterns in scanner.go and sources.go transfer directly.

Pulling these patterns into your own tools

NovaRadar is a good example of what a focused Go application looks like when it doesn’t try to be more than it needs to be. The entire backend fits in four files: main.go for the entry point, app.go for the Wails lifecycle, scanner.go for concurrent two-phase verification, and sources.go for IP range management.

The bounded concurrency with buffered channels, the TCP+TLS two-phase check against the standard library, the Wails bindings for desktop UI — I’ve used variations of all of these in other projects, and they hold up well. If you want to see another example of Go powering desktop or terminal applications, check out the post on building a terminal spreadsheet in Go.