Files
proxy/docs
Sienna Meridian Satterwhite 0baab92141 docs: add project README, reference docs, license, CLA, and contributing guide
Apache-2.0 license with CLA for dual-licensing. Lefthook enforces
Signed-off-by on all commits. AGENTS.md updated with new modules.

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

layout, title, description, toc
layout title description toc
default Sunbeam Proxy Documentation Configuration reference and feature documentation for Sunbeam Proxy true

Sunbeam Proxy Documentation

Complete reference for configuring and operating Sunbeam Proxy — a TLS-terminating reverse proxy built on Pingora 0.8.

Quick Start

# Local development
SUNBEAM_CONFIG=dev.toml RUST_LOG=info cargo run

# Run tests
cargo nextest run

# Build release (linux-musl for containers)
cargo build --release --target x86_64-unknown-linux-musl

Configuration Reference

Configuration is TOML, loaded from $SUNBEAM_CONFIG or /etc/pingora/config.toml.

Listeners & TLS

[listen]
http  = "0.0.0.0:80"
https = "0.0.0.0:443"

[tls]
cert_path = "/etc/ssl/tls.crt"
key_path  = "/etc/ssl/tls.key"

Telemetry

[telemetry]
otlp_endpoint = ""         # OpenTelemetry OTLP endpoint (empty = disabled)
metrics_port  = 9090        # Prometheus scrape port (0 = disabled)

Routes

Each route maps a host prefix to a backend. host_prefix = "docs" matches requests to docs.<your-domain>.

[[routes]]
host_prefix = "docs"
backend     = "http://docs-backend.default.svc.cluster.local:8080"
websocket   = false                    # forward WebSocket upgrade headers
disable_secure_redirection = false     # true = allow plain HTTP

Path Sub-Routes

Longest-prefix match within a host. Mix static serving with API proxying.

[[routes.paths]]
prefix       = "/api"
backend      = "http://api-backend:8000"
strip_prefix = true                    # /api/users → /users
websocket    = false

Static File Serving

Serve frontends directly from the proxy. The try_files chain checks candidates in order:

  1. $static_root/$uri — exact file
  2. $static_root/$uri.html — with .html extension
  3. $static_root/$uri/index.html — directory index
  4. $static_root/$fallback — SPA fallback

If nothing matches, the request falls through to the upstream backend.

[[routes]]
host_prefix = "meet"
backend     = "http://meet-backend:8080"
static_root = "/srv/meet"
fallback    = "index.html"

Content-type detection is based on file extension:

Extensions Content-Type
html, htm text/html; charset=utf-8
css text/css; charset=utf-8
js, mjs application/javascript; charset=utf-8
json application/json; charset=utf-8
svg image/svg+xml
png, jpg, gif, webp, avif image/*
woff, woff2, ttf, otf font/*
wasm application/wasm

Cache-control headers are set per extension type:

Extensions Cache-Control
js, css, woff2, wasm public, max-age=31536000, immutable
png, jpg, svg, ico public, max-age=86400
Everything else no-cache

Path sub-routes take priority over static serving — if /api matches a path route, it goes to that backend even if a static file exists.

Path traversal (..) is rejected and falls through to the upstream.

URL Rewrites

Regex patterns compiled at startup, applied before static file lookup. First match wins.

[[routes.rewrites]]
pattern = "^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$"
target  = "/docs/[id]/index.html"

Response Body Rewriting

Find/replace in response bodies, like nginx sub_filter. Only applies to text/html, application/javascript, and text/javascript responses. Binary responses pass through untouched.

The entire response is buffered in memory before substitution (fine for HTML/JS — typically <1MB). Content-Length is removed since the body size may change.

[[routes.body_rewrites]]
find    = "old-domain.example.com"
replace = "new-domain.sunbeam.pt"

Custom Response Headers

[[routes.response_headers]]
name  = "X-Frame-Options"
value = "DENY"

Auth Subrequests

Gate path routes with an HTTP auth check before forwarding upstream. Similar to nginx auth_request.

[[routes.paths]]
prefix               = "/media"
backend              = "http://seaweedfs-filer:8333"
strip_prefix         = true
auth_request         = "http://drive-backend/api/v1.0/items/media-auth/"
auth_capture_headers = ["Authorization", "X-Amz-Date", "X-Amz-Content-Sha256"]
upstream_path_prefix = "/sunbeam-drive/"

The auth subrequest sends a GET to auth_request with the original Cookie, Authorization, and X-Original-URI headers.

Auth response Proxy behavior
2xx Capture specified headers, forward to backend
Non-2xx Return 403 to client
Network error Return 502 to client

HTTP Response Cache

Per-route in-memory cache backed by pingora-cache.

[routes.cache]
enabled                     = true
default_ttl_secs            = 60      # TTL when upstream has no Cache-Control
stale_while_revalidate_secs = 0       # serve stale while revalidating
max_file_size               = 0       # max cacheable body size (0 = unlimited)

Pipeline position: Cache runs after the security pipeline and before upstream modifications.

Request → DDoS → Scanner → Rate Limit → Cache → Upstream

Cache behavior:

  • Only caches GET and HEAD requests
  • Respects Cache-Control: no-store and Cache-Control: private
  • TTL priority: s-maxage > max-age > default_ttl_secs
  • Skips routes with body rewrites (content varies)
  • Skips requests with auth subrequest headers (per-user content)
  • Cache key: {host}{path}?{query}

SSH Passthrough

Raw TCP proxy for SSH traffic.

[ssh]
listen  = "0.0.0.0:22"
backend = "gitea-ssh.devtools.svc.cluster.local:2222"

DDoS Detection

KNN-based per-IP behavioral classification over sliding windows.

[ddos]
enabled         = true
model_path      = "ddos_model.bin"
k               = 5
threshold       = 0.6
window_secs     = 60
window_capacity = 1000
min_events      = 10

Scanner Detection

Logistic regression per-request classification with verified bot allowlist.

[scanner]
enabled            = true
model_path         = "scanner_model.bin"
threshold          = 0.5
poll_interval_secs = 30        # hot-reload check interval (0 = disabled)
bot_cache_ttl_secs = 86400     # verified bot IP cache TTL

[[scanner.allowlist]]
ua_prefix    = "Googlebot"
reason       = "Google crawler"
dns_suffixes = ["googlebot.com", "google.com"]
cidrs        = ["66.249.64.0/19"]

Rate Limiting

Leaky bucket per-identity throttling. Identity resolution: ory_kratos_session cookie > Bearer token > client IP.

[rate_limit]
enabled                = true
eviction_interval_secs = 300
stale_after_secs       = 600
bypass_cidrs           = ["10.42.0.0/16"]

[rate_limit.authenticated]
burst = 200
rate  = 50.0

[rate_limit.unauthenticated]
burst = 50
rate  = 10.0

Observability

Request IDs

Every request gets a UUID v4 request ID. It's:

  • Attached to a tracing::info_span! so all log lines within the request inherit it
  • Forwarded upstream via X-Request-Id
  • Returned to clients via X-Request-Id
  • Included in audit log lines

Prometheus Metrics

Served at GET /metrics on metrics_port (default 9090).

Metric Type Labels
sunbeam_requests_total Counter method, host, status, backend
sunbeam_request_duration_seconds Histogram
sunbeam_ddos_decisions_total Counter decision
sunbeam_scanner_decisions_total Counter decision, reason
sunbeam_rate_limit_decisions_total Counter decision
sunbeam_cache_status_total Counter status
sunbeam_active_connections Gauge

GET /health returns 200 for k8s probes.

# Prometheus scrape config
- job_name: sunbeam-proxy
  static_configs:
    - targets: ['sunbeam-proxy.ingress.svc.cluster.local:9090']

Audit Logs

Every request produces a structured JSON log line (target = "audit"):

{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "method": "GET",
  "host": "docs.sunbeam.pt",
  "path": "/api/v1/pages",
  "query": "limit=10",
  "client_ip": "203.0.113.42",
  "status": 200,
  "duration_ms": 23,
  "content_length": 0,
  "user_agent": "Mozilla/5.0 ...",
  "referer": "https://docs.sunbeam.pt/",
  "accept_language": "en-US",
  "accept": "text/html",
  "has_cookies": true,
  "cf_country": "FR",
  "backend": "http://docs-backend:8080",
  "error": null
}

Detection Pipeline Logs

Each security layer emits a target = "pipeline" log line before acting:

layer=ddos       → all HTTPS traffic (scanner training data)
layer=scanner    → traffic that passed DDoS (rate-limit training data)
layer=rate_limit → traffic that passed scanner

This guarantees training pipelines always see the full traffic picture.


CLI Commands

# Start the proxy server
sunbeam-proxy serve [--upgrade]

# Train DDoS model from audit logs
sunbeam-proxy train --input logs.jsonl --output ddos_model.bin \
    [--attack-ips ips.txt] [--normal-ips ips.txt] \
    [--heuristics heuristics.toml] [--k 5] [--threshold 0.6]

# Replay logs through detection pipeline
sunbeam-proxy replay --input logs.jsonl --model ddos_model.bin \
    [--config config.toml] [--rate-limit]

# Train scanner model
sunbeam-proxy train-scanner --input logs.jsonl --output scanner_model.bin \
    [--wordlists path/to/wordlists] [--threshold 0.5]

Architecture

Source Files

src/main.rs          — server bootstrap, watcher spawn, SSH spawn
src/lib.rs           — library crate root
src/config.rs        — TOML config deserialization
src/proxy.rs         — ProxyHttp impl: routing, filtering, caching, logging
src/acme.rs          — Ingress watcher for ACME HTTP-01 challenges
src/watcher.rs       — Secret/ConfigMap watcher for cert + config hot-reload
src/cert.rs          — K8s Secret → cert files on disk
src/telemetry.rs     — JSON logging + OTEL tracing init
src/ssh.rs           — TCP proxy for SSH passthrough
src/metrics.rs       — Prometheus metrics and scrape endpoint
src/static_files.rs  — Static file serving with try_files chain
src/cache.rs         — pingora-cache MemCache backend
src/ddos/            — KNN-based DDoS detection
src/scanner/         — Logistic regression scanner detection
src/rate_limit/      — Leaky bucket rate limiter
src/dual_stack.rs    — Dual-stack (IPv4+IPv6) TCP listener

Runtime Model

Pingora manages its own async runtime. K8s watchers (cert/config, Ingress) each run on separate OS threads with their own tokio runtimes. This isolation is deliberate — Pingora's internal runtime has specific constraints that don't mix with general-purpose async work.

Security Pipeline

Request
  │
  ├── DDoS detection (KNN per-IP)
  │     └── blocked → 429
  │
  ├── Scanner detection (logistic regression per-request)
  │     └── blocked → 403
  │
  ├── Rate limiting (leaky bucket per-identity)
  │     └── blocked → 429
  │
  ├── Cache lookup
  │     └── hit → serve cached response
  │
  └── Upstream request
        ├── Auth subrequest (if configured)
        ├── Response body rewriting (if configured)
        └── Response to client