Releases: featherbitplatform/gateway
Release list
featherbit 0.12.1
Patch release: two web UI fixes. No gateway or config behavior changes.
Numeric fields accept environment placeholders
Numeric config fields (upstream target port, timeouts, status codes, limits…) were native number inputs. So:
${BACKEND_PORT}could not be typed at all;- a placeholder already in
gateway.yaml, like the upstreamport: ${ECHO_PORT:-3010}, showed as an empty field.
The gateway itself already handled this. At compile time, a value that is exactly one ${NAME} / ${NAME:-default} is resolved and typed as a number.
Numeric fields now accept either a number or one such placeholder:
- The placeholder is stored as that string, and numbers stay numbers.
- Partial or mixed text (
${BACK,12abc,${A}:${B}) is flagged and never saved, so the config keeps its last valid value. - This applies to top-level fields, list rows and object sub-fields (like upstream
targets[].port).
Prefer the :-default form. An unset variable without a default resolves to an empty string, which a numeric field cannot use.
Suggestions in the shared config editor
The Plugin configs editor rendered its form without the variable context, so its text fields got no {{ / ${ popover and no legend. It now offers the same suggestions as the node inspector:
- the environment variables the gateway started with, as
${NAME}; - the template paths.
A shared config is not tied to one node, so there are no live request values to preview. Environment values are never sent to the UI, anywhere, as before.
Tests
cargo fmt --check,cargo clippy --all-targets,cargo test- UI:
tsc, eslint, vitest (newnumberFieldtests) - Full e2e suite: 152 passed; the skips are the usual Redis/Pebble-gated scenarios. It includes:
- E2E-UI-26: the seeded placeholder port is shown, partial text is rejected, and a placeholder saves and still routes live traffic.
- E2E-PC-04: shared config suggestions list
env.LOG_LEVEL, and a placeholder status code saves.
Hotfix branch off main (identical to develop at 0.12.0). After merge: tag v0.12.1, then bring develop up to main.
featherbit 0.12.0
Minor release. The admin UI asks for credentials, the admin API can speak HTTPS in one line, and route priority is editable from the UI.
⚠️ Behavior changes
- The web UI requires sign-in. It used to send a hard-coded
admin:adminwhenever nothing was stored. That meant it only worked against default credentials and never asked for real ones. It now opens a sign-in screen. Credentials stored manually underlocalStorage['gw_credentials']keep working, since the key is unchanged. Scripts or browser automation that relied on the silent fallback must sign in (or seed that key) first. - UI requests get a bare 401. Requests carrying
X-Featherbit-Clientget a401withoutWWW-Authenticate, so the browser does not open its native Basic dialog. Every other client (curl, scripts, orchestrators) still gets the standard challenge. - New warnings. The gateway logs a startup warning when the admin API still uses
admin/admin. It also warns when the data plane has TLS but the admin API is plain HTTP on a non-loopback address.
Sign-in for the admin UI
- Verified before storing. The form checks the credentials against
/api/statusbefore keeping them. - Storage. Credentials last for the browser tab by default (
sessionStorage), or across sessions with Remember me (localStorage). - Password changed mid-session. A 401 opens the same form as an overlay over the still-mounted editor, so unsaved canvas edits survive re-entering the password.
- Sidebar footer. It shows "Signed in as …", a Sign out button, and a warning while the gateway runs on
admin/admin.GET /api/statusreports this as"default_credentials": true. - Plain HTTP. The sign-in screen warns when the page was loaded over plain HTTP from anywhere but localhost.
- Server side. Basic Auth credentials are now compared in constant time, and non-ASCII passwords work (UTF-8 Basic encoding).
Admin API over HTTPS: admin.tls: { inherit: true }
tls:
cert_path: /etc/gateway/tls/cert.pem
key_path: /etc/gateway/tls/key.pem
admin:
tls:
inherit: true- What is shared. Only the data plane's certificate and key, hot-reload included.
min_versionand mTLS (client_ca_path/client_cert_required) stay the admin block's own, so a data-plane client CA never locks operators out. - Refused at startup when there is no top-level
tls:, when the data plane's default certificate is ACME-managed (ACME is still not supported on the admin listener), or whenadmin.tlsalso sets its owncert_path/key_path/sni_certs/acme. - No surprise defaults. The admin listener still never picks up
tls:on its own, so a TLS-terminating proxy in front of the admin port is unaffected.
Route priority from the UI
Routes are matched top to bottom and the first match wins. Until now, changing the order meant editing gateway.yaml.
| Surface | |
|---|---|
PUT /api/routes |
Body {"order": [...]}. It must name every route exactly once; anything else is 400 and the order is unchanged. Live immediately. |
MCP put_route_order |
Write scope, dry_run supported, Run/Skip-gated in the chat |
| Sidebar | Each row has a drag grip, its priority number, hover up/down arrows, and a drop indicator |
Smaller changes
- Refresh without reloading the page. A header button, or Ctrl+K → Refresh UI data, re-fetches routes, policies and libraries while keeping unsaved canvas edits. It is separate from Reload Config, which re-reads
gateway.yamlon the gateway. The chat panel also refreshes after every write tool it runs. - Chat: whitespace-only assistant messages (models often stream a bare newline next to tool calls) no longer render as empty bubbles.
- ACME: a transiently denied exclusive create of the takeover marker is retried instead of failing the takeover (#69).
Upgrading
- If you use the web UI: sign in once. Tick Remember me to keep the browser signed in.
- If the admin port is reachable from other machines: add
admin.tls(inherit: trueor its own certificate), or bind admin to127.0.0.1. Either change it from the shippedadmin/adminor setADMIN_USER/ADMIN_PASSWORD.
featherbit 0.11.0
Minor release. A cache you can invalidate, a script that can answer the request, a store budget that now bounds every wait, and an examples/ directory where every scenario runs.
⚠️ Breaking
- Every
scriptnode must wirerespond.scriptdeclares arespondoutcome port, and outcome ports are mandatory wiring, so a policy with ascriptnode fails to compile until it gains an edge from<id>.respond(toclient, usually) — even for a script that never takes the port. The error names the node and the port; a gateway whose config fails to compile exits at startup, so do this before upgrading. Same class of change as theredirectport ondingtalk-auth/feishu-auth. serverless-pre-function/serverless-post-function: a second return value fromexecuteused to be silently ignored; it is nowLUA_BAD_PORTunless it is"success". These nodes declare no outcome port — a script that must answer the request belongs in ascriptnode.LUA_UNMARSHAL_ERRORis now the code for a script whoseexecutereturns something that is not a table (wasLUA_EXECUTION_ERROR). Observable inerror-handlertemplates and loggers that match on{{error.code}}.
Explicit cache invalidation
proxy-cache pairs can be purged by their id, on whichever backend holds them:
| Trigger | |
|---|---|
DELETE /api/cache/{id} |
200 {id, purged: [{backend, store?, removed}]} · 404 when no policy has a pair with that id — a typo must not read as a successful flush · 502 when a backend could not be reached, listing what succeeded first |
MCP purge_cache {id, dry_run} |
Write scope; dry_run lists the backends a purge would hit and deletes nothing |
proxy-cache phase: purge |
a node on the write route clears the pair on the way through — write-through invalidation, the case a TTL cannot cover |
Reads degrade, purges report. A lookup that cannot reach its backend is a miss, because a cache protects latency. A purge is different in kind: the caller asked for state to change, so a failed purge exits the node's error port (CACHE_PURGE_FAILED) rather than continuing silently. The purge node is not gated by cache_method, so it fires on POST/PUT/DELETE.
Every key a pair writes begins with {id}\u{1}, so a purge is a prefix removal — products never touches products-v2. On redis it is SCAN MATCH + batched UNLINK with glob metacharacters escaped; the scan walks the store's whole keyspace, so do not wire a purge to a high-rate write path on a large shared store. from_config now rejects an id containing the separator (or any control character). A policy: local purge clears the instance that received the request only; policy: redis purges are cluster-wide. cache_events{event="purge"} counts purge operations.
A script can answer the request
function execute(ctx)
if blocked(ctx) then
ctx.response.status_code = 403
ctx.response.body = '{"error": "forbidden"}'
return ctx, "respond" -- leave on the respond port; the upstream never runs
end
return ctx
endUntil now a script had no way to stop a request: whatever it wrote into ctx.response before the upstream was replaced when the upstream ran — the Lua guide's bot-blocker example had never blocked anything. return ctx, "respond" leaves the node on its respond outcome port; return ctx is success as before. Nothing is inferred from the response, and only "respond"/"success" are accepted names: anything else is LUA_BAD_PORT with the context as it was before the script ran.
Store budget bounds every wait
connect_budget_ms bounded only the first connection. Once a store went away mid-life, every command awaited the redis client's internal reconnect — six backed-off attempts, each up to connect_timeout_ms, nothing capping the total. Measured against a stopped container: the first request after the outage failed in 1.5 ms, the second held its worker for 54.8 seconds before its 503. Every store operation is now bounded by the budget (store operation gave up), and the reconnect continues in the background so the first request after the store returns succeeds.
Examples: one runnable directory per scenario
examples/ is eight self-contained Compose stacks — minimal, tls, etcd-single, etcd-cluster, and new mcp (endpoint enabled behind scoped tokens from a gitignored .env, a .mcp.json for Claude Code), lua-scripts, stream (a TCP stream next to the HTTP plane), redis-stores (a shared cache pair, a purge route, a counter). CI validates every stack's compose config. The loose system-*.yaml files are gone; every reference in the docs points at the new layout.
Also
| Docs admonitions | 47 :::type Title blocks — every Breaking change notice on the auth plugin pages among them — rendered as literal text under Docusaurus v3; all now use :::type[Title] |
| Lua guide | no longer claims timeout_ms is unenforced (it has been since 0.10.0) |
proxy-cache docs |
cache_method gates the lookup and store phases only; a purge half joins the pair-agreement check |
| Agent panel | purge_cache in the write-tool list (a missing entry meant no Run/Skip card) |
Upgrade notes
- Add
<id>.respond → client.into everyscriptnode before upgrading. See Breaking above. connect_budget_ms(default 5000) now also caps how long any store command waits during a reconnect. A store that legitimately takes longer than that to come back now fails those requests instead of holding them; raise the budget if that is your situation.- The
redis-storesexample needs this release (phase: purge,DELETE /api/cache/{id}).
Verification
cargo test 1416 passed with a live redis, in debug and --release · both clippy invocations clean (--locked -- -D warnings, and --no-default-features) · cargo fmt --check clean · website build clean · e2e 155 passed / 1 skipped.
Every load-bearing behaviour in this release was shown to fail against the implementation it rules out — the purge prefix boundary, the purge node under the method gate, the respond→success alias, the count arithmetic under concurrent writes, and the reconnect wait — by mutating the code and watching the test catch it.
featherbit 0.10.0
Minor release. Policies can remember things — four store-* nodes over the shared redis/valkey stores — and proxy-cache can share one cache across instances. Plus two availability fixes where the gateway could previously hang or fail open.
Policies can hold state
store-get, store-set, store-incr and store-delete read and write arbitrary keys in a declared stores: entry. Until now the only cross-request store a policy had was a cookie — client-side, size-limited, spoofable, per-browser.
- id: count-retry
type: store-incr
config:
store: sessions
key: "oidc-retry:{{client.ip}}"
ttl_seconds: 300
name: retry_countstore-get declares a miss outcome port, so "the key isn't there" is a branch the compiler makes you wire rather than something you discover at runtime. It is deliberately distinct from error: a store outage must never look like "nothing recorded", or a policy takes its happy path during exactly the incident where that is most wrong.
store-incr's TTL applies when the key is created and is never refreshed, so a client that keeps retrying cannot hold its own bound open. refresh_ttl: true opts into the sliding window instead, and store-get's extend_ttl_seconds turns a read into a keep-alive (GETEX), for state that should expire a fixed time after last use.
Keys live under a kv: namespace. A registry now declares every namespace featherbit writes — cnt, acme, sess, lock, subj, kv, cache — with a test that calls each subsystem's real key builder, so a future subsystem cannot quietly collide with one already in use.
A shared response cache
proxy-cache takes policy: local | redis + store:. Every instance used to keep its own cache, so three instances meant three cold caches and three times the upstream load for one working set — scaling out lowered the hit rate.
This cache fails open, unlike everything else that touches a store. A backend it cannot reach is a miss, and the request goes to the upstream. Sessions and the store-* nodes fail closed because losing their store loses correctness; a cache only holds a copy of something the upstream can produce again, so losing it should cost latency and nothing else. Failing closed would let a redis blip take down the highest-traffic routes it was only ever meant to accelerate.
Because that is silent, gateway_cache_events_total{backend,store,event} reports hits, misses, errors, evictions and size-skips. hit+miss partition every lookup, so the hit rate is hits/(hits+misses); error is an overlay on the miss it caused.
A pair whose halves disagree about policy or store is now rejected at compile time. It used to compile and serve a permanent 100% miss with no error anywhere.
Two things that could hang or fail open
A runaway Lua script no longer pins a worker. The script node parsed timeout_ms, stored it, and never enforced it — the field literally carried #[allow(dead_code)]. Since the Lua call is synchronous inside an async plugin, while true do end pinned the tokio worker polling it. Now bounded by a Luau VM interrupt; the same budget also bounds policy-compile validation, where a top-level loop previously hung the Admin API.
A store outage no longer hangs a request. connect_timeout_ms bounded one attempt, not the retry schedule around it — 6 retries with uncapped backoff. Measured against a refused connection: 17.96 seconds before the error surfaced, on the first request after an outage began. connect_budget_ms (default 5000) bounds connect plus retries plus waits together, for every store consumer.
Also
reads_response_body |
now answered per configured instance. A traffic-label matching on resp_body used to silently stop matching on a streaming route; the 16 log_format loggers no longer force buffering when their format never mentions the body |
| Trace listings | carry a retention block (truncated, evicted, oldest_seq), so an empty filtered result is distinguishable from one that rotated out. max_traces default 50 → 1000 |
| Local response cache | bounded by cache.max_entries (default 10,000). It previously evicted an expired entry only when something read it, so anything written and never read again was kept for the life of the process |
| Editor | the sidebar shows one library at a time — routes get the whole body instead of a quarter |
| Clipboard | copy works outside a secure context; navigator.clipboard is undefined over plain HTTP and threw a TypeError |
Upgrade notes
max_tracesdefaults to 1000 (was 50). Debug mode is off by default, so only deliberate users are affected.connect_budget_msdefaults to 5000. A store that previously took longer than that to connect now fails instead of eventually succeeding. Raise it if you are on a slow link.cache.max_entriesdefaults to 10,000. Apolicy: localcache that was previously unbounded is now capped; raise it if your working set is larger.- A
scriptwhose top level legitimately takes longer thantimeout_msto load now fails to compile. Pathological at the 5000ms default; raise the budget or move the work intoexecute. - No config changes are required otherwise. Existing policies compile and behave as before.
Known limitations
- No explicit cache invalidation — entries expire, nothing purges on demand.
- Two-tier caching (local in front of redis) is out of scope; coherence is its own design.
- A
proxy-cachehalf with no counterpart is reported, not rejected — it is also what a half-built policy looks like. - Request bodies are still fully buffered; streaming uploads remain separate work.
Verification
cargo test 1377 passed with a live redis, without it, and in --release · both clippy invocations clean (--locked -- -D warnings, and --no-default-features) · cargo fmt --check clean · UI lint, tsc -b, 194 vitest · website build clean · e2e 152 passed / 1 skipped.
Every load-bearing behaviour in this release was demonstrated to fail against the implementation it rules out — the cross-instance cache sharing, the store-incr TTL rule, the cache size guard, the eviction policy, and the script timeout — by mutating the code and observing the test catch it, not by reading the diff.
featherbit 0.9.0
Minor release. Streaming responses — featherbit can now relay Server-Sent Events and chunked bodies to the client unbuffered, decided automatically at policy-compile time.
Version bump only in this PR; the feature landed in #46 and #47.
Why
The gateway's handler returned Response<Full<Bytes>> and upstream bodies were .collect().to_bytes(), so a long-lived response was held until the upstream closed. An SSE endpoint delivered nothing, then died at timeout_ms. Found in production: a notifications channel consumed by a frontend worker worked directly against the backend and delivered nothing through the gateway.
How it decides
Streaming is inferred, never configured. At compile time, for each upstream node the compiler walks forward from its success port to client. If every node on the way declares it does not read the response body, that upstream streams; otherwise it buffers exactly as before, and the compiler records which node forced it.
/// Whether this configured instance reads `context.response.body`.
/// Defaults to `true`: a plugin that does not opt out forces buffering, so
/// adding a plugin can never silently break a stream.
fn reads_response_body(&self) -> bool { true }Exactly nine node types opt out: client, proxy-rewrite, request-id, traffic-label, prometheus, opentelemetry, zipkin, skywalking, and response-rewrite when it configures neither filters nor a replacement body. Everything else forces buffering.
The asymmetry is deliberate. Buffering when streaming was possible is a missed optimization; streaming when a node needed the body is a corrupted response.
New and changed
stream_idle_timeout_ms on upstream |
new, default 60000. Bounds the gap between body frames, resetting per frame. |
timeout_ms on a streaming response |
now bounds connect + request + headers only. Buffered responses keep whole-call semantics unchanged. |
POST /api/policies/validate |
new Admin endpoint. Returns valid, errors, and a buffering array naming any node that forces buffering. Behind Basic Auth, read-only. |
MCP validate_policy |
same buffering array, so an agent sees what a human sees. |
| Debug panel | a streamed response is marked "streamed — body not captured" instead of appearing as an unexplained 0-byte body. |
Concurrency guards follow the stream: balancer in-flight counters are bound to the body, so a long-lived connection keeps counting against limits instead of releasing at headers.
Upgrade notes
No config changes required. Existing policies compile and behave as before; those whose success path happens to contain only opt-out nodes simply start streaming.
timeout_ms changes meaning on streaming routes only. If you rely on it to cap total response time for a route that now streams, set stream_idle_timeout_ms instead — timeout_ms will no longer bound the body.
Check whether the routes you expect to stream actually do. POST /api/policies/validate tells you, naming the blocking node. Two cases surprise people: all loggers block unconditionally, including ones whose log_format never references the body; and limit-conn can never stream, because its release node must sit after the upstream.
Known limitations
- A streaming route cannot use body transformers —
gzip,brotli,proxy-cache,body-transformer, or aresponse-rewritewithfilters. Correct (compressing an endless stream is meaningless), and the compiler names the node rather than failing silently. - Loggers all force buffering, even when their format never touches the body. A conditional opt-out for the whole family is follow-up work.
- A node that opts out but evaluates a template or condition referencing the response body (
$resp_body,{{response.body}}, aresponse_body:$...JSONPath) sees an empty body on a streaming route. Affectstraffic-labelmatchers,response-rewritevarsgates,proxy-rewriteresponse-phase headers, andrequest-idheader_name. - A mid-stream failure cannot become an error page — status and headers are already on the wire. The connection terminates without the chunked terminator, so a client sees an unambiguous truncation rather than a response falsely claiming to be complete.
- Request bodies are still fully buffered; streaming uploads are separate work.
X-Accel-Bufferingremains inert — an nginx directive, harmless in migrated configs.
Verification
cargo test 1282 passed · cargo test --release 1282 passed · cargo fmt --check clean · cargo clippy --all-targets clean · UI lint, tsc -b and tests (189/189) clean. All verified on develop before this branch was cut.
The keystone test forces the buffering path back, observes the SSE test time out at 3.04s, restores streaming, and observes it pass in 0.04s — it is demonstrated to fail against the implementation it exists to rule out. Wire framing is checked over a real TCP socket in both the unknown-length (chunked) and known-length (length-delimited) cases. Buffered byte-identity has its own round-trip test asserting hyper still computes content-length from size_hint.
featherbit 0.8.2
Bugfix release. Two HTTP/2-only ingress defects — neither reachable over HTTP/1.1, both silent. No config changes, no migration.
If you terminate TLS at the gateway, HTTP/2 is negotiated by default, so this affects you.
Fixes
Cookies split across multiple cookie fields were truncated to the first
RFC 9113 §8.2.3 permits an HTTP/2 client to split one request's cookies across several cookie header fields, and requires the server to concatenate them before processing. Firefox does exactly this. Every cookie reader in the gateway took the first field only — 9 call sites across openid-connect, cas-auth, dingtalk-auth, feishu-auth, authz-casdoor, wolf-rbac and the $cookie_* var resolver — so a cookie landing in the second field was invisible to all of them.
Observed in the wild as an OIDC login that completed at the identity provider and then failed its callback with {"error": "unauthorized", "message": "missing or invalid login flow cookie"}: the session cookie occupied the first field and the transient login-flow cookie the second, so the CSRF check rejected a perfectly valid login.
request.host was empty over HTTP/2
It was read only from the Host header. HTTP/2 carries the authority in the :authority pseudo-header — exposed on the request URI, not as a header — and browsers send no Host over h2 at all.
| Consequence | Affected |
|---|---|
match.host route rules never matched. Host-scoped routes silently fell through to broader ones, or 404'd, with nothing in the logs explaining why |
routing |
http_to_https redirect target became https:///… |
redirect |
Service URL became https:///path, which CAS rejects |
cas-auth |
Empty X-Forwarded-Host; empty host and port |
forward-auth, opa |
$host / {{request.host}} resolved empty, as did host fields in access logs, traces and Prometheus labels |
everywhere |
The Host header still wins when present, so HTTP/1.x behaviour is unchanged.
Why the fix is at ingress
Both defects are properties of the wire protocol, not of any one plugin, so both are fixed once in GatewayRequest::from_hyper rather than in each reader. Patching nine cookie readers would have left the tenth wrong.
Audit
The sibling bug class — rebuilding a request target from request.path and losing the query string, fixed for upstream in 0.8.1 — was audited across every other site that does the same thing. No further instances: proxy-mirror, forward-auth, authz-casdoor, dingtalk-auth, feishu-auth, opa, hmac-auth and openid-connect all handle the query correctly; cas-auth, authz-casbin and oas-validator are deliberately path-only.
Verification
cargo test 1242 passed / 0 failed · cargo fmt --check clean · cargo clippy --all-targets clean. Three regression tests added, each watched fail before the fix.
featherbit 0.8.1
Bugfix release. No new node types, no config-surface changes, no migration steps.
Why now
upstream built its outbound URL from the request path alone, so every proxied request lost its query string. Anything query-dependent was silently broken — pagination, filters, and OIDC/OAuth hops, where the authorization request reached the IdP with no client_id at all and the IdP answered invalid_request.
Fixes
upstream drops the query string |
The request-target is now path plus the rebuilt query, for both the buffered HTTP path and the WebSocket relay (which stashed a bare path in __ws_upstream_path, so wss://host/ws?token=… lost its token the same way). Values are stored exactly as received — ingress splits the raw query on &/= without percent-decoding — so rebuilding is lossless. Parameter order is normalized: query_params is a map, so the original order was already lost at ingress; sorting makes the outbound target deterministic rather than arbitrary. |
| rustls → 0.23.45 | GHSA-2mjx-qc3c-rqvc, functionally the same bug as Go's GO-2026-4340 (CVE-2025-61730). Lockfile-only; rustls-webpki 0.103.13 → 0.103.15 alongside. |
Docs and examples
- Self-contained
docker composeexample stacks underexamples/compose/— minimal, TLS, etcd-single, etcd-cluster — runnable from published images. - TLS guide: the container-uid gotcha when mounting certificates.
- Every Admin API endpoint documented, with the UI panels the gateway has grown, plus a test guarding the endpoint table against drift.
Behavior change worth reading
Three external-auth scenarios previously observed a bare path at the upstream for requests carrying ?ticket=… / ?code=…. That was the dropped-query bug, not a plugin feature — nothing documents these plugins consuming their credential parameter (cas-auth strips the ticket only in interactive mode, and does it by redirecting to the ticket-free service URL). A spent single-use ticket or code now reaches the backend. If that is undesirable for your deployment, it is a deliberate change to those plugins rather than a property of the proxy hop — please open an issue.
Housekeeping
This branch also merges main into the release, recovering the hotfix/docs-admin-endpoints work (#39) that was merged to main but never back into develop. Merging this release into develop repairs that drift.
Verification
cargo test 1239 passed / 0 failed · cargo fmt --check clean · cargo clippy --all-targets clean · gitleaks clean against the CI image and config.
featherbit 0.8.0
This release takes two kinds of manual work off the operator: certificates renew themselves, and an agent can drive the gateway. ACME issues and rotates TLS certificates without a human in the loop, and a Model Context Protocol endpoint — plus an in-browser chat panel built on it — lets a model read traces, explain policy behavior, and author routes and policies against the real config.
Highlights
Automatic TLS certificates (ACME)
RFC 8555 with the TLS-ALPN-01 challenge (RFC 8737), against Let's Encrypt or any ACME CA (ZeroSSL / Google Trust Services via EAB, step-ca, Pebble). An acme: block in system.yaml sets the directory, contact, ToS, key type and renewal window; acme: { domains } replaces cert_path/key_path on the default certificate and on any sni_certs entry, mixable with file-based slots. Certificates live in filesystem storage (atomic writes, 0600 keys) or a redis/valkey store, sealed at rest, with lease-coordinated renewal so one instance in a cluster renews and the rest adopt the result. Placeholder certificates bootstrap a cold start and gate /readyz until the real chain arrives. Managed via GET/POST /api/acme/certs and a Certificates panel in the admin UI. admin.tls deliberately refuses ACME. Docs
MCP server for agents (admin.mcp)
An rmcp Streamable HTTP endpoint mounted on the admin listener, off by default and restart-gated, behind scoped bearer tokens with constant-time comparison and an Origin allow-list. A read token gets 22 tools — the node-type catalog, vars, status, config export, routes/policies/supernodes/plugin-configs/stores/consumers (credentials masked), validation, traces and the sandbox. A write token adds 11 more (put_*/delete_*, each accepting dry_run, plus reload_config); read tokens never see write tools in tools/list. Every plugin, concept and reference documentation page is embedded in the binary as an agent-readable resource, and nine precompiled prompts (troubleshoot_trace, why_this_port, review_policy, design_policy, …) are served to both agents and the UI. Writes go through the same validate → compile → commit path as the Admin API and are logged. Behind the default-on mcp cargo feature. Docs
Chat panel in the web UI
A bring-your-own-key chat against any OpenAI-compatible endpoint, running entirely in your browser — the gateway still runs no model and stores no provider key. It calls this gateway's MCP tools mid-conversation: reads run as soon as the model asks, writes render a Run/Skip card (or auto-run behind an explicit toggle), and retries of a failing tool fold into one card. Threads and settings live in browser local storage, replies render as GitHub-flavoured Markdown, and a searchable model dropdown loads the provider's own /models list. A trace's Troubleshoot with AI, a step's Ask AI about this step, the policy toolbar's Review with AI and the Ctrl+K palette all open a thread seeded with the matching prompt and the data inlined.
On top of the gateway's own redaction — traces redact at capture, MCP masks consumer credentials, config keeps raw ${ENV} placeholders — the chat adds a client-side pass before anything is stored or sent: bearer/basic credentials, cookies, secret-looking keys, JWTs, PEM blocks and your own keys become [REDACTED]. It is a heuristic, and the guide says so.
set-vars node type
Derives context.message variables from a template, a JSONPath expression, or a regex capture — so a path segment, query parameter, header or JSON body field can be pulled out once and reused downstream as $msg_<name>. This is the piece that makes /hello/frenk → "hello frenk" a two-node policy. Docs
⚠️ Breaking changes
Provider-backed auth plugins now distinguish the identity provider failed from the credential was rejected. A failing IdP answers 502 {"error":"provider_error"} out the node's error port, with no WWW-Authenticate challenge:
| Plugin | Was | Now |
|---|---|---|
openid-connect |
401 {"error":"unauthorized"} |
502 {"error":"provider_error"} |
cas-auth |
401 {"error":"unauthorized"} |
502 {"error":"provider_error"} |
ldap-auth |
401 + WWW-Authenticate: Basic |
502 {"error":"provider_error"} |
authz-keycloak |
403 {"error":"access_denied"} |
502 {"error":"provider_error"} |
authz-casdoor |
403 {"error":"access_denied"} |
502 {"error":"provider_error"} |
The old behavior was actively misleading: ldap-auth re-prompted the browser for a password that had never been checked, because the directory was down. Error code LDAP_AUTH_FAILED is renamed LDAP_AUTH_PROVIDER_ERROR; every other code is unchanged. Clients, alerts or dashboards keying on the old statuses need updating — each plugin page carries a callout.
Rejected saves and error-port exits are visible
This came out of a live demo: an openid-connect node kept answering in bearer mode because the save that made it interactive had been rejected by the Admin API — the last-good config kept running, and nothing said so beyond a five-second toast. Now apply_gateway logs every rejected candidate at WARN with the reason (UI, file watcher and etcd drivers alike), error-port exits are logged with policy, node, code and message without needing debug traces, and the UI keeps a persistent notification log instead of a toast that vanishes.
Operational notes
admin.mcpis off by default and, like everything insystem.yaml, restart-gated — nothing changes for an existing deployment until you enable it. Tokens must be at least 16 characters; generate them withopenssl rand -base64 32.run_sandboxsits atreadscope but executes real plugins whendebug.enabledis set. Setdebug.sandbox: falseto keep tracing without that capability, and don't hand read tokens to untrusted agents.- New metrics:
featherbit_acme_cert_state,featherbit_acme_cert_not_after_timestamp_seconds,featherbit_acme_renewals_total,featherbit_acme_last_renewal_attempt_timestamp_seconds. - Behind a reverse proxy that rewrites
Host, either preserve the original or list the public origin inadmin.mcp.allowed_origins— the UI's chat relies on the same-origin exemption.
Improvements & housekeeping
- Every plugin page now documents its errors: an
Errorssection on all 87 pages, 23 with full| Code | Status | When |tables, enforced by tests that fail if a plugin can emit a code its page does not document. - Plugin registration is guarded end to end: factory, catalog, palette category, icon, docs page and sidebar each have a test, with an "Adding a node type" checklist in
CLAUDE.md. A plugin that works in YAML but is invisible to the MCP tools and the UI palette is now a build failure, not a surprise. - The Lua runtime accepts the shapes scripts naturally write — scalar header values, nil bodies, numeric or string status codes — and names the offending field instead of an opaque
LUA_UNMARSHAL_ERROR. reload_configrefuses to silently discard unsaved live edits, listing what differs, unless called withdiscard_unsaved: true.- CI additions: Pebble-gated ACME live tests, and e2e coverage for MCP scopes over HTTP, the Agent panel and the chat panel driving real MCP tools.
Full changelog: v0.7.0...v0.8.0
featherbit 0.7.0
This release gives the gateway shared state where it earns its keep: named redis/valkey stores power cluster-accurate rate limiting and opt-in server-side sessions — revocable, sealed at rest, with lock-coordinated token refresh — plus the admin UI, e2e coverage, and a CI matrix that proves both backends for real.
Highlights
Shared stores (stores:)
A new top-level gateway.yaml resource: named redis/valkey connections (type: valkey is a first-class alias), declared once and referenced by plugin config. Connections are lazy and reused across reloads; ${ENV} placeholders stay raw in stored config and resolve only when the client is built, so the Admin API/UI never serve resolved secrets. Managed via GET/POST/PUT/DELETE /api/stores with a referrer-guarded delete (409 in_use naming every referrer) and POST /api/stores/:name/ping (latency + server version). etcd-synced as a sixth key family. Behind the default-on redis-store cargo feature. Docs
Cluster-accurate rate limiting (policy: redis)
limit-count (and the workflow limit-count action) count in a shared store via one atomic server-side script: window boundaries are wall-clock aligned, so every instance agrees on them. allow_degradation picks fail-open vs reject on backend outage (limit-count only; the workflow action always rejects). Errors surface in gateway_counter_store_errors_total{store}.
Server-side sessions (session.storage: redis)
All five interactive auth plugins — openid-connect, cas-auth, authz-casdoor, dingtalk-auth, feishu-auth — can move sessions server-side: the cookie shrinks to a bare 128-bit id and the payload is sealed (AES-256-GCM) at rest under it. That buys revocation: GET /api/sessions lists metadata (never payloads), DELETE /api/sessions/:store/:id kills one session, DELETE /api/sessions?store=&subject= is logout-everywhere. A store outage is always a 503 (SESSION_STORE_ERROR) on the node's error port — never a silent 401 or fail-open. Cookie mode stays the default, byte-identical, and deliberately unrevocable. Keys are Redis-Cluster hash-tagged from day one.
Lock-coordinated token refresh (openid-connect)
In redis mode (session.refresh, default on), expiring access tokens are refreshed against the IdP under a short store lock: one instance wins and rewrites the session in place, the rest reuse its result — no thundering herd. A failed refresh falls back to re-login, never a 503.
dingtalk-auth / feishu-auth: session mode restored
Both ports regain their upstream session behavior (dropped when the gateway was strictly stateless): session.secret enables it, unauthenticated requests 302 to redirect_uri, and the code exchange establishes a session then redirects to the code-stripped URL with the cookie. Works with both cookie and redis storage. Stateless mode (no session.secret) is unchanged.
Breaking change: dingtalk-auth and feishu-auth now declare the interactive port set — the redirect outcome port is mandatory-wired. Existing policies with these nodes must add one edge (e.g. dingtalk.redirect → client.in) or fail compilation.
Admin UI: Stores editor & Sessions panel
A Stores sidebar section (create/edit/delete, raw placeholders round-tripped verbatim, one-click Ping) and a Sessions panel (list/filter by store & subject, per-row revoke, revoke-all-for-subject, graceful notice on headless builds). Store picker dropdowns appear on limit-count and all five session plugins' config forms.
Operational notes
- etcd clusters: upgrade all gateway instances together — an older binary sharing the etcd prefix garbage-collects the new
stores/key family on its next commit. - Two new metrics:
gateway_counter_store_errors_total{store},gateway_session_store_errors_total{store}. - CI now runs the live-store test suite against redis:7 and valkey/valkey:8 service containers; the e2e job exercises the full redis-backed login/revocation flow (
E2E-SESS-*).
Improvements & housekeeping
- Store delete-guard recognizes references in workflow rules and both flat/nested session keys.
- e2e suite at 136 scenarios (133 hermetic + 3 redis-gated).
- Docs: new Shared Stores & Sessions concept page; admin API reference, five plugin pages, and the observability metrics table updated.
Full changelog: v0.6.0...v0.7.0
featherbit 0.6.0
This release makes supernodes first-class citizens of the port paradigm: definitions now declare arbitrary named output and error ports, the editor can turn any selection into a supernode with one click, and shared plugin configs got major quality-of-life upgrades. Plus a security fix for env-placeholder leaking.
Highlights
Supernodes: named output ports
A definition may declare any number of type: output boundary nodes — each node's id becomes a port the instance exposes in policies, exactly like a plugin's outcome ports (the output-id node keeps mapping to success, so existing definitions are untouched). An auth supernode can finally expose denied as its own exit. Every output-derived instance port is mandatory-wired, compiler-enforced. Docs
Breaking change: definitions whose output boundary is unreachable previously compiled with the instance's success port unwired; that port must now be wired like any mandatory port.
Supernodes: named error ports
Error boundaries are plural and nameable too: each type: error node's id is an error-kind port (optional wiring, as always). The error-id boundary is the default — the black-box rule routes unhandled inner errors through it, and renaming or deleting it removes that implicit wiring. Docs
Extract selection as supernode
Select two or more nodes on a policy canvas and turn them into a reusable supernode — toolbar button, right-click, or Ctrl+K. Entry, per-exit output ports, and per-target error ports are derived automatically; the definition lands in the library and the selection is replaced by a wired instance. Boundary ports can be added, renamed, and deleted in the supernode editor (never below one output and one error). Docs
Shared configs: inline inheritance & extract-from-node
Selecting a shared config now shows inherited values directly in the form with overrides shared/added flags and one-click reset; a new "Save as shared config" button extracts a node's effective config into the library and re-links the node — traffic-identical before and after. (#26)
Fixes
- Security — stored config no longer resolves env placeholders (#25):
${VAR}secrets stayed resolved in the stored config, leaking through Admin API reads, YAML export, and etcd seeding. Interpolation now happens at point of use; the stored form keeps placeholders. conditionevaluates absent variables leniently (#24): a comparison over a missing variable now branchesfalseinstead of erroringCONDITION_UNCHECKABLE; existence tests unchanged.
Improvements & housekeeping
- Unrestricted fan-in, no cycles (#23): any number of edges may converge on any node — editor and compiler now agree — and cyclic policies are rejected at draw time and compile time.
- CycloneDX SBOMs (#22): per-component SBOMs (gateway binary, embedded UI, docs site, e2e) published with each build.
- e2e: new supernode scenarios (named ports end-to-end, editor extraction flow); suite at 129 scenarios.
Full changelog: v0.5.0...v0.6.0