EasySNI uses raw sockets and TLS manipulation in Go to get around network censorship. I dug into how it's built.

EasySNI: How a Go tool rewrites TLS packets to bypass censorship


Plenty of network filtering happens before encryption really gets going. A TLS ClientHello can still expose the hostname through SNI, and simple SNI-based filters can block the connection before the handshake finishes.

EasySNI is a Go project that experiments with rewriting, fronting, and fragmenting SNI (Server Name Indication) traffic so basic SNI filters see something different from the application-level destination. What caught my attention isn’t only the circumvention angle. The repo is a dense grab bag of Go networking patterns: raw sockets, build tags, package boundaries, system proxy setup, and goroutine-based relay code. It also builds with CGO_ENABLED=0.

What SNI manipulation actually means

The SNI field sits inside the TLS ClientHello message. It’s the one piece of your HTTPS connection that’s sent unencrypted, and it tells the server which hostname you want. Deep packet inspection systems love this field because it’s trivial to read and match against blocklists.

EasySNI tries to work around this by swapping SNI values, fronting requests through CDN edges, or fragmenting the TCP packets that carry the ClientHello. That does not defeat every kind of filtering, and the README is explicit that it helps with SNI-based blocking rather than destination-IP blocking. From a Go perspective, the useful part is how much low-level networking the project manages without cgo.

Project structure and Go patterns

Everything lives under internal/, which is a deliberate choice. Go enforces that packages under internal/ can’t be imported by code outside the module. The APIs are private, they can break between commits, and nobody should depend on them. The packages break down like this:

  • internal/sni/ handles SNI rewriting and domain fronting
  • internal/desync/ does TCP desynchronization with raw sockets
  • internal/proxy/ runs the local proxy server
  • internal/sysproxy/ configures system proxy settings per platform
  • internal/server/ provides an HTTP server with config and handlers
  • internal/splus/ implements SOCKS5 proxy and tunnel transport

Each package does one thing. Small packages, clear boundaries. If you’re trying to figure out how to structure a Go project that’s bigger than a single main.go but not a sprawling monorepo, this is a useful reference alongside the official project layout guidance.

SNI rewriting in internal/sni/

The internal/sni/sni.go file parses SNI fields out of raw TLS ClientHello bytes. internal/sni/rewrite.go modifies those values before forwarding. And internal/sni/front.go implements domain fronting — you set the SNI to some innocuous CDN domain while the HTTP Host header points at your real destination.

There’s also internal/sni/cloudflare.go for Cloudflare-specific fronting, which makes sense given Cloudflare’s massive edge network.

The Go-specific thing worth noting here: you’re doing byte-level TLS parsing. Go’s encoding/binary package and slice operations make it pretty natural to pull apart a ClientHello by hand. Read the 5-byte record header, check the content type, walk through extensions until you hit the SNI extension (type 0x0000). If you’ve used Go’s crypto/tls package, you know it doesn’t expose raw ClientHello manipulation at all. You’re on your own at the byte level, and that’s fine.

Platform-specific raw sockets in internal/desync/

This is where things get interesting from a Go tooling perspective. TCP desynchronization fragments the ClientHello so DPI systems can’t reconstruct the SNI. That means raw socket access, and raw sockets work differently on every OS.

EasySNI solves this with build tags:

  • internal/desync/raw_linux.go for Linux
  • internal/desync/raw_windows.go for Windows
  • internal/desync/raw_other.go as a fallback

Shared logic stays in internal/desync/desync.go. This is textbook build constraint usage. Each platform file uses syscall or golang.org/x/sys to open raw sockets and set options like IP_HDRINCL for packet fragmentation.

On Linux, the raw-socket path uses the platform implementation in raw_linux.go. Windows goes through WinDivert support. The raw_other.go file is explicit: raw segment injection is unavailable outside Linux and Windows, and it returns an error that names the current runtime.GOOS.

This pattern — shared logic file plus per-platform files gated by build tags — is exactly what Go’s standard library does throughout os, net, and syscall. If you want to learn cross-platform Go that touches the OS directly, this package is a good case study. Understanding how context and cancellation work across goroutines is a related skill you’ll need for this kind of networked code.

System proxy configuration in internal/sysproxy/

Another clean build tag example:

  • internal/sysproxy/sysproxy.go defines the shared interface
  • internal/sysproxy/sysproxy_darwin.go handles macOS
  • internal/sysproxy/sysproxy_linux.go handles Linux

On macOS, setting the system proxy means shelling out to networksetup. On Linux, you might be writing environment variables or poking at GNOME/KDE settings. Go’s os/exec package makes this straightforward:

package sysproxy

import "os/exec"

func setProxyDarwin(host string, port string) error {
	cmd := exec.Command("networksetup", "-setwebproxy", "Wi-Fi", host, port)
	return cmd.Run()
}

Wrap OS commands behind a shared interface, let build tags pick the right one at compile time. It’s unglamorous but it works well.

Proxy and tunnel architecture

internal/proxy/proxy.go sets up a local proxy server that accepts connections, peeks at the first bytes to detect TLS ClientHellos, applies SNI rewriting, and forwards traffic.

The internal/splus/ package adds another layer on top:

  • internal/splus/socks5.go — a SOCKS5 proxy
  • internal/splus/tunnel.go — tunnel management
  • internal/splus/transport.go — transport abstraction
  • internal/splus/relay.go — bidirectional data relay

SOCKS5 is a natural fit because it’s protocol-agnostic and simple to implement. The core is: read version/method selection, handle the connect request, dial the target, then relay bytes in both directions with io.Copy in two goroutines:

func relay(client, target net.Conn) {
	done := make(chan struct{}, 2)
	go func() {
		io.Copy(target, client)
		done <- struct{}{}
	}()
	go func() {
		io.Copy(client, target)
		done <- struct{}{}
	}()
	<-done
}

This two-goroutine relay is one of those fundamental Go networking patterns you see everywhere. Each direction gets its own goroutine, a channel signals when either side finishes. If you’ve read about Go’s concurrency model, this is what it looks like in practice.

There’s also internal/splus/transport_livekit.go, pointing to an integration with LiveKit for WebRTC-based transport. This is clever — WebRTC traffic looks like normal video/audio streams to DPI systems, making it much harder to fingerprint and block.

Other integrations

The project hooks into several external tools:

  • internal/psiphon/ integrates with Psiphon, another Go-based circumvention tool. The psiphon_real.go / psiphon_stub.go split tells you it’s compiled conditionally.
  • internal/singbox/ integrates with sing-box, a universal proxy platform.
  • internal/edgetunnel/ adds Cloudflare edge tunnel support.

The stub pattern deserves a closer look. When you want an optional dependency in Go, you use build tags to swap between the real implementation and a stub that returns errors or does nothing. This keeps binaries small and avoids pulling in heavy dependency trees when they’re not needed. It’s a variation on the kind of flexible API design discussed in the context of functional options — don’t force users into dependencies they don’t want.

Logging with internal/logbus/

internal/logbus/logbus.go provides centralized logging. When you’re chaining multiple proxy layers and rewriting packets at the byte level, you need to know what’s happening and where things break. Go’s log/slog package (added in 1.21) is the modern choice, though plenty of projects still use log or third-party loggers.

What Go developers should take from this

This codebase is useful because it demonstrates several patterns working together in a real system, not a tutorial:

Build tags for platform-specific code — the desync and sysproxy packages are clean examples. Internal packages for encapsulation, keeping the public API surface at zero. Byte-level protocol parsing without third-party dependencies, just encoding/binary and careful slice work. The goroutine-per-direction relay that’s the backbone of any Go TCP proxy. And optional compilation with stubs to keep the Psiphon dependency from infecting builds that don’t need it.

If you’re building something in Go that needs to work across platforms or talk to networks at a level below net.Dial, spend some time reading through this repo. The patterns transfer well beyond censorship circumvention.