Files
proxy/AGENTS.md
Sienna Meridian Satterwhite e16299068f feat: add native dual-stack IPv4/IPv6 support
This commit implements comprehensive dual-stack support for the proxy,
allowing it to listen on both IPv4 and IPv6 addresses simultaneously.

Key changes:
- Added new dual_stack.rs module with DualStackTcpListener implementation
- Updated SSH module to use dual-stack listener
- Updated configuration documentation to reflect IPv6 support
- Added comprehensive tests for dual-stack functionality

The implementation is inspired by tokio_dual_stack but implemented
natively without external dependencies. It provides fair connection
distribution between IPv4 and IPv6 clients while maintaining full
backward compatibility with existing IPv4-only configurations.

Signed-off-by: Sienna Meridian Satterwhite <sienna@sunbeam.pt>
2026-03-10 23:38:19 +00:00

7.1 KiB

AGENTS.md — sunbeam-proxy

Critical Rules

Read before you write. Read every file you intend to modify. Do not guess at code structure, function signatures, or types. This is a ~500-line Rust codebase — read the actual source.

Minimal changes only. Do exactly what is asked. Do not:

  • Add features, abstractions, or "improvements" beyond the request
  • Refactor surrounding code, rename variables, or restructure modules
  • Add comments, docstrings, or type annotations to code you didn't change
  • Add error handling or validation for scenarios that cannot happen
  • Create helper functions or utilities for one-off operations
  • Add backwards-compatibility shims, re-exports, or // removed comments
  • Introduce new dependencies without being explicitly asked

Do not create new files unless the task absolutely requires it. Prefer editing existing files.

Do not over-engineer. Three similar lines of code is better than a premature abstraction. If a fix is one line, submit one line.

Ask before acting on anything destructive or irreversible: deleting files, force-pushing, modifying CI, running commands with side effects.

Project Overview

sunbeam-proxy is a TLS-terminating reverse proxy built on Pingora 0.8 (Cloudflare's proxy framework) with rustls. It runs in Kubernetes and handles:

  • Host-prefix routing: routes foo.example.com by matching prefix foo against the config
  • Path sub-routes: longest-prefix match within a host, with optional prefix stripping
  • ACME HTTP-01 challenges: routes /.well-known/acme-challenge/* to cert-manager solver pods
  • TLS cert hot-reload: watches K8s Secrets, writes cert files, triggers zero-downtime upgrade
  • Config hot-reload: watches K8s ConfigMaps, triggers graceful upgrade on change
  • SSH TCP passthrough: raw TCP proxy for SSH traffic (port 22 to Gitea)
  • HTTP-to-HTTPS redirect: with per-route opt-out via disable_secure_redirection

Source Files

src/main.rs      — binary entry point: server bootstrap, watcher spawn, SSH spawn
src/lib.rs       — library crate root: re-exports acme, config, proxy, ssh
src/config.rs    — TOML config deserialization (Config, RouteConfig, PathRoute)
src/proxy.rs     — ProxyHttp impl: request_filter, upstream_peer, upstream_request_filter, logging
src/acme.rs      — Ingress watcher: maintains AcmeRoutes (path → solver backend)
src/watcher.rs   — Secret/ConfigMap watcher: cert write + graceful upgrade trigger
src/cert.rs      — fetch_and_write / write_from_secret: K8s Secret → cert files on disk
src/telemetry.rs — JSON logging + optional OTEL tracing init
src/ssh.rs       — TCP proxy: tokio TcpListener + copy_bidirectional
tests/e2e.rs     — end-to-end test: real SunbeamProxy over plain HTTP with echo backend

Architecture Invariants — Do Not Break These

  1. Separate OS threads for K8s watchers. The cert/config watcher and Ingress watcher run on their own std::thread with their own tokio::runtime. Pingora has its own internal runtime. Never share a tokio runtime between Pingora and the watchers.

  2. Fresh K8s Client per runtime. Each runtime creates its own kube::Client. Tower workers are tied to the runtime that created them. Do not pass a Client across runtime boundaries.

  3. std::sync::RwLock for AcmeRoutes, not tokio::sync. The RwLock guard is held across code paths in Pingora's async proxy calls. A tokio RwLock guard is Send but the waker cross-runtime issues make std::sync::RwLock the correct choice here.

  4. insert_header() not headers.insert() on Pingora RequestHeader. Pingora maintains a CaseMap alongside base.headers. Using headers.insert() directly causes the header to be silently dropped during header_to_h1_wire serialization. Always use insert_header() or remove_header().

  5. rustls crypto provider must be installed first. rustls::crypto::aws_lc_rs::default_provider().install_default() must run before any TLS initialization. This is in main() line 19.

  6. Cert watcher writes certs from the Apply event payload. It does NOT re-fetch via the API. The watcher::Event::Apply(secret) carries the full Secret object; cert::write_from_secret writes directly from it, then triggers the upgrade.

  7. Graceful upgrade = spawn new process with --upgrade + SIGQUIT self. Pingora transfers listening socket FDs via Unix socket. The new process inherits them. Do not change this flow.

Build & Test Commands

# Build (debug)
cargo build

# Build (release, with cross-compile for linux-musl if needed)
cargo build --release --target x86_64-unknown-linux-musl

# Run all tests (unit + e2e)
cargo test

# Run unit tests only (no e2e, which needs port 18889)
cargo test --lib

# Check without building
cargo check

# Lint
cargo clippy -- -D warnings

# Format check
cargo fmt -- --check

Always run cargo check after making changes. If it doesn't compile, fix it before proceeding. Do not submit code that doesn't compile.

Run cargo test after any behavioral change. The e2e test in tests/e2e.rs spins up a real proxy and echo backend — it catches real regressions.

Run cargo clippy -- -D warnings before finishing. Fix all warnings. Do not add #[allow(...)] attributes to suppress warnings unless there is a genuine false positive.

Rust & Pingora Conventions in This Codebase

  • Error handling: anyhow::Result for fallible startup code, pingora_core::Result inside ProxyHttp trait methods. Map between them with pingora_core::Error::because().
  • Logging: tracing::info!, tracing::warn!, tracing::error! with structured fields. The logging() method on the proxy uses target = "audit" for the request log line.
  • No unwrap() in production paths. Use expect() only where failure is genuinely impossible and the message explains why. unwrap_or_else(|e| e.into_inner()) is acceptable for poisoned locks.
  • Async: async_trait for the ProxyHttp impl. Pingora controls the runtime; do not spawn tasks on it.
  • Config: All configuration is in TOML, deserialized with serde. Add new fields to the existing structs in config.rs with #[serde(default)] for backwards compatibility.
  • Tests: Unit tests go in #[cfg(test)] mod tests inside the relevant source file. Integration tests go in tests/e2e.rs.

Common Mistakes to Avoid

  • Do not add tokio::main to main.rs. Pingora manages its own runtime via server.run_forever(). The temporary runtimes for cert fetch and SSH are intentionally separate.
  • Do not use headers.insert() on upstream requests. Use insert_header(). See invariant #4.
  • Do not hold a RwLock guard across .await points in proxy.rs. Clone the data out, drop the guard, then proceed.
  • Do not add new crate dependencies for things the existing deps already cover. Check Cargo.toml first.
  • Do not modify the graceful upgrade flow (watcher.rs trigger_upgrade) unless explicitly asked. It is deliberately simple and correct.
  • Do not add #[tokio::test] to e2e tests. They use std::thread + raw TcpStream intentionally to avoid runtime conflicts with Pingora.