Skip to content

Releases: featherbitplatform/gateway

featherbit 0.12.1

Choose a tag to compare

@francescodedomenico francescodedomenico released this 24 Sep 11:04
a9ad0e0

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 upstream port: ${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 (new numberField tests)
  • 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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 24 Sep 08:52
36cef8f

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:admin whenever 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 under localStorage['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-Client get a 401 without WWW-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/status before 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/status reports 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_version and 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 when admin.tls also sets its own cert_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.yaml on 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: true or its own certificate), or bind admin to 127.0.0.1. Either change it from the shipped admin/admin or set ADMIN_USER/ADMIN_PASSWORD.

PRs: #69, #70, #71, #72.

featherbit 0.11.0

Choose a tag to compare

@francescodedomenico francescodedomenico released this 21 Sep 06:29

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 script node must wire respond. script declares a respond outcome port, and outcome ports are mandatory wiring, so a policy with a script node fails to compile until it gains an edge from <id>.respond (to client, 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 the redirect port on dingtalk-auth/feishu-auth.
  • serverless-pre-function / serverless-post-function: a second return value from execute used to be silently ignored; it is now LUA_BAD_PORT unless it is "success". These nodes declare no outcome port — a script that must answer the request belongs in a script node.
  • LUA_UNMARSHAL_ERROR is now the code for a script whose execute returns something that is not a table (was LUA_EXECUTION_ERROR). Observable in error-handler templates 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
end

Until 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.in to every script node 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-stores example 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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 19 Sep 19:02

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_count

store-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_traces defaults to 1000 (was 50). Debug mode is off by default, so only deliberate users are affected.
  • connect_budget_ms defaults 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_entries defaults to 10,000. A policy: local cache that was previously unbounded is now capped; raise it if your working set is larger.
  • A script whose top level legitimately takes longer than timeout_ms to load now fails to compile. Pathological at the 5000ms default; raise the budget or move the work into execute.
  • 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-cache half 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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 15 Sep 16:50

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 a response-rewrite with filters. 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}}, a response_body:$... JSONPath) sees an empty body on a streaming route. Affects traffic-label matchers, response-rewrite vars gates, proxy-rewrite response-phase headers, and request-id header_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-Buffering remains 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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 15 Sep 07:00

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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 14 Sep 22:22

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 compose example stacks under examples/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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 13 Sep 20:26

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.mcp is off by default and, like everything in system.yaml, restart-gated — nothing changes for an existing deployment until you enable it. Tokens must be at least 16 characters; generate them with openssl rand -base64 32.
  • run_sandbox sits at read scope but executes real plugins when debug.enabled is set. Set debug.sandbox: false to 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 in admin.mcp.allowed_origins — the UI's chat relies on the same-origin exemption.

Improvements & housekeeping

  • Every plugin page now documents its errors: an Errors section 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_config refuses to silently discard unsaved live edits, listing what differs, unless called with discard_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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 24 Aug 10:01

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

Choose a tag to compare

@francescodedomenico francescodedomenico released this 20 Aug 21:44

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.
  • condition evaluates absent variables leniently (#24): a comparison over a missing variable now branches false instead of erroring CONDITION_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