Home Docs Security

TLS & Security

Automatic certificates, circuit breakers, and observability

1 TLS Modes

Soli Proxy supports two TLS modes. Switch between them with a single configuration line.

Auto Mode

Default

Generates a self-signed certificate for localhost on startup. Perfect for local development -- HTTPS works immediately with no external dependencies.

config.toml
# Self-signed cert for localhost
[tls]
mode = "auto"

Let's Encrypt Mode

Production

Automatic ACME HTTP-01 challenge validation for all configured domains. Certificates are issued, stored, and renewed without any manual intervention.

config.toml
[tls]
mode = "letsencrypt"
cache_dir = "./certs"

[letsencrypt]
staging = false
email = "admin@example.com"
terms_agreed = true

2 Certificate Auto-Provisioning

The entire certificate lifecycle is fully automated -- from domain discovery to renewal.

1

Domain Discovery

Domains are collected from two sources: hostnames in proxy.conf routing rules and domain fields in sites/*/app.infos files. All unique domains are queued for certificate provisioning.

2

ACME Account

An ACME account is created on first run and persisted to certs/account_credentials.json. On subsequent starts, the existing account is restored automatically.

3

HTTP-01 Challenge Validation

The proxy intercepts requests to /.well-known/acme-challenge/ and serves the ACME token response. This happens transparently before any backend routing.

4

Certificate Issuance & SNI Routing

Once validated, the certificate and private key are stored per-domain. The TLS acceptor uses SNI (Server Name Indication) to serve the correct certificate for each incoming connection.

5

Auto-Renewal

A background task checks every 12 hours whether any certificate is within 30 days of expiry. Certificates nearing expiry are renewed automatically with zero downtime.

certs/ directory layout
certs/
  account_credentials.json           # ACME account keypair
  self-signed.cert.pem               # Reserved fallback (auto-generated)
  self-signed.key.pem
  example.com.cert.pem               # Per-domain cert (exact-match SNI)
  example.com.key.pem
  api.example.com.cert.pem
  api.example.com.key.pem
  _wildcard.example.com.cert.pem     # Wildcard cert (covers *.example.com,
  _wildcard.example.com.key.pem      # one label deep per RFC 6125)

SNI resolution order: exact-match <sni>.cert.pem → wildcard _wildcard.<parent>.cert.pem → self-signed fallback. Each cert is validated against its expected SAN at load time; mismatches log a warning and are skipped.

3 Circuit Breaker

Per-backend circuit breakers track health and automatically stop routing traffic to failing backends.

Closed

Healthy

Traffic flows normally. Failures are counted. When consecutive failures reach failure_threshold, the breaker opens.

Open

Unhealthy

All requests to this backend are rejected immediately (503). After recovery_timeout_secs, the breaker moves to half-open.

Half-Open

Probing

A single probe request is allowed through. If success_threshold consecutive successes occur, the breaker closes. Any failure re-opens it.

State Transitions

Closed
failures >= threshold
Open
timeout elapsed
Half-Open
successes >= threshold
Closed
config.toml
# Circuit Breaker Configuration
# Tracks backend health and stops sending traffic to failing backends.
# When all targets for a route are open, returns 503 Service Unavailable.
[circuit_breaker]
failure_threshold = 5               # consecutive failures before opening
recovery_timeout_secs = 30          # seconds before probing an open backend
success_threshold = 2               # successful probes to close breaker
failure_status_codes = [502, 503, 504]  # HTTP codes that count as failures

Multi-Target Load Balancing

When a route has multiple targets, Soli Proxy supports three load balancing strategies. Use @lb:round-robin, @lb:weighted, or @lb:failover to specify the strategy. Default is round-robin.

proxy.conf
# Round-robin load balancing (default)
api.example.com -> http://backend-1:3000, http://backend-2:3000

# Weighted load balancing
example.com/api/* -> http://api-primary:8080, http://api-secondary:8080  # @lb:weighted

# Failover: use second target only if first fails
example.com/backup/* -> http://primary:8080, http://backup:8080  # @lb:failover

Admin API Endpoints

GET
/api/v1/circuit-breaker

Returns the current state of every tracked backend: state (closed/open/half_open), consecutive failures, and consecutive successes.

POST
/api/v1/circuit-breaker/reset

Resets all circuit breakers to the closed (healthy) state. Useful after deploying a fix to a failing backend.

GET /api/v1/circuit-breaker response
{
  "http://backend-1:3000": {
    "state": "closed",
    "consecutive_failures": 0,
    "consecutive_successes": 12
  },
  "http://backend-2:3000": {
    "state": "open",
    "consecutive_failures": 5,
    "consecutive_successes": 0
  }
}

4 Prometheus Metrics

Soli Proxy exposes a built-in /metrics endpoint in Prometheus text format, ready for scraping by Prometheus, Grafana Agent, or any compatible collector.

config.toml
[metrics]
enabled = true
endpoint = "/metrics"

Sample Output

curl http://localhost/metrics
# HELP proxy_requests_total Total number of HTTP requests
# TYPE proxy_requests_total counter
proxy_requests_total 48291

# HELP proxy_requests_in_flight Number of requests currently being processed
# TYPE proxy_requests_in_flight gauge
proxy_requests_in_flight 12

# HELP proxy_bytes_received Total bytes received from clients
# TYPE proxy_bytes_received counter
proxy_bytes_received 2847103

# HELP proxy_bytes_sent Total bytes sent to clients
# TYPE proxy_bytes_sent counter
proxy_bytes_sent 91740258

# HELP proxy_response_time_seconds Average response time in seconds
# TYPE proxy_response_time_seconds gauge
proxy_response_time_seconds 0.000284

# HELP proxy_tls_connections_total Total number of TLS connections
# TYPE proxy_tls_connections_total counter
proxy_tls_connections_total 15820

# HELP proxy_errors_total Total number of proxy errors
# TYPE proxy_errors_total counter
proxy_errors_total 3

# HELP proxy_response_status_codes_total HTTP response status codes
# TYPE proxy_response_status_codes_total counter
proxy_response_status_codes_total{code="200"} 45102
proxy_response_status_codes_total{code="301"} 2040
proxy_response_status_codes_total{code="404"} 1146
proxy_response_status_codes_total{code="503"} 3

Metrics Reference

Metric Type Description
proxy_requests_total counter Total number of HTTP requests processed
proxy_requests_in_flight gauge Number of requests currently being processed
proxy_bytes_received counter Total bytes received from clients
proxy_bytes_sent counter Total bytes sent to clients
proxy_response_time_seconds gauge Average response time in seconds
proxy_tls_connections_total counter Total number of TLS connections established
proxy_errors_total counter Total number of proxy errors encountered
proxy_response_status_codes_total counter HTTP response status codes with code label

# Worker Privilege Dropping

By default, app workers inherit the same privileges as the Soli Proxy process. If you run the proxy as root (to bind ports 80/443), all workers also run as root. This is a security risk — a compromised app gives attackers full system access.

Configuring Per-App Users

Use the user and group fields in app.infos to run each app as a different user:

app.infos
# Run app workers as a specific user
name = "myapp"
domain = "myapp.example.com"
start_script = "npm start"
user = "nobody"
group = "nogroup"

Never run app workers as root in production

Always configure a dedicated non-root user for each app. This limits the blast radius if an app is compromised.

Removing the Server Header

By default, Soli Proxy does not add a Server header to responses. This hides your proxy software version from clients, preventing attackers from targeting known vulnerabilities in specific versions. Use Lua scripting to remove additional headers:

on_response.lua
-- Remove sensitive headers from responses
function on_response(mods)
    mods.remove_headers = {"Server", "X-Powered-By", "X-AspNet-Version"}
end

# Security Hardening

Soli Proxy includes several built-in protections against common attacks. These are configurable via config.toml.

Slowloris Protection

The keep_alive_timeout setting limits how long a client can take to send headers. This prevents slowloris attacks where clients hold connections open indefinitely by sending headers one byte at a time.

config.toml
[limits]
keep_alive_timeout = 30   # seconds to complete header transmission

Request Size Limits

The max_request_size setting rejects requests whose Content-Length header exceeds the configured limit, protecting against large-payload DoS and memory exhaustion. When this setting is configured, requests using Transfer-Encoding: chunked are also rejected outright (since chunked bodies cannot be size-checked from headers alone); set a non-chunked client or omit max_request_size if your workload genuinely requires chunked uploads.

config.toml
[limits]
max_request_size = "10MB"   # reject requests with Content-Length exceeding this

Backend Request Timeout

The request_timeout setting limits how long a backend request can take before being terminated. This prevents hanging connections and ensures resources are freed for waiting clients.

config.toml
[limits]
request_timeout = 60   # seconds before backend request is terminated

Rate Limiting (via Lua)

Soli Proxy does not ship a built-in rate limiter, but the Lua scripting API exposes everything needed to implement one: process-shared atomic counters (shared.incr), a millisecond clock (time.now_ms), header access, and early denial via req:deny. The fixed-window per-IP limiter below allows 100 requests per minute per client.

rate_limit.lua
-- Fixed-window per-IP rate limiter: 100 requests per minute
function on_request(req)
    local ip = req:header("x-forwarded-for") or "unknown"
    local window = math.floor(time.now_ms() / 1000 / 60)
    local key = "rl:" .. ip .. ":" .. window
    local count = shared.incr(key)
    if count > 100 then
        return req:deny(429, "rate limit exceeded")
    end
end

Limitations to be aware of

  • shared state is in-memory and per-process — counters are not synchronized across multiple proxy instances.
  • Shared keys have no automatic TTL. Bucketing by time window (as above) prevents unbounded growth in practice, but stale buckets are not reaped.
  • X-Forwarded-For is only trustworthy behind a proxy you control; otherwise clients can spoof the header to evade limits.

Always use TLS in production

Without TLS, all traffic including authentication tokens and session cookies are transmitted in plaintext. Configure Let's Encrypt for automatic certificates.