SenPaiScanner: How a Cloudflare IP Scanner Uses Go Concurrency
SenPaiScanner is a Cloudflare IP scanner written in Go. It probes IP ranges, validates candidates through Xray, and puts everything behind a terminal UI built with Bubble Tea. The whole thing is small enough to read in an afternoon, which is part of why I like it — you can hold the architecture in your head while still picking up real patterns: concurrent workers, embedded files, Xray config generation, and TUI rendering.
Here’s how it fits together.
Project structure and entry point
The repo has a layout I wish more Go CLI projects followed. Entry point is cmd/senpaiscanner/main.go. Everything else lives in internal/ packages: engine, prober, ipsrc, xraytest, ui, config, result, output, and banner.
Each package does one thing. engine orchestrates scanning. prober handles network probes. xraytest builds and runs Xray configs to test connectivity. ui manages the Bubble Tea model. If you’ve ever stared at a 2,000-line main.go and wondered how people organize Go CLI tools, this is a solid reference.
There’s also pkg/version/version.go for version info, injected at build time via -ldflags. The Makefile and .goreleaser.yaml both set those values, and GoReleaser handles release packaging.
IP range loading with embedded files
Inside internal/ipsrc/ sit two text files: ranges_v4.txt and ranges_v6.txt. These are Cloudflare’s IPv4 and IPv6 CIDR ranges.
Go’s embed package (since Go 1.16) lets you compile static files straight into your binary. The internal/ipsrc/ipsrc.go file uses a //go:embed directive, which means the scanner ships as a single binary. No config files to bundle alongside it, no runtime fetching.
I see this pattern constantly in Go CLI tools and it’s one of those small things that makes distribution painless.
The internal/ipsrc/neighbors.go file picks nearby IPv4 addresses around a hit while keeping them inside known Cloudflare ranges. Go’s net package gives you net.ParseCIDR and net.IPNet for range checks, while the code uses encoding/binary to move around IPv4 addresses as integers.
If you’ve worked with Go’s net package before, you know IP addresses are byte slices. Incrementing them, checking containment, splitting ranges — all straightforward without pulling in third-party libraries.
The engine: coordinating concurrent workers
internal/engine/engine.go is where the real work happens. A scanner that probes IPs sequentially would be unusable. You need concurrency.
The approach here is the standard Go worker pool: spin up N goroutines, feed them work through a channel, collect results through another. This pattern is one of Go’s best fits for I/O-bound work. It’s simple, it scales, and it is easy to reason about when the channels have clear ownership.
A typical implementation looks like this:
func runWorkers(ctx context.Context, ips <-chan string, results chan<- Result, workers int) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ip := range ips {
if ctx.Err() != nil {
return
}
r := probe(ip)
results <- r
}
}()
}
wg.Wait()
close(results)
}
The engine coordinates between the IP source, the prober, and the Xray tester. IPs flow into the pipeline, probe results come out, and everything gets forwarded to the UI. The context.Context parameter handles cancellation — when the user quits the TUI, workers stop. If you want a refresher on how context works in Go, check out this post on context.
Probing and Xray testing
internal/prober/prober.go does the network probing itself. It supports TCP, TLS, and HTTP modes, using net, crypto/tls, and net/http to measure latency and collect details such as TLS success, HTTP status, and Cloudflare colo when available.
The more interesting part is internal/xraytest/. It has three files: builder.go, parser.go, and runner.go. This package is not a shell-out wrapper. parser.go parses VLESS, Trojan, and VMess share URLs into a common config struct. builder.go turns that into Xray JSON. runner.go writes a temporary config, decodes it through Xray’s own config loader, and starts an embedded Xray instance through imported Xray packages. That avoids relying on a separate xray binary being present on PATH.
This three-file split — parse input, build config, run validation — is a clean way to wrap a complex dependency. Each step is testable on its own, which matters more than people think when you’re debugging why a specific IP probe failed at 2am.
Terminal UI with Bubble Tea
The internal/ui/ package has model.go, pages.go, cmds.go, and live_results.go. This is a Bubble Tea application, which follows the Elm architecture: a Model holds state, Update handles messages, View renders the screen.
model.go defines the application state. pages.go implements different screens (configuration, scanning progress, results). cmds.go defines Bubble Tea commands, which are functions that return messages asynchronously.
live_results.go is the most interesting file here. As the engine produces scan results, the UI needs to update in real time. Bubble Tea handles this through its command system: a command listens on a channel and sends messages back to the update loop.
The pattern looks like this:
func waitForResult(resultCh <-chan result.Result) tea.Cmd {
return func() tea.Msg {
r, ok := <-resultCh
if !ok {
return scanDoneMsg{}
}
return newResultMsg{result: r}
}
}
Every time a result arrives on the channel, Bubble Tea gets a message and re-renders. The UI stays responsive because Bubble Tea runs its own event loop. This is a concrete example of Go channels bridging concurrent workers and UI rendering, and it’s surprisingly elegant once you see it working.
If you’ve built terminal apps before, you might like our post on a spreadsheet built as a TUI in Go — similar patterns throughout.
Configuration and output
internal/config/config.go handles user settings such as worker count, timeout duration, scan mode, output path, ports, and optional WebSocket checks. internal/output/output.go can write text, CSV, or JSON/JSONL, so users can pipe results into other tools.
Go’s encoding/csv and encoding/json packages make this trivial. The result types in internal/result/result.go give both the engine and output packages a shared struct to work with.
CI and release pipeline
.github/workflows/ci.yml and .github/workflows/release.yml handle testing and releases. GoReleaser builds binaries for multiple platforms, generates checksums, and creates GitHub releases. Standard stuff for Go CLI tools, and it works well.
Patterns to study
SenPaiScanner is small, but the patterns here show up in real Go applications all the time:
- Worker pools with channels for concurrent I/O
embedfor static assets so your binary has zero external dependencies- direct use of Xray packages when a separate process would be awkward to ship
- Bubble Tea for building terminal UIs that update in real time
- A
cmd/+internal/+pkg/layout that keeps packages focused
The whole codebase fits in one reading session. If you’re building Go CLI tools and want to see how these pieces connect in practice, it’s worth cloning. And if you’re interested in other ways to keep Go project structure clean, the functional options pattern pairs well with this kind of architecture.