Beta / work in progress. This is not production ready. APIs, config keys, and behavior may change without notice. Use it, break it, open issues.
A caching reverse proxy that only speaks modern protocols. No TLS 1.2, no HTTP/1.1 on the wire, no heuristic cache guessing. If your stack is already modern, Pyloros fits right in without dragging along thirty years of compatibility baggage.
The name comes from the ancient Greek πυλωρός, the person who stands at the gate and decides what gets through.
Pyloros sits in front of your backends and handles:
- TLS termination (1.3 only, not configurable on purpose)
- Post-quantum key exchange (X25519MLKEM768 hybrid KEM, FIPS 203) on all TLS 1.3 connections by default
- HTTP/2 on both the client and upstream side
- HTTP/3 (QUIC) listener and upstream transport, opt-in
- RFC 9111 compliant caching with strict mode on by default
- CONNECT-UDP proxying via MASQUE (RFC 9298), when HTTP/3 datagrams are enabled
- Automatic certificate management via ACME or manual cert files with hot reload
- Per upstream circuit breaking and active health checks
- Prometheus metrics and structured JSON logging out of the box
Pyloros does not support TLS 1.2 or earlier. It does not speak HTTP/1.1 to backends. It will not perform heuristic cache freshness calculations unless you explicitly turn strict mode off. If any of those things are requirements, it is the wrong tool.
- Go 1.25 or newer
- A certificate, either managed by ACME or provided manually
Build from source:
git clone https://github.com/monkburger/pyloros
cd pyloros
make build
The binary goes to dist/pyloros. Copy it wherever you like, it has no runtime dependencies.
Check the version:
./pyloros -version
pyloros v0.1.0 (commit abc1234, built 2026-03-14T12:00:00Z)
Pyloros reads a single TOML file. Pass the path with -config:
./pyloros -config /etc/pyloros/pyloros.toml
A full annotated example lives in pyloros.toml. The sections below cover the most important parts.
[server]
http2_addr = ":443"
read_header_timeout = "5s" # time to read the request line and headers; defends against Slowloris
read_timeout = "30s" # time to read the full request including body
write_timeout = "60s" # time to write the full response
idle_timeout = "120s" # keep-alive timeout between requests
max_header_bytes = 8192 # maximum size of request headers in bytes
max_request_body_size = "10mb" # requests with a larger body are rejected with 413
trusted_proxies = ["10.0.0.0/8", "172.16.0.0/12"]read_header_timeout limits the time a client has to send the request line and all headers. It protects against Slowloris-style attacks and is separate from read_timeout, which covers the full request including the body.
trusted_proxies is a list of CIDR ranges that are allowed to set the X-Forwarded-For header. Pyloros only reads X-Forwarded-For from requests that arrive on a trusted source address. Requests from anywhere else have their X-Forwarded-For header ignored and the real remote address is used instead. Leave it empty if Pyloros is directly internet-facing.
HTTP/3 is opt-in. Nothing happens unless you set http3_addr.
[server]
http3_addr = ":443" # UDP port; separate socket from http2_addr (TCP)
enable_datagrams = false # set true to enable RFC 9297 datagrams + CONNECT-UDPWhen http3_addr is set, Pyloros starts a QUIC listener on that UDP port and injects Alt-Svc headers into HTTP/2 responses so clients can upgrade automatically.
Setting enable_datagrams = true turns on RFC 9297 HTTP Datagram support and activates the MASQUE CONNECT-UDP proxy (RFC 9298). Any HTTP/3 extended-CONNECT request with protocol connect-udp is handled as a UDP relay. There is no authentication on CONNECT-UDP. If Pyloros is internet-facing and datagrams are enabled, any client can use it as a UDP relay. Restrict access at the network level or leave enable_datagrams = false.
Upstreams can also use HTTP/3:
[[upstream]]
name = "api"
address = "https://api.internal:443"
http3 = false # set true to connect to this upstream over QUIC instead of TCP
enable_datagrams = false # RFC 9297 datagrams on the upstream H3 connection (requires http3 = true)
masque_enabled = false # accept CONNECT-UDP tunnels through this upstream (requires http3 = true)
# connect_ip_enabled = false # CONNECT-IP tunnel support (not yet implemented)By default Pyloros uses ACME to obtain and renew certificates automatically. Point it at your ACME directory, give it your domains and an email address, and it handles the rest.
[tls.acme]
enabled = true
domains = ["example.com"]
email = "ops@example.com"
directory_url = "https://acme-v02.api.letsencrypt.org/directory"
cache_dir = "/var/lib/pyloros/acme"To use your own certificates instead, set acme.enabled = false and provide paths to the cert and key files. Pyloros watches those files and reloads them automatically when they change, so you can rotate certificates without a restart.
[tls]
cert_file = "/etc/pyloros/cert.pem"
key_file = "/etc/pyloros/key.pem"
[tls.acme]
enabled = falseTLS 1.3 is always the minimum. There is no configuration key for it.
Post-quantum key exchange is on by default on all TLS 1.3 connections, both client-facing and upstream. Pyloros uses the X25519MLKEM768 hybrid KEM (ML-KEM-768 from FIPS 203/CRYSTALS-Kyber, combined with X25519). Peers that do not support ML-KEM fall back to X25519 automatically within the same handshake, no configuration needed and no compatibility risk. To opt out: set GODEBUG=tlsmlkem=0 in the service environment.
When you serve multiple public hostnames from a single Pyloros instance, each hostname can have its own TLS certificate. This is only available in manual (non-ACME) mode. ACME mode uses autocert which handles all listed domains automatically, just include every domain you need in tls.acme.domains.
Pyloros selects the certificate to present using the TLS ClientHello SNI (RFC 6066 §3). Selection order:
- Exact match: case-insensitive (RFC 4343), so
api.example.commatchesAPI.Example.COM. - One-label wildcard:
*.example.commatchessub.example.combut not the apexexample.comand notx.sub.example.com. - Fallback: the global
cert_file/key_fileis always required and is used when no vhost matches (including clients that connect without SNI).
All cert files (the default and all vhost files) are watched for changes on disk. When any of them change, the full set is reloaded atomically with no connection interruptions, so you can rotate certs without restarting.
[tls]
cert_file = "/etc/pyloros/default.pem" # fallback for no-SNI or unknown-host
key_file = "/etc/pyloros/default.key"
[tls.acme]
enabled = false
# Exact match — takes precedence over any wildcard.
[[tls.vhost]]
host = "api.example.com"
cert_file = "/etc/pyloros/api.pem"
key_file = "/etc/pyloros/api.key"
# Wildcard — covers all single-label subdomains except those with an exact entry.
[[tls.vhost]]
host = "*.example.com"
cert_file = "/etc/pyloros/wildcard.pem"
key_file = "/etc/pyloros/wildcard.key"
# Separate domain entirely.
[[tls.vhost]]
host = "admin.otherdomain.com"
cert_file = "/etc/pyloros/admin.pem"
key_file = "/etc/pyloros/admin.key"Note: "one-label wildcard" matches exactly one subdomain level.
*.example.comcoversapi.example.combut notv1.api.example.com. That is standard RFC 6125 wildcard behavior. For deeper nesting, add a separate[[tls.vhost]]entry, or use ACME with all the domains listed.
Wire a per-vhost TLS cert together with a route's host field for the full virtual hosting setup:
# TLS presents the right cert → routing dispatches to the right upstream.
[[route]]
host = "api.example.com"
path = "/*"
upstream = "api"
[[route]]
host = "*.example.com"
path = "/*"
upstream = "web"You can host any number of independent public domains from a single Pyloros instance. Each domain gets its own TLS certificate, its own upstream pool, and its own route block. TOML array-of-tables ([[...]]) means you just keep adding sections, no limit.
The following config serves three unrelated public domains, each with a dedicated certificate and backend cluster:
[tls]
cert_file = "/etc/pyloros/fallback.pem" # used when no [[tls.vhost]] matches
key_file = "/etc/pyloros/fallback.key"
[tls.acme]
enabled = false
# ── domain 1: api.example.com (exact) ─────────────────────────────────────
[[tls.vhost]]
host = "api.example.com"
cert_file = "/etc/pyloros/api.example.com.pem"
key_file = "/etc/pyloros/api.example.com.key"
# ── domain 2: all of example.org (wildcard + apex covered separately) ──────
[[tls.vhost]]
host = "*.example.org"
cert_file = "/etc/pyloros/wildcard.example.org.pem"
key_file = "/etc/pyloros/wildcard.example.org.key"
[[tls.vhost]]
host = "example.org" # apex — wildcard does not cover it
cert_file = "/etc/pyloros/example.org.pem"
key_file = "/etc/pyloros/example.org.key"
# ── domain 3: shop.example.net (exact) ────────────────────────────────────
[[tls.vhost]]
host = "shop.example.net"
cert_file = "/etc/pyloros/shop.example.net.pem"
key_file = "/etc/pyloros/shop.example.net.key"
# ── Upstreams ───────────────────────────────────────────────────────────────
[[upstream]]
name = "api"
address = "https://api-backend.internal:8443"
[[upstream]]
name = "web"
address = "https://web-backend.internal:8443"
[[upstream]]
name = "shop"
address = "https://shop-backend.internal:8443"
# ── Routes — host checked first, then path ─────────────────────────────────
[[route]]
host = "api.example.com"
path = "/*"
upstream = "api"
cache = false
[[route]]
host = "example.org"
path = "/*"
upstream = "web"
cache = true
[[route]]
host = "*.example.org"
path = "/*"
upstream = "web"
cache = true
[[route]]
host = "shop.example.net"
path = "/*"
upstream = "shop"
cache = falseACME mode with multiple domains: if you prefer automatic certificates, list every public domain in tls.acme.domains. autocert provisions and renews each certificate independently:
[tls.acme]
enabled = true
email = "ops@example.com"
domains = ["api.example.com", "example.org", "*.example.org", "shop.example.net"]ACME wildcard certificates require a DNS-01 challenge. The built-in
autocertmanager only supports the HTTP-01 challenge, so wildcard certificates must be issued externally and supplied via[[tls.vhost]]in manual mode.
Each upstream must be reachable over HTTPS. Pyloros connects over HTTP/2. If a backend only speaks HTTP/1.1 Pyloros will refuse to connect.
[[upstream]]
name = "api"
address = "https://api.internal:443"Set addresses (plural) to distribute requests across a pool. Pyloros uses Least Outstanding Requests (LOR) selection: each new request goes to the backend currently carrying the fewest in-flight HTTP/2 streams. This works well for HTTP/2 since the transport keeps one persistent multiplexed connection per backend. Round-robin ignores how loaded a backend already is, which is a bad fit. Backends with an open circuit breaker are skipped automatically. Each backend has its own circuit breaker and inflight counter.
[[upstream]]
name = "api"
addresses = [
"https://api-1.internal:443",
"https://api-2.internal:443",
"https://api-3.internal:443",
]
max_conns = 100
dial_timeout = "10s"
response_timeout = "30s" # total time to wait for the upstream to respond (0 = no limit)
[upstream.circuit_breaker]
threshold = 5 # consecutive failures before the circuit opens
reset_timeout = "10s" # how long the circuit stays open before trying again
[upstream.health_check]
enabled = true
path = "/healthz"
interval = "10s"
timeout = "3s"By default, the TLS SNI sent in the upstream handshake is derived from the hostname in the backend address URL. This is correct when the address hostname matches the backend's certificate.
If the backend is reachable by an internal hostname that differs from its certificate (e.g. the cert is issued for api.example.com but your internal DNS name is api-prod.internal), set sni_name to override the SNI:
[[upstream]]
name = "api"
address = "https://api-prod.internal:443"
sni_name = "api.example.com" # SNI sent in TLS handshake (must match the cert)sni_name must be a DNS hostname. IP address literals are not valid SNI values (RFC 6066 §3) and are rejected at startup.
Routes are matched in the order they appear in the config. Both host and path must match; host is checked first (RFC 9110 §7.2). The first route where both match wins.
[[route]]
path = "/api/*"
upstream = "api"
cache = true
strip_prefix = false
[[route]]
path = "/static/*"
upstream = "static"
cache = true
strip_prefix = true
[[route]]
path = "/*"
upstream = "api"
cache = falseAdd a host field to restrict a route to a specific incoming hostname. Supported forms:
| Pattern | Matches |
|---|---|
| (empty) | Any host — route applies regardless of Host: header |
* |
Same as empty — any host |
api.example.com |
Exact match (case-insensitive, RFC 4343) |
*.example.com |
One label deep — sub.example.com yes; example.com or x.sub.example.com no |
Port numbers in the incoming Host: header are stripped before matching (RFC 9110 §7.2).
# Traffic for api.example.com → api cluster
[[route]]
host = "api.example.com"
path = "/*"
upstream = "api"
cache = false
# Traffic for www.example.com → web cluster
[[route]]
host = "www.example.com"
path = "/*"
upstream = "web"
cache = true
# Catch-all for everything else (no host filter)
[[route]]
path = "/*"
upstream = "api"
cache = falseBy default, Pyloros rewrites the Host: header on the outbound request to the backend's own hostname (e.g. api-1.internal). This lets nginx/Apache on the backend dispatch by its internal server_name.
When preserve_host = true, the original client Host: header (e.g. api.example.com) is forwarded unchanged. Use this when the backend has a virtual host configured for the public domain name.
The TLS SNI used for the upstream connection is always derived from the backend address (or sni_name). It is not affected by preserve_host.
[[route]]
host = "api.example.com"
path = "/*"
upstream = "api"
preserve_host = true # backend's server_name must be "api.example.com"Pyloros emits one structured log line per request at Info level. The entry includes: request ID (also set as X-Request-Id on the response), method, path, status, bytes, latency, client IP, User-Agent, and the Cache-Status header value.
Access logging is on by default. To disable it:
[access_log]
disable = truePyloros has two hard constraints on how it connects to upstream backends:
- HTTPS is required. Upstream addresses must begin with
https://. There is no plain-HTTP or h2c (cleartext HTTP/2) mode. - HTTP/2 (h2 via ALPN) is required. Pyloros uses
golang.org/x/net/http2.Transportdirectly. During the TLS handshake the backend must advertise theh2ALPN protocol. If it only offershttp/1.1, the connection is refused.
For TLS certificate trust, Pyloros uses Go's system certificate store. There is no insecure_skip_verify option. Certificates signed by a public CA are trusted automatically. For internal backends using a private CA, add the CA root to the OS trust store before starting Pyloros.
Enable SSL and HTTP/2 on the port that Pyloros will connect to. The server_name must match the hostname in the Pyloros address URL (or the sni_name override).
# nginx >= 1.25.1
server {
listen 8443 ssl;
http2 on;
# nginx < 1.25.1: use listen 8443 ssl http2;
server_name api.backend.internal;
ssl_certificate /etc/nginx/ssl/api.crt;
ssl_certificate_key /etc/nginx/ssl/api.key;
ssl_protocols TLSv1.3; # Pyloros enforces TLS 1.3 minimum
# Restore the real client IP from Pyloros's Forwarded / X-Forwarded-For header.
# Replace 127.0.0.1 with Pyloros's actual address if not co-hosted.
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location / {
proxy_pass http://127.0.0.1:8080;
}
}Pyloros upstream entry:
[[upstream]]
name = "api"
address = "https://api.backend.internal:8443"If the nginx server_name differs from the DNS name in address (e.g. you connect by IP), use sni_name to set the correct SNI value (RFC 6066 §3):
[[upstream]]
name = "api"
address = "https://127.0.0.1:8443"
sni_name = "api.backend.internal" # must match a SAN in the nginx certWhen nginx itself hosts multiple virtual hosts and dispatches by the incoming Host: header, configure Pyloros to forward the original public hostname instead of the backend's internal name:
server {
listen 8443 ssl;
http2 on;
server_name api.example.com; # public domain, not the internal address
ssl_certificate /etc/nginx/ssl/api.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/api.example.com.key;
ssl_protocols TLSv1.3;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location / {
proxy_pass http://127.0.0.1:8080;
}
}[[upstream]]
name = "api"
address = "https://127.0.0.1:8443"
sni_name = "api.example.com" # SNI from cert; not affected by preserve_host
[[route]]
host = "api.example.com"
path = "/*"
upstream = "api"
preserve_host = true # keeps Host: api.example.com → nginx vhost dispatches correctlyProtocols h2 http/1.1 is required. Without it, Apache will not advertise h2 in ALPN and Pyloros will refuse to connect.
LoadModule ssl_module modules/mod_ssl.so
LoadModule http2_module modules/mod_http2.so
<VirtualHost *:8443>
ServerName api.backend.internal
SSLEngine on
SSLCertificateFile /etc/ssl/certs/api.crt
SSLCertificateKeyFile /etc/ssl/private/api.key
SSLProtocol TLSv1.3
Protocols h2 http/1.1
H2Direct on
# Restore the real client IP from Pyloros's X-Forwarded-For header.
# Requires mod_remoteip. Replace 127.0.0.1 with Pyloros's address if remote.
RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 127.0.0.1
ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/"
</VirtualHost>Pyloros upstream entry:
[[upstream]]
name = "api"
address = "https://api.backend.internal:8443"Pyloros uses Go's system certificate store and has no insecure_skip_verify option. Internal backends need certificates that are trusted by the OS running Pyloros.
Private CA (recommended for internal networks)
Generate a CA and issue server certificates from it, then add the CA root to the system trust store:
# 1. Create the CA (one time)
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -days 3650 \
-keyout /etc/pki/internal-ca/ca.key \
-out /etc/pki/internal-ca/ca.crt \
-subj "/CN=Internal CA" -nodes
# 2. Issue a server certificate
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout backend.key -out backend.csr \
-subj "/CN=api.backend.internal" -nodes
openssl x509 -req \
-CA /etc/pki/internal-ca/ca.crt \
-CAkey /etc/pki/internal-ca/ca.key \
-CAcreateserial -days 365 \
-in backend.csr -out backend.crt \
-extfile <(printf 'subjectAltName=DNS:api.backend.internal')
# 3. Trust the CA on the machine running Pyloros (AlmaLinux / RHEL / Rocky)
sudo cp /etc/pki/internal-ca/ca.crt /etc/pki/ca-trust/source/anchors/internal-ca.crt
sudo update-ca-trust
# Ubuntu / Debian:
# sudo cp ca.crt /usr/local/share/ca-certificates/internal-ca.crt
# sudo update-ca-certificatesSame public certificate as the frontend (single-host setup)
If Pyloros and the backend run on the same machine and share the same public DNS name, configure the backend to listen on a non-standard port (8443) using the same public certificate that Pyloros presents to clients. No private CA is needed.
Pyloros implements RFC 9111 as a shared cache. Strict mode is on by default, meaning responses without an explicit Cache-Control: max-age or s-maxage are never stored. Set strict_rfc = false if you need heuristic freshness, but think about it first.
[cache]
store = "memory" # memory | disk | both
max_size = "512mb" # total cache capacity per store
max_body_size = "10mb" # individual response bodies larger than this are never cached
strict_rfc = true # false = allow heuristic freshness (not recommended)
# default_ttl = "1h" # heuristic TTL when strict_rfc = false; must be set to activate heuristic mode
disk_path = "/var/cache/pyloros" # required when store = disk or both| Mode | Description |
|---|---|
memory |
Default. Entries live in process memory; all entries are lost on restart. |
disk |
Each response is serialised to disk under disk_path. Entries survive restarts and are loaded back automatically on startup. Expired or corrupt files are removed at load time. |
both |
Memory acts as an L1 cache in front of disk. Lookups check memory first; a disk hit promotes the entry into memory so the next request is served without disk I/O. max_size applies independently to each store. |
The cache is implemented in internal/cache/ across two files.
cache.go is the main entry point and holds the shared request-handling logic:
- Storability check (RFC 9111 §3): only GET responses are stored.
Authorization-carrying requests requirepublic,s-maxage, ormust-revalidatein the response.no-storeandprivateon either side always prevent caching. - Primary cache key:
GET|<Host>|<path?sorted-query>, query parameters are sorted so?b=2&a=1and?a=1&b=2resolve to the same entry. - Secondary key (Vary): the values of all headers named in the response
Varyfield are snapshotted at store time. A lookup only hits if the current request headers match the snapshot exactly.Vary: *is always a miss. - TTL resolution:
s-maxagetakes precedence overmax-age(shared cache semantics per RFC 9111 §5.2.2). - Stale serving:
stale-while-revalidateallows serving an expired entry while triggering a background revalidation.stale-if-errorextends the serving window when the upstream returns an error. - Invalidation: unsafe methods (POST, PUT, DELETE, PATCH) invalidate the primary key before the request is forwarded, consistent with RFC 9111 §4.4.
- Eviction: a background goroutine runs every 60 seconds, first removing entries past their
max-age + stale-if-errorwindow, then LRU-evicting by insertion time if total size exceedsmax_size.
disk.go implements the disk store:
- Each entry is gob-encoded and written to
<disk_path>/<2 hex chars>/<remaining 62 hex chars>, where the path is the SHA-256 hash of the cache key. The two-level directory prefix avoids large flat directories on filesystems without hash-tree indexing. - Writes are atomic: data goes to a
cache-*.tmpfile in the same directory and is renamed into place.rename(2)is atomic on Linux when source and destination are on the same filesystem, so a reader never observes a partial entry. - An in-memory index (key → path + expiry + size) is kept so eviction and existence checks never require a disk read. The index is rebuilt from disk on startup; leftover
.tmpfiles and expired or corrupt entries are cleaned up silently. - The
max_sizecap for the disk store is enforced separately from the memory cap. Whenstore = "both", both stores share the samemax_sizevalue but enforce it independently.
Supported Cache-Control directives:
| Directive | Behaviour |
|---|---|
s-maxage |
TTL for shared cache (takes precedence over max-age) |
max-age |
TTL fallback |
no-store |
Response is never cached |
private |
Response is never cached |
no-cache |
Entry stored but must be revalidated before serving |
immutable |
Revalidation skipped while entry is within its max-age window |
stale-while-revalidate |
Serve stale, revalidate in background |
stale-if-error |
Extend TTL window for error fallback |
Vary |
Full secondary key hashing per RFC 9111 |
Unsafe methods (POST, PUT, DELETE, PATCH) invalidate the cached entry for that URL before the request is forwarded.
If a bunch of requests hit the same cold or expired URL at once, Pyloros makes one upstream call and shares the response with all the waiting requests. Nothing to configure.
Requests served this way get Cache-Status: pyloros; fwd=uri-miss; collapsed (RFC 9211 §5.2). The pyloros_cache_coalesced_total counter tracks how often it happens.
Coalescing only applies to cacheable routes (cache = true) and GET requests. Non-cacheable routes and mutation methods stream directly.
Pyloros exposes a Prometheus endpoint on a separate port so it never shares a listener with proxy traffic.
[metrics]
enabled = true
addr = ":9090"
path = "/metrics"A /healthz endpoint lives on the same port and returns 200 when the process is up.
Available metrics:
| Metric | Type | Description |
|---|---|---|
pyloros_cache_hits_total |
counter | Cache hits |
pyloros_cache_misses_total |
counter | Cache misses |
pyloros_cache_stored_total |
counter | Responses stored |
pyloros_cache_coalesced_total |
counter | Requests served from a coalesced upstream flight |
pyloros_upstream_requests_total |
counter | Requests forwarded, by upstream |
pyloros_upstream_latency_seconds |
histogram | Upstream response time, by upstream |
pyloros_upstream_errors_total |
counter | Upstream errors, by upstream |
pyloros_requests_total |
counter | Requests by method, status, and route |
pyloros_request_duration_seconds |
histogram | End to end request duration |
[log]
level = "info" # debug | info | warn | error
format = "json" # json | textJSON is the default because it is easier to ship to log aggregators. Switch to text if you are running it locally and reading the output yourself.
Pyloros handles three Unix signals at runtime.
| Signal | Effect |
|---|---|
SIGHUP |
Re-parse the config file and apply changes to upstreams and routes without dropping connections |
SIGUSR1 |
Flush the in-memory cache |
SIGUSR2 |
Flush the in-memory cache and the disk cache (runs in the background, does not block traffic) |
Example:
kill -HUP $(pidof pyloros) # reload config
kill -USR1 $(pidof pyloros) # flush memory cache
kill -USR2 $(pidof pyloros) # flush memory + disk cache
make fmt # format source files
make vet # go vet
make test # run tests
make test-race # run tests with race detector
make test-cover # coverage report at dist/cover.html
make lint # golangci-lint
make staticcheck # staticcheck
make vulncheck # govulncheck
make verify # full CI gate (fmt + vet + lint + staticcheck + test-race + mod-verify)
make build # compile to dist/pyloros
make build-all # cross-compile for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64
Tools (golangci-lint, staticcheck, govulncheck) are installed into .tools/bin on first use. They do not touch your global Go environment.
cmd/pyloros/ binary entrypoint
internal/
cache/ RFC 9111 cache engine
cache.go storability checks, primary/secondary key, Vary, TTL, eviction, stale serving
disk.go disk store: content-addressed files, atomic writes, startup rebuild, eviction
config/ TOML config parsing and validation
metrics/ Prometheus instrumentation
proxy/ core reverse proxy handler
tlsconfig/ TLS 1.3 manager, ACME and manual cert modes
transport/ HTTP/3 (QUIC) upstream transport factory
upstream/ HTTP/2 and HTTP/3 connection pool, LOR load balancing, circuit breaker, health checks
accesslog/ structured per-request access logging middleware
version/ build-time version string (wired via -ldflags)
pyloros.toml annotated example configuration
Things that are planned but not done yet, in rough priority order.
-
RFC 9484 CONNECT-IP: CONNECT-IP extended-CONNECT requests (
connect-ipprotocol) are detected and rejected with 501. Full forwarding requires kernel TUN interface integration and is not yet implemented. -
Cache purge API: no way to evict a specific key without restarting the process or flushing everything via SIGUSR1/SIGUSR2. A small admin endpoint on the metrics port would cover the common case.
-
Request and response header rewrite rules: no way to add, remove, or rename headers at the proxy layer beyond the per-route
add_headersmap. Common use cases are stripping internal headers, injecting auth tokens, and rewritingHost. -
W3C traceparent / OpenTelemetry propagation: Pyloros should inject a
traceparentheader when forwarding requests and expose a trace context for the response. -
Weighted load balancing: Least Outstanding Requests is implemented and handles heterogeneous backend load at the stream level. Static weight-based distribution for permanently different backend capacities is not yet implemented.
Pyloros is built against the following RFCs. Items marked ⏳ are tracked in the TODO section.
| RFC | Title | Status |
|---|---|---|
| RFC 9111 | HTTP Caching (STD 98) | ✅ Full |
| RFC 5861 | stale-while-revalidate and stale-if-error Cache-Control Extensions |
✅ Full |
| RFC 8246 | HTTP immutable Response Header Field |
✅ Full |
| RFC 9211 | Cache-Status HTTP Response Header Field |
✅ Full |
| RFC 9218 | Extensible Prioritization Scheme for HTTP — RFC 9218 urgency-based write scheduler on the server side; client Priority header stripped on upstream requests (intermediary fairness, §7) |
✅ Full |
| RFC | Title | Status |
|---|---|---|
| RFC 9110 | HTTP Semantics (STD 97) — hop-by-hop stripping, Via, conditional 304, Connection header field list, host-based virtual routing (§7.2) |
✅ Full |
| RFC 7239 | Forwarded HTTP Extension — Forwarded: injection, ingress spoofing prevention |
✅ Full |
| RFC 4343 | DNS case insensitivity — Host: pattern matching is case-insensitive |
✅ Full |
| RFC 6066 | TLS Extensions — inbound SNI-based cert selection ([[tls.vhost]]); outbound SNI from backend address; overridable via sni_name; IP literals rejected (§3) |
✅ Full |
| RFC 6125 | Wildcard certificate matching — one-label wildcard (*.example.com) in [[tls.vhost]]; exact match takes priority |
✅ Full |
| RFC | Title | Status |
|---|---|---|
| RFC 9113 | HTTP/2 — client and upstream connections, ALPN negotiation, no chunked encoding on H2 frames | ✅ Full |
| RFC 8446 | TLS 1.3 — enforced as minimum version, not configurable | ✅ Full |
| RFC 8555 | ACME — automatic certificate provisioning and renewal | ✅ Full |
| RFC 9000 | QUIC — transport used by HTTP/3 | ✅ Full (via quic-go) |
| RFC 9001 | Using TLS 1.3 with QUIC | ✅ Full (via quic-go) |
| RFC 9114 | HTTP/3 over QUIC — server listener (http3_addr) and opt-in upstream transport (http3 = true); Alt-Svc injection on HTTP/2 responses |
✅ Full |
| RFC 9297 | HTTP Datagrams (enable_datagrams = true) — QUIC datagram capsule framing on HTTP/3 connections |
✅ Full |
| RFC 9298 | Proxying UDP in HTTP (MASQUE CONNECT-UDP) — extended CONNECT with connect-udp protocol; enabled when enable_datagrams = true |
✅ Full |
| RFC 9484 | Proxying IP in HTTP (CONNECT-IP) — extended CONNECT with connect-ip protocol detected; full kernel TUN forwarding not yet implemented |
⏳ Partial (501) |
| FIPS 203 | ML-KEM-768 (CRYSTALS-Kyber) — used as the X25519MLKEM768 hybrid KEM (ML-KEM-768 + X25519) on every TLS 1.3 client and upstream handshake; peers that do not support it fall back to X25519 within the same handshake |
✅ Full |
MIT. See LICENSE.