Wednesday, August 20, 2025
Building a Zero-Trust Proxy in Go
Sekisho (関所) - building security infrastructure with extreme minimalism. The project's defining constraint—zero external dependencies—drives architectural decisions that illuminate both the capabilities and limitations of different technology stacks for proxy development.
Architecture: Middleware Pipeline Design
The system adopts a monolithic, single-binary architecture that compiles to approximately 10MB. The core request handling follows a middleware pipeline pattern:
func setupMiddleware() http.Handler {
return RequestID(
Recovery(
RateLimiter(
SecurityHeaders(
AuthMiddleware(
PolicyMiddleware(
ProxyHandler()))))))
}
This compositional approach creates clear separation of concerns. Each middleware component handles a single responsibility, making the security-critical code easier to audit and test.
Language Selection: Evaluating the Options
Go's Advantages for Network Proxies
Concurrency Model
Go's goroutine-based concurrency provides genuine parallelism with minimal
complexity. Each incoming connection spawns a lightweight goroutine (~2KB
stack), enabling thousands of concurrent connections without the overhead of OS
threads.
func acceptConnections(listener net.Listener) {
for {
conn, _ := listener.Accept()
go handleConnection(conn) // Lightweight, efficient concurrency
}
}
Standard Library Comprehensiveness
The stdlib includes production-ready implementations for:
- TLS termination (
crypto/tls
) - OAuth2 flows (
net/http
,encoding/json
) - AES-256-GCM encryption (
crypto/aes
,crypto/cipher
) - HTTP reverse proxying (
net/http/httputil
)
Deployment Simplicity
Static binary compilation enables minimal container images:
FROM scratch
COPY sekisho /
ENTRYPOINT ["/sekisho"]
No runtime, interpreter, or package manager required—just the executable.
Systems Languages Comparison
Rust: Maximum Performance, Maximum Complexity
Rust could deliver superior performance with zero-cost abstractions:
use hyper::{Client, Request, Response, Body};
async fn proxy_handler(req: Request<Body>) -> Result<Response<Body>> {
let client = Client::new();
client.request(req).await
}
For this use case, Rust presents trade-offs:
- Pros: Better memory efficiency (20-50MB), higher throughput (50,000+ connections), compile-time safety guarantees
- Cons: Complex async runtime choices (tokio vs async-std), steeper learning curve, 10-20x longer compile times
- Verdict: Overkill for a simple proxy unless maximum performance is critical
Zig: Radical Simplicity, Radical Control
Zig offers explicit control without hidden complexity:
const std = @import("std");
fn handleRequest(server: *Server, conn: std.net.Connection) !void {
var buf: [4096]u8 = undefined;
const n = try conn.read(&buf);
try server.upstream.write(buf[0..n]);
}
Zig considerations:
- Pros: Smallest binaries (3-5MB), no runtime overhead, comptime metaprogramming, explicit allocations
- Cons: Immature ecosystem, manual memory management, limited OAuth/TLS libraries, pre-1.0 stability
- Verdict: Excellent for learning systems programming, risky for production security infrastructure
Go: The Pragmatic Middle Ground
Go sits between Rust's performance and Python's simplicity. For a zero-trust proxy, Go provides:
- Sufficient performance (10,000+ concurrent connections)
- Rich standard library (OAuth, TLS, crypto included)
- Fast iteration cycles (5-10s builds vs Rust's minutes)
- Mature ecosystem and tooling
- Simpler error handling than Rust's Result<T, E>
The choice reflects the project's goals: operational simplicity over maximum performance.
Alternative Language Trade-offs
Rust
Rust offers memory safety without garbage collection:
async fn proxy_handler(req: Request<Body>) -> Result<Response<Body>> {
let client = Client::new();
let (parts, body) = req.into_parts();
let upstream_req = Request::from_parts(parts, body);
client.request(upstream_req).await
}
Advantages:
- Zero-cost abstractions and no runtime overhead
- Memory safety guarantees at compile time
- Excellent performance (often faster than Go)
- Smaller binaries (5-8MB possible)
Challenges:
- Steep learning curve (borrow checker, lifetimes)
- Longer compile times (minutes vs seconds)
- Async ecosystem fragmentation (tokio vs async-std)
- More verbose for simple tasks
Zig
Zig provides low-level control with modern ergonomics:
fn handleConnection(conn: net.Connection) !void {
var buffer: [4096]u8 = undefined;
const bytes_read = try conn.read(&buffer);
try upstream.write(buffer[0..bytes_read]);
}
Advantages:
- No hidden allocations or runtime
- Compile-time code execution
- C interoperability without FFI overhead
- Explicit error handling like Go
Challenges:
- Immature ecosystem (pre-1.0)
- Limited library availability
- Manual memory management complexity
- Smaller community and documentation
Python
While Python offers cleaner syntax for simple cases:
@app.route('/<path:path>')
@oauth_required
def proxy(path):
return requests.get(f"{upstream}/{path}")
Limitations include:
- GIL prevents true parallelism for CPU-bound operations
- Virtual environment complexity in production
- 50-100MB interpreter overhead
- 10-100x slower startup times affecting container orchestration
Node.js
JavaScript's event loop excels at I/O operations:
app.use("/*", (req, res) => {
fetch(`${upstream}${req.path}`).then((r) => r.body.pipe(res));
});
Challenges arise from:
- Callback/promise complexity for authentication flows
- Dependency proliferation (average project: 1000+ packages)
- Runtime requirement in production
- Single-threaded execution despite async I/O
Java/Spring
Enterprise frameworks provide comprehensive features:
@Bean
RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/**").filters(f -> f.oauth2()).uri(upstream))
.build();
}
Overhead considerations:
- 200-500MB baseline JVM memory
- Complex build systems (Maven/Gradle)
- 5-30 second cold start times
- Framework abstraction layers
Module-by-Module Architectural Analysis
internal/auth/
)
Authentication Module (Current Approach: Provider interface with OAuth2/OIDC implementations
type Provider interface {
AuthURL(state, redirectURI string) string
TokenURL() string
UserInfoURL() string
Scopes() []string
}
Design Decision: Interface-based provider system enables easy extensibility for new OAuth providers (Google, GitHub, Microsoft) without code duplication.
Alternative Approaches:
- Generic OAuth2 Library: Could use
golang.org/x/oauth2
library for provider abstraction - SAML Support: Enterprise environments often require SAML in addition to OAuth2
- Certificate-based Auth: mTLS authentication for machine-to-machine communication
Trade-offs Made:
- Chosen: Custom provider implementations for zero dependencies
- Pro: Complete control over OAuth flows, no external library versions to track
- Con: Manual implementation of OAuth2 specification details, potential for security bugs
Go Patterns Used:
- Implicit interfaces: Providers automatically satisfy interface without explicit declaration
- Factory pattern: Provider selection based on configuration string
- Error wrapping:
fmt.Errorf("token exchange failed: %w", err)
for error context
internal/session/
)
Session Management (Current Approach: In-memory storage with AES-256-GCM encryption
type Store struct {
sessions map[string]*Session
mutex sync.RWMutex
ttl time.Duration
}
Design Decision: Prioritizes simplicity and performance over scalability and persistence.
Alternative Approaches:
- Redis/Memcached: External session store for horizontal scaling
- Database Storage: PostgreSQL/MySQL for persistence across restarts
- JWT Tokens: Stateless sessions encoded in client-side tokens
- Encrypted Cookies: Session data stored in encrypted client cookies
Trade-offs Analysis:
Approach | Pros | Cons | Memory | Scalability |
---|---|---|---|---|
In-Memory (Current) | Zero config, microsecond access | Single instance only | Low | None |
Redis | Horizontal scaling, persistence | External dependency, network latency | None | High |
JWT | Stateless, scales infinitely | Token size, revocation complexity | None | Infinite |
Database | Persistence, ACID properties | Complex setup, slower access | None | Medium |
Go Patterns Used:
- Mutex synchronization:
sync.RWMutex
for concurrent map access - Cleanup goroutines: Background session expiration using
time.Ticker
- Crypto package usage: AES-256-GCM for session encryption without external libraries
internal/proxy/
)
Proxy Engine (Current Approach: Standard library HTTP reverse proxy with custom TCP proxy
func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
proxy := &httputil.ReverseProxy{
Director: p.director,
Transport: p.transport,
}
proxy.ServeHTTP(w, r)
}
Design Decision: Leverage Go's built-in httputil.ReverseProxy
for HTTP and
implement custom TCP proxy for arbitrary protocols.
Alternative Approaches:
- Full Custom Implementation: Build HTTP proxy from scratch for complete control
- Envoy Integration: Use Envoy as data plane with Sekisho as control plane
- HAProxy Backend: Use HAProxy for load balancing with Sekisho for auth
- Service Mesh: Implement as Istio/Linkerd sidecar proxy
Architecture Comparison:
// Current: Standard library approach
proxy := &httputil.ReverseProxy{Director: director}
// Alternative: Full custom implementation
func customProxy(w http.ResponseWriter, r *http.Request) {
client := &http.Client{}
resp, err := client.Do(r)
// Manual response copying, header handling, etc.
}
Trade-offs Made:
- Chosen: Standard library + custom TCP proxy
- Pro: Mature, tested HTTP proxy implementation with connection pooling
- Con: Less control over proxy behavior, limited to stdlib features
Go Patterns Used:
- HTTP hijacking: Taking control of TCP connection for CONNECT tunnels
- io.Copy: Efficient bidirectional data streaming for TCP proxy
- Context propagation: Request cancellation through proxy chain
internal/config/
)
Configuration System (Current Approach: Custom YAML parser with environment variable substitution
func parseYAML(filename string) (*Config, error) {
// Custom line-by-line parsing
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := parseConfigLine(scanner.Text())
// Custom parsing logic
}
}
Design Decision: Implement minimal YAML parser to avoid external dependencies.
Alternative Approaches:
- gopkg.in/yaml.v3: Standard YAML library with full specification support
- TOML Configuration: Using
github.com/BurntSushi/toml
for simpler syntax - JSON Configuration: Standard library
encoding/json
support - Environment Variables: 12-factor app approach with
os.Getenv
Complexity Comparison:
Approach | Lines of Code | Features | Dependencies |
---|---|---|---|
Custom YAML | ~200 | Basic key-value, lists | 0 |
yaml.v3 | ~20 | Full YAML spec | 1 |
TOML | ~25 | Rich types, comments | 1 |
JSON | ~15 | Simple, ubiquitous | 0 |
Trade-offs Made:
- Chosen: Custom parser for zero dependencies
- Pro: No external libraries, complete control over features
- Con: Limited YAML support, potential parsing bugs, maintenance burden
internal/policy/
)
Policy Engine (Current Approach: Rule-based evaluation with caching
type Rule struct {
Name string
Path string
Methods []string
AllowUsers []string
RequireAuth bool
Action Action
}
Design Decision: Simple, declarative rule system with glob pattern matching and LRU cache.
Alternative Approaches:
- Open Policy Agent: Rego language for complex authorization logic
- Cedar Language: Amazon's policy language for fine-grained authorization
- XACML: XML-based standard for enterprise authorization
- Lua Scripting: Embedded Lua for custom policy logic
Policy Language Comparison:
# Current: YAML-based rules
rules:
- name: "admin_access"
path: "/admin/*"
allow_users: ["admin@company.com"]
action: "allow"
# Alternative: Open Policy Agent
package sekisho.authz
allow {
input.path == "/admin/*"
input.user == "admin@company.com"
}
Trade-offs Made:
- Chosen: Simple rule-based system
- Pro: Easy to understand, fast evaluation, YAML configuration
- Con: Limited expressiveness, no complex policy logic
Go Patterns Used:
- Strategy pattern: Multiple matcher implementations (glob, email, IP)
- Caching: LRU cache with TTL for policy decisions
- Hot reloading: File modification time checking for config updates
internal/middleware/
)
Middleware Pipeline (Current Approach: Functional middleware composition
type Middleware func(http.Handler) http.Handler
func Chain(middlewares ...Middleware) Middleware {
return func(h http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
}
Design Decision: Standard Go middleware pattern with functional composition.
Alternative Approaches:
- Chi/Gin Framework: Router-based middleware registration
- Negroni/Alice: Dedicated middleware chaining libraries
- Context-based: Pass data through
context.Context
instead of headers - Annotation-based: Decorator pattern using struct tags
Middleware Patterns Comparison:
// Current: Functional composition
handler = RequestID(Recovery(RateLimit(handler)))
// Alternative: Framework approach
r := chi.NewRouter()
r.Use(RequestID, Recovery, RateLimit)
r.Handle("/*", handler)
Security Middleware Analysis:
Component | Implementation | Security Benefit |
---|---|---|
Rate Limiting | Token bucket per IP | DDoS protection |
CSRF Protection | Double-submit cookie | Cross-site attack prevention |
Security Headers | Static header injection | Browser security policies |
Request ID | UUID generation | Request tracing and correlation |
internal/audit/
)
Audit Logging (Current Approach: Buffered logging with multiple formatters
type Formatter interface {
Format(entry *Entry) ([]byte, error)
}
// Multiple implementations: JSON, Text, CEF, Compact
Design Decision: Strategy pattern for log formatting with buffered, concurrent-safe logging.
Alternative Approaches:
- Structured Logging Libraries:
zerolog
,zap
,logrus
for performance - OpenTelemetry: Distributed tracing and metrics standard
- Syslog Integration: RFC 5424 syslog for enterprise log management
- Streaming Logs: Direct output to log aggregation systems
Performance Implications:
// Current: Mutex-protected buffer
type Logger struct {
buffer []Entry
mutex sync.Mutex
}
// Alternative: Channel-based logging
type Logger struct {
entries chan Entry
}
Trade-offs Made:
- Chosen: Custom logger with multiple formatters
- Pro: Zero dependencies, flexible output formats
- Con: Lower performance than optimized libraries, more complex implementation
Session Storage Architecture Deep Dive
The system uses in-memory session storage with mutex synchronization:
type Store struct {
sessions map[string]*Session
mutex sync.RWMutex
}
Benefits:
- Zero configuration overhead
- Microsecond lookup times
- No network latency
- No external dependencies
Limitations:
- Single-instance restriction
- Session loss on restart
- Linear memory growth without cleanup
- No cross-datacenter capabilities
This design explicitly optimizes for operational simplicity over horizontal scalability—appropriate for personal infrastructure but unsuitable for enterprise deployments.
Custom Implementations vs. Libraries
YAML Parsing
A line-by-line parser replaces gopkg.in/yaml.v3
:
func parseYAML(filename string) (*Config, error) {
// Custom parsing logic
// Handles simple key-value pairs and lists
// No support for anchors, aliases, or complex structures
}
Trade-off: Simplicity and control versus robustness and YAML specification compliance.
Metrics Exposition
Prometheus-compatible metrics without the client library:
func formatMetrics() string {
return fmt.Sprintf(
"# TYPE http_requests_total counter\n" +
"http_requests_total{method=\"%s\"} %d\n",
method, count)
}
Trade-off: Minimal code footprint versus automatic metric registration and advanced features.
Performance Characteristics
Benchmarking reveals language-specific advantages:
Metric | Go (Sekisho) | Rust | Zig | Python (Flask) | Node.js (Express) | Java (Spring) |
---|---|---|---|---|---|---|
Request Overhead | <10ms | <5ms | <5ms | 20-50ms | 15-30ms | 30-100ms |
Memory Baseline | 50-100MB | 20-50MB | 15-30MB | 150-300MB | 100-200MB | 300-800MB |
Concurrent Connections | 10,000+ | 50,000+ | 30,000+ | 100-500 | 5,000+ | 5,000+ |
Cold Start | <100ms | <50ms | <50ms | 1-3s | 500ms-1s | 5-30s |
Binary Size | 10MB | 5-8MB | 3-5MB | N/A | N/A | 50-200MB |
Build Time | 5-10s | 30-120s | 5-15s | N/A | N/A | 10-30s |
These numbers reflect:
- Compiled binary efficiency (Go, Rust, Zig)
- Runtime overhead differences
- Memory management strategies
- Concurrency model implications
Go-Specific Patterns
Interface-Based Extensibility
OAuth provider abstraction leverages Go's implicit interfaces:
type Provider interface {
AuthURL(state, redirect string) string
TokenURL() string
UserInfoURL() string
}
// Implementations automatically satisfy the interface
type GitHubProvider struct{}
type GoogleProvider struct{}
Context-Based Lifecycle Management
Graceful shutdown using context propagation:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx) // Coordinated shutdown across goroutines
Explicit Error Handling
Every error path requires explicit handling:
session, err := store.Create(userID)
if err != nil {
return nil, fmt.Errorf("session creation failed: %w", err)
}
if err := setCookie(w, session); err != nil {
store.Delete(session.ID) // Cleanup on partial failure
return nil, fmt.Errorf("cookie setting failed: %w", err)
}
While verbose compared to exception-based languages, this approach creates predictable failure modes essential for security infrastructure.
Architectural Lessons
Constraint-Driven Design
The zero-dependency requirement forced creative solutions that might not emerge
in typical development. Artificial constraints can lead to simpler, more
maintainable systems.
Appropriate Complexity
Sekisho's limitations (single-instance, volatile sessions) align with its use
case. Enterprise features would add complexity without value for personal
infrastructure.
Language-Task Alignment
Go proves particularly suited for:
- Network services requiring high concurrency
- System tools needing simple deployment
- Security infrastructure demanding predictable behavior
- Operations-focused applications
Standard Library Sufficiency
Modern standard libraries provide significant functionality:
- Go: Comprehensive networking, crypto, HTTP tooling
- Rust: Minimal stdlib, relies on external crates
- Zig: Growing stdlib, but limited high-level abstractions
- Python/Node: Rich ecosystems but dependency-heavy
For security infrastructure, Go's stdlib completeness enables the zero-dependency goal that would be impractical in Rust (would need tokio, hyper, rustls) or Zig (limited OAuth/TLS support).
Conclusion
Sekisho demonstrates that minimal, focused tools can effectively solve specific problems without unnecessary complexity. The project's trade-offs—favoring simplicity over scalability, minimalism over features—represent deliberate design decisions rather than limitations.
The choice of Go enables these decisions through its compilation model, comprehensive standard library, and concurrency primitives. While other languages could implement similar functionality, Go's characteristics align particularly well with the goals of simple, reliable, and performant infrastructure tools.
The complete implementation spans approximately 2,000 lines of Go code, proving that production-ready security infrastructure doesn't require massive frameworks or extensive dependencies.
Sekisho (関所) refers to the checkpoint stations that controlled movement during Japan's Edo period.