Skip to main content

Roadmap

The following features appear in the requirements or configuration surface but are not yet implemented. No delivery dates are promised; this page tracks the honest current state.

FeatureCurrent state
Python scripting runtime (pyo3)Not implemented. No .py examples ship and runtime: python is rejected at policy-compile time.
TLS terminationImplemented — set tls in system.yaml to serve HTTPS on the data-plane listener (and, via admin.tls, the Admin API). rustls/ring, PEM cert+key, min_version 1.2/1.3, certificate hot-reload (served on new connections without a restart), mTLS (client-cert verification via client_ca_path, required/optional, with the client fingerprint, subject CN, and SAN DNS names exposed to the pipeline for identity-based authz), SNI multi-certificate termination (sni_certs presents a per-hostname cert on one listener; see TLS guide), and automatic certificates via ACME (TLS-ALPN-01; Let's Encrypt or any RFC 8555 CA incl. EAB; filesystem or redis stores: storage with sealed keys; placeholder bootstrap gating /readyz; lease-coordinated renewal with ARI; Admin API + Certificates panel + Prometheus series — see TLS guide → Automatic certificates). Follow-ups: CRL/OCSP revocation, HTTP-01 and DNS-01 (wildcards), RSA ACME keys, ACME on admin.tls, pub/sub cert propagation between instances.
HTTP/2Implemented — enabled by default (http2.enabled); the listener negotiates HTTP/2 alongside HTTP/1.1 (ALPN over TLS, h2c prior-knowledge over plaintext), and the outbound client advertises h2 to TLS upstreams (see TLS & HTTP/2).
WebSocket proxyingImplemented — a client WebSocket upgrade runs the normal policy graph (access-phase plugins apply), the upstream node resolves the target, and the listener relays the upgraded connection to a ws:// or wss:// upstream (via the upstream node's tls flag). Both the HTTP/1.1 upgrade and HTTP/2 extended CONNECT (RFC 8441) are accepted from clients (see TLS, HTTP/2 & WebSocket). Follow-up: RFC 8441 to the upstream (upstream leg is HTTP/1.1).
TCP/UDP proxyingImplemented — L4 stream listeners under stream: in system.yaml proxy raw TCP (accept → relay) and UDP (per-client datagram sessions) to a load-balanced upstream pool, independent of the HTTP engine, including SNI-based TLS passthrough routing on TCP (see L4 stream proxying). Follow-ups: dynamic/hot-reloadable stream routes.
proxy-cache pluginImplemented — a lookup/store node pair sharing one namespace by id (see proxy-cache), backed by either an in-memory policy: local cache or a shared policy: redis + store: cache (same named stores: resource as the rate-limit policy and sessions below), so multiple instances can share one cache and one policy pair can serve either backend without any other config change. A per-entry size cap (max_object_bytes, default 1 MiB) keeps one oversized response from filling a shared store, and the local backend is bounded by cache.max_entries (system.yaml, default 10000) -- it previously only evicted an entry when something read it, so anything written and never read again was kept for the life of the process. Hits, misses, backend errors, evictions and size-skips are reported per backend and store as gateway_cache_events_total. A backend it cannot reach is treated as a miss rather than an error, deliberately unlike sessions and the store-* nodes: a cache protects latency, not correctness. A pair whose halves disagree about policy/store is rejected at policy-compile time -- it would otherwise compile and serve a permanent 100% miss with no error -- and a half with no counterpart is reported in the cache_pairs array of POST /api/policies/validate. Explicit invalidation by id has three triggers: DELETE /api/cache/:id, the MCP purge_cache tool, and a phase: purge node on a write path; a policy: local purge only clears the instance that handled the request, while policy: redis purges are cluster-wide because the store is shared. Follow-ups: caching streamed responses.
Redis rate-limit policyImplementedlimit-count and workflow support policy: redis via named stores:; token-bucket rate-limit and limit-conn remain local-only, distributed backends planned on the same resource.
Provider-failure responses of the auth pluginsChanged (breaking) — when the identity/authorization provider itself fails (unreachable, timeout, non-2xx, unparseable: OIDC discovery/JWKS/introspection/token endpoint, CAS /serviceValidate, an LDAP bind transport error, a Keycloak or Casdoor callout) the node now prepares one shared response on its error port: 502 {"error": "provider_error", "message": "<reason>"} with no challenge header (src/plugins/util/provider_error.rs). Previously each plugin mirrored its own denied shape — 401 unauthorized (+ WWW-Authenticate) for openid-connect, cas-auth, ldap-auth; 403 access_denied for authz-keycloak, authz-casdoor — so an IdP outage read as a rejected credential and, for the interactive plugins, as a broken login instead of an unreachable IdP. Error codes are unchanged except LDAP_AUTH_FAILEDLDAP_AUTH_PROVIDER_ERROR. Every error-port exit and every rejected config apply is also logged at WARN.
Server-side sessionsImplemented for five interactive auth plugins — openid-connect, cas-auth, authz-casdoor, dingtalk-auth, feishu-auth — via opt-in session.storage: redis (+ session.store: naming a declared stores: entry), backed by the same shared Redis/Valkey resource as the rate-limit policy above. The sealed session payload moves server-side under a bare 128-bit-id cookie, enabling listing/revocation (GET/DELETE /api/sessions) and, for openid-connect, lock-coordinated token refresh (session.refresh). session.storage: cookie (the default) is unchanged and remains unrevocable by design. dingtalk-auth/feishu-auth additionally had their pre-existing session/redirect flow restored (both storage modes) — a breaking port-spec change, since both now mandatory-wire a redirect output port. Remaining deviation: no secret_fallbacks (multi-secret key rotation) for the session sealer. Follow-ups originally tracked here are now shipped: a Stores editor and Sessions panel in the web UI (with store/session.store picker dropdowns on limit-count and the five session plugins), gated e2e coverage (E2E-STORE-01..04 ungated, E2E-SESS-01..03 gated on FEATHERBIT_TEST_REDIS_URL), and a redis:7 + valkey/valkey:8 service-container CI matrix (plus a redis:7 service on the e2e job) — see Shared Stores & Sessions.
unpack nodeSpecified in the requirements; not implemented.
SupernodesImplemented — reusable, named subgraphs stored top-level in gateway.yaml (supernodes:), edited in the web UI's library, and referenced from any policy via a type: supernode node. Instances are inlined into the compiled graph at compile time with namespaced instance/inner node ids (visible in traces and /metrics), with black-box error routing out the instance's single error port. Full Admin API CRUD (GET/PUT/DELETE /api/supernodes[/:name]) and etcd support (see Supernodes). In the policy editor, an instance expands in place to a read-only, zoomed-out preview of its inner graph (0.4.0) — expansion is editor-session state only, never saved into the policy. V1 has no parameters and no nesting (a supernode cannot contain another supernode); "extract selection to supernode" in the UI is a fast-follow.
Shared plugin configsImplemented — named, typed config profiles stored top-level in gateway.yaml (plugin_configs:) and attached to any plugin node, in a policy or inside a supernode definition, via config_ref. Resolved at compile time (shallow, top-level merge, local wins — a local null overrides with null and works only where the plugin field is Optional; there's no way to remove an inherited key otherwise); the stored config, GET /api/config/export, and etcd always keep the reference form. Full Admin API CRUD (GET/PUT/DELETE /api/plugin-configs[/:name]), etcd support, and a web UI library section + node-inspector picker with inherited-value display (see Shared plugin configs). V1 has no nesting (a shared config cannot reference another) and no deep merge.
Debug mode & plugin sandboxImplemented — opt-in per-request policy-execution traces (context snapshot + derived diff per node, redaction at capture time, bounded ring buffer) and a sandbox that runs plugins or whole policies against a synthetic request through the real engine. Off by default, restart-gated (debug.enabled in system.yaml), served from /api/debug/* and the web UI's Debug panel (see Debugging & sandbox). Follow-up: Lua-script step-through remains out of scope.
MCP server for agentsImplemented — a Model Context Protocol (Streamable HTTP) endpoint on the admin listener (admin.mcp, off by default) behind scoped read/write bearer tokens: tools over routes/policies/supernodes/plugin-configs/stores (writes with dry_run), traces and the sandbox, embedded docs pages as resources, and precompiled debugging/authoring prompts; the web UI gains an Agent panel and "copy as agent prompt" actions (see MCP server for agents). The UI's Chat panel talks to a bring-your-own OpenAI-compatible endpoint from the browser and uses these MCP tools mid-conversation (reads auto-run, writes ask first; threads and settings in browser local storage). Follow-ups: stdio transport, consumer writes, listChanged notifications, an Anthropic provider for the chat, a session-only key mode.
Outbound (upstream) mTLSImplemented (0.2.0) — the upstream node presents a client certificate (client_cert_path/client_key_path) and/or trusts a private CA (ca_cert_path, replacing system roots for that upstream), validated at policy compile, applied to HTTPS proxying and wss relays. Follow-ups: cert-file hot-reload (rotation currently needs a gateway.yaml touch or restart), client certs for callout plugins (forward-auth, OPA, loggers).
Headless build / Docker image variantsImplemented (0.3.0) — the web editor is gated by the default-on ui cargo feature; -headless image tags on Docker Hub ship without it, and full builds can disable it at runtime via admin.ui_enabled.
etcd clustering (stateful mode)Implementedconfig.source: etcd delivers config over etcd's v3 HTTP/JSON gateway with cluster-wide convergence and seed-if-empty bootstrap (see Deployment → HA clustering with etcd). Follow-ups: TLS-to-etcd, streaming watch, multi-endpoint failover.
Graceful shutdownImplemented — on SIGTERM/Ctrl+C the gateway stops accepting on every listener and drains in-flight HTTP + Admin requests (bounded by timeouts.shutdown_timeout_seconds, default 30s) before exiting (see Deployment → Graceful shutdown). Long-lived WebSocket/L4 tunnels get the drain window then close at exit.
Script execution timeoutsImplemented — the script node enforces timeout_ms (default 5000) through a Luau VM interrupt: a runaway script is stopped at a VM instruction boundary and the node fails with LUA_TIMEOUT, instead of blocking the request and pinning the worker thread polling it. The same budget bounds the validation run at policy-compile time, where a top-level loop previously hung the Admin API. 0 disables enforcement. Limitation: interrupts fire inside the VM only, so time spent in a Rust callback or require's file IO is not bounded. Follow-up: script execution still runs synchronously on the async worker, so a slow script occupies it for up to timeout_ms.
script respond portImplemented — breaking in 0.11.0. A script answers a request itself with return ctx, "respond" (the node's respond outcome port); every script node must wire respond. Also in 0.11.0: a serverless-pre-function/serverless-post-function script that returns a second value other than "success" now fails with LUA_BAD_PORT (it used to be ignored), and a script whose execute returns something that is not a table now fails with LUA_UNMARSHAL_ERROR (was LUA_EXECUTION_ERROR).
Context var autocompleteImplemented — the web UI's plugin config editor offers $var autocomplete with live value preview (from the selected node's latest debug trace) on schema-form fields flagged as var-accepting, plus a full var legend. Backed by a machine-readable catalog (src/vars/catalog.rs, drift-guarded against the resolver) served at GET /api/vars. New engine coverage: $sent_http_<name> (response headers) and $request_body complete the var surface so every Context field is addressable (see Context vars). V1 scope: raw JSON/YAML editors and expression-rule ([var, op, value]) builders don't get the popover, though the vars still work when typed by hand; live values need debug mode, an incoming edge, and an existing trace.
Universal config templatesImplemented{{namespace.path}} rendering (src/vars/template.rs) applies across every traffic-bound plugin config field (~30 plugin families), not just the ~15 fields the legacy $var syntax ever covered; unrecognized {{...}} passes through literally (with a load-time warning for a known-namespace typo or an unset {{env.NAME}}), so it's safe on secrets, regexes, and JSON-Schema $ref alike. {{env.NAME}} (GET /api/env-vars, authed, names only) resolves at Template::parse time — only in the fields actually swept onto Template; every other field only ever gets the older, universal ${NAME}/${NAME:-default} substitution (src/config/loader.rs) that applies to all config regardless of templating. The legacy 15 fields compose both {{...}} and $var via render_with_legacy (the $ pass never touches rendered {{...}} output — a deliberate security property). Web UI: {{-triggered suggestions with live preview on every field the engine genuinely templates; elsewhere the same trigger offers env names only, inserted as ${NAME} (not {{env.NAME}}, which would never resolve there) — plus a legend v2 with a legacy mapping table (see Templates). Structural fields (regex/schema/Lua/Casbin/balancer/TLS/numeric) are excluded by design; body-transformer and error-handler keep their own, pre-existing {{...}} dialects untouched. Behavior change on upgrade: literal text that happens to look like a recognized {{namespace.path}} reference in a field that previously only interpolated $var (or any newly-swept field) now substitutes instead of passing through unchanged — see the COMPAT note.
Terminal nodes / CORS preflightFixed. cors now exits an OPTIONS preflight through a dedicated preflight output port instead of success, so wiring cors.preflight to client genuinely short-circuits the request before upstream ever sees it — the policy compiler rejects any policy that leaves an outcome port like preflight unwired. Covered end-to-end by E2E-DP-09 in the e2e suite.
Streaming responsesImplemented — an upstream node whose success path has no body-reading node relays the response to the client as it arrives instead of buffering it first, inferred at policy-compile time (never configured directly); POST /api/policies/validate reports which node forces buffering when one does. timeout_ms bounds connect + request + headers only on a streaming response, with the body separately bounded by stream_idle_timeout_ms. Proven end-to-end with a real SSE upstream (E2E-STREAM-01/02, plus real-socket wire-framing tests in src/server/listener.rs). The opt-out is answered per configured instance, not per type: the four nodes that evaluate a template or condition after the upstream (traffic-label matchers, response-rewrite vars, proxy-rewrite response-phase headers, request-id header_name) scan their own config for a response-body reference, and the 16 log_format loggers opt out when their format never mentions the body — so upstream → logger → client streams, where every logger used to block it. Follow-ups: request-body streaming (uploads are still fully buffered); X-Accel-Buffering (honored by nginx, inert here); streaming through proxy-cache; compression of streams; a logger with no log_format still buffers, because the default entry records the body length; a mid-stream failure cannot become an error response, since the status and headers are already on the wire by the time it happens.