CrossLink: a Go proxy gateway for LLM APIs worth reading
CrossLink is a Go project that sits between your application and multiple LLM providers — OpenAI, Anthropic, and others. It handles load balancing, failover, rate limiting, budget management, content auditing, caching, and MCP gateway capabilities. That’s a lot for a single gateway to own, and the way the codebase is organized makes it worth pulling apart.
How the application boots
The entry point lives at cmd/server/main.go, which is standard Go project layout. The interesting part is the boot sequence in internal/app/phases.go. CrossLink splits startup into distinct phases: infrastructure setup, configuration loading, middleware registration, and route binding. Each phase is a discrete step, coordinated through the App struct defined in internal/app/app.go.
I like this. Most Go services I’ve seen dump everything into main() or a single Run() function and call it a day. CrossLink separates concerns so that infrastructure (database connections, caches) initializes before the domain layer tries to use any of it. The file internal/app/infrastructure.go handles that first layer — the pieces everything else depends on.
There’s also internal/app/draining.go for graceful shutdown. If you’ve dealt with in-flight requests during deployment, you know this matters. Go’s http.Server.Shutdown method gives you the hook, but coordinating it with active proxy connections to upstream LLM providers adds real complexity. CrossLink keeps its draining logic in its own file rather than tangling it into the main server loop. If you want the foundational patterns behind this, I wrote about context and cancellation which covers the basics.
Configuration and provider seeding
The config system lives in internal/config/config.go. CrossLink needs to know about multiple LLM providers — endpoints, API key formats, rate limits, model mappings. The file internal/config/provider_seed.go is where default provider configurations get populated. This is the seed data that tells CrossLink how to talk to each upstream API.
Separating config structure from seed data keeps things clean. The config struct defines the shape. The seed file fills in defaults. When you add a new LLM provider, you add a seed entry rather than touching the core config parsing logic. Simple, but it saves you from the “one more if-statement” death spiral.
Crypto: standard and GM flavors
One unusual aspect of CrossLink is its cryptography layer. The internal/crypto/ directory contains both standard crypto (internal/crypto/standard.go) and Chinese national standard GM cryptography (internal/crypto/gm.go, internal/crypto/gm_jwt.go). A factory pattern in internal/crypto/factory.go selects the right implementation based on configuration.
This is Go interfaces doing exactly what they’re good at. The internal/crypto/provider.go file defines the interface that both standard and gm implementations satisfy. The factory reads a config flag and returns the correct concrete type. Your application code never cares which crypto backend is active — it calls methods on the interface and moves on.
If you want to understand how Go interfaces enable this kind of pluggability, the functional options pattern post covers similar design thinking around flexible configuration in Go APIs.
The admin API and SSRF protection
The internal/admin/ package is substantial. It has handlers (internal/admin/handlers.go), authentication (internal/admin/auth.go), provider management (internal/admin/providers.go), usage tracking (internal/admin/usage.go), and key management (internal/admin/keys.go). This is the control plane — how operators configure CrossLink at runtime.
One file that caught my eye: internal/admin/ssrf.go. Server-Side Request Forgery protection in a proxy gateway isn’t optional. CrossLink accepts URLs from users (provider endpoints, callback URLs), and without SSRF checks, an attacker could make CrossLink send requests to internal services. The admin helper blocks localhost, private IPs, link-local addresses, multicast, and hostnames that resolve to restricted ranges. The guardrail package has a second SSRF-safe dialer that also validates the socket destination to reduce DNS rebinding risk.
In Go, SSRF protection typically involves parsing the URL with net/url, resolving the host with net.LookupHost, and checking the resulting IPs against RFC 1918 private ranges and loopback addresses. Here’s the general pattern:
package ssrf
import (
"fmt"
"net"
"net/url"
)
func IsPrivateIP(ip net.IP) bool {
privateRanges := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
}
for _, cidr := range privateRanges {
_, network, _ := net.ParseCIDR(cidr)
if network.Contains(ip) {
return true
}
}
return false
}
func ValidateURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
ips, err := net.LookupHost(parsed.Hostname())
if err != nil {
return fmt.Errorf("DNS lookup failed: %w", err)
}
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
if IsPrivateIP(ip) {
return fmt.Errorf("URL resolves to private IP: %s", ipStr)
}
}
return nil
}
Having a dedicated file for this says something about the developers. They thought about security before someone filed a bug report.
Debug middleware
The internal/debug/ package provides request/response capture for troubleshooting. Three files: internal/debug/middleware.go, internal/debug/store.go, and internal/debug/writer.go.
The writer.go file wraps Gin’s ResponseWriter to capture response bodies while still delegating writes to the real client connection. This is a well-worn Go pattern: create a writer type, forward the real write, and copy data to a bounded buffer on the side:
type captureWriter struct {
http.ResponseWriter
statusCode int
body []byte
}
func (cw *captureWriter) WriteHeader(code int) {
cw.statusCode = code
cw.ResponseWriter.WriteHeader(code)
}
func (cw *captureWriter) Write(b []byte) (int, error) {
cw.body = append(cw.body, b...)
return cw.ResponseWriter.Write(b)
}
The middleware in internal/debug/middleware.go wraps handlers with this writer, and internal/debug/store.go persists the captured data. Middleware, writer, storage: three files, three jobs. It is a useful layout for your own projects.
Anthropic-specific domain logic
The file internal/domain/anthropic.go handles Anthropic-specific request/response transformations. Each LLM provider has its own API format. Anthropic uses a different message structure than OpenAI’s chat completions API. CrossLink needs to normalize these differences or handle them explicitly.
Having provider-specific domain files means adding a new provider is mostly additive — you create a new file in internal/domain/ with the translation logic rather than adding branches to a giant switch statement. This follows the Open-Closed Principle, and more importantly, it means your pull requests don’t touch every file in the repo.
Why this codebase is worth your time
A few things stand out:
The phased startup in internal/app/phases.go and internal/app/infrastructure.go is how you avoid the “everything initializes in random order” problem that plagues larger Go services. The interface-based crypto abstraction in internal/crypto/ is a clean example of swapping implementations without touching callers. The response capture middleware in internal/debug/ is a pattern you’ll need eventually — might as well see a tidy version of it. And the SSRF validation in internal/admin/ssrf.go is the kind of thing that separates a toy project from something you’d trust in production.
If you’re building any kind of API gateway or reverse proxy in Go, read the source. The codebase isn’t huge, but it covers the concerns that tutorial projects pretend don’t exist — security, observability, multi-provider coordination, shutdown that doesn’t drop requests on the floor.