Caddy-access-filter: pre/post-proxy hooks with an external HTTP processor

caddy-access-filter is a Caddy plugin that adds two hooks around reverse_proxy:
access (before forwarding) and filter (after forwarding). Both hooks call an
external HTTP processor over a single endpoint.

I maintain a gateway where the business rules change often: auth, rate limiting,
gray release, tenant-specific URL rewrites, device-based headers. The rules used
to live in the gateway code.

OpenResty solves this class of problem by embedding LuaJIT in NGINX. It exposes
hooks for the whole request lifecycle: access_by_lua, rewrite_by_lua,
header_filter_by_lua, body_filter_by_lua, balancer_by_lua, content_by_lua. It
runs in-process with no extra network hop, and can do almost anything at any
stage.

Caddy has no runtime scripting. The existing options are scattered:

  • replace_response: static replacement only.
  • forward_auth: request-side only. Cannot rewrite URL or body, no response
    side.
  • handle_response / intercept: a limited set of Go directives, cannot call an
    external service.

The missing piece was a unified pre/post hook with a language-agnostic
processor contract. The mapping against OpenResty is roughly:

access_by_lua / auth_request  ->  access hook (allow / deny)
rewrite_by_lua                ->  access hook rewrite (path / query / headers)
header_filter_by_lua          ->  filter hook headers
body_filter_by_lua            ->  filter hook buffer / stream

The tradeoff: one extra HTTP call per hook, and response-body transforms are
buffered, not streaming. The gain: the processor is not tied to Lua, does not
run inside the gateway, and can be changed without recompiling Caddy.

One endpoint, one contract, distinguished by a phase field.

  • access phase: the gateway POSTs the request (method, uri, headers, client_ip).
    The processor returns allow or deny. allow can include a rewrite
    (path/query/headers); deny sets the status (401/403/429) and a response body.
  • filter phase: the gateway POSTs the response (status, headers, body, duration,
    bytes). The processor returns the transforms.

The processor is language-agnostic: PHP, Node, Python, Go, or a cloud function.
It can be deployed and replaced independently. But PHP is perfer, because we hava
FrankenPHP, which combine Caddy and PHP, so its worker mode is very fast, like Lua
in nginx.

Reliability is built in: 200ms per-call timeout, on_error passthrough/block
fallback, circuit breaker (5 consecutive failures → 30s bypass → recovery
probe), and contract version negotiation (X-Processor-Ver: 1).

Install

xcaddy build --with github.com/wangbo5825/caddy-access-filter

Caddyfile

route {
    access {
        upstream http://127.0.0.1:8081
        path     /__proc/request
        timeout  200ms
        on_error passthrough
    }
    filter {
        mode     buffer
        upstream http://127.0.0.1:8081
        path     /__proc/request
        timeout  200ms
        on_error passthrough
    }
    reverse_proxy http://upstream:9000
}

With no processor configured, the module is a transparent pass-through,
equivalent to a plain reverse proxy. You can build it into Caddy without
touching any existing Caddyfile.

Status

v1.0.0. Verified end-to-end in a real environment: FrankenPHP running a PHP
worker as the processor, a Python service as the upstream, eight scenarios
covered. Zero business dependencies. Module ID: http.handlers.access_filter.

Known gaps: response body transform is fully buffered (practical for text, not
for binary or streaming); dynamic upstream is hint-header based, no real load
balancing; no built-in authentication for the processor endpoint (use the
header directive or mTLS in front).

Repo: GitHub - wangbo5825/caddy-access-filter: General-purpose Caddy middleware with programmable access/filter hooks delegated to an external HTTP processor. 通用 Caddy 中间件:将可编程 access/filter 钩子委托给外部 HTTP 处理器。 · GitHub

Feedback welcome.