Releases: Rwx-G/Lorica
Release list
Lorica v1.5.13
What's Changed
- fix(security): cert-export chown no longer crashes the proxy via seccomp SIGSYS (v1.5.13) by @Romain-Grosos in #21
Full Changelog: v1.5.12...v1.5.13
v1.5.12
In-place ACME certificate renewal: a renewed certificate keeps its route binding, fixing the case where a domain was re-requested every cycle until Let's Encrypt rate-limited the account.
Fixed
- ACME certificate auto-renewal now updates the existing certificate in place (same id) instead of issuing a new certificate, reassigning routes, then deleting the old one. The previous insert-then-reassign design could leave the renewed certificate unbound while the route kept pointing at the old certificate, so the same domain was re-requested every cycle until Let's Encrypt rate-limited the account. Route bindings now survive a renewal untouched, aligning with how Caddy, Traefik and certbot replace a certificate in place.
- The auto-renewal loop no longer renews certificates that are not referenced by any route, so orphaned or duplicate certificates can no longer consume Let's Encrypt issuance quota and snowball into a rate-limit.
- On startup, superseded orphan ACME certificates (unbound and replaced by a newer certificate for the same identifier set) are purged. Bound and unique certificates are never touched.
- The renewal loop now backs off on a Let's Encrypt rateLimited error until the returned retry-after time instead of re-attempting every cycle.
Security
- The cooldown parsed from a Let's Encrypt retry-after response is clamped to a 7 day maximum, so a malformed or hostile ACME response cannot suspend auto-renewal long enough for the live certificate to expire.
Full changelog: https://github.com/Rwx-G/Lorica/blob/v1.5.12/CHANGELOG.md
v1.5.11
Fixed
- Cron-scheduled load tests now run in supervisor/worker mode: the supervisor created a
LoadTestEnginefor the management API but never started the cron scheduler, so scheduled load tests silently never executed outside single-process mode. Both API-serving modes now go through one sharedstartup::start_load_test_enginehelper that creates the engine and starts the scheduler together, making the asymmetry unrepresentable (Epic 8 Story 8.1 finding). - Notification-channel edits hot-reload in single-process mode: the config-reload listener now rebuilds the notification dispatcher from the persisted channel configs on every reload signal, mirroring supervisor mode. Previously single-process deployments kept dispatching alerts to the boot-time channel list until restart (Epic 8 Story 8.1 finding).
- The e2e suite teardown now passes every compose profile to
down -v, so profile-gated containers and volumes (workers, cert-export, smoke profiles) no longer survive a run and leak stale state into the next one (Epic 8 Story 8.1 finding, extended to all seven current profiles).
v1.5.10
Fixed
DELETE /api/v1/logsandDELETE /api/v1/waf/eventsno longer share onelogs_clearrate-limit bucket: clearing both forensics surfaces within a minute used to 429 the second wipe silently. Each endpoint keeps its own 1/min cap, so the L-6 protection (a stolen session cookie cannot flush a trail faster than once a minute) is unchanged per trail.- The forensics wipes are strongly consistent with the new background log writer: both clear endpoints drain the writer queue through a flush barrier before the
DELETE, so rows that were in flight at wipe time can no longer be persisted right after it and resurrect pre-wipe traffic. - Restored the
pnpm.overridespostcss security floor (>=8.5.10) inlorica-dashboard/frontend/package.json; its absence (the lockfile still carried it) madepnpm install --frozen-lockfilefail under pnpm 10 and broke every e2e image rebuild. - The e2e Docker suite is runnable again (entrypoints read the v1.5.9 password file instead of parsing stdout,
run.sh --buildrebuilds the runner images, the failover test polls through the v1.5.8 flip-hysteresis window). Full suite green: 340/340 single-process, worker isolation, cert-export smoke.
Changed
-
lorica/src/main.rs(4329 LOC) is split intocli.rs+startup/{mod, supervisor, worker, single}.rswithmain()reduced to ~70 LOC of dispatch (backlog #8). Before the split, the duplicated background-task wiring acrossrun_supervisor/run_worker/run_single_processwas deduplicated into sharedstartuphelpers (alerting + SLA + probes stack, API-server tail including the ACME renewal and cert-expiry spawns, retention loop, health check, GeoIP/ASN/rDNS handle init), so a future mode cannot silently miss a spawn the way the v1.5.2 worker-mode cert-hotswap bug did (audit H-9). -
lorica/src/proxy_wiring.rs(5813 LOC) is split into focused submodules:config.rs(route tables + WRR),lb.rs(EWMA + circuit breaker),context.rs(RequestCtx),filters.rs(request_filter stages),worker_rpc.rs(two-phase reload + metrics RPC), with the module root keepingLoricaProxyand theProxyHttpimpl (backlog #7). Beforehand,request_filter(~1700 LOC, cyclomatic ~70) was decomposed into 22 documentedcheck_*stage methods (audit H-8), and the 18 duplicated terminal error-response blocks collapsed onto onewrite_error_responsehelper whoseextra_headersparameter makes the previously driftingRetry-After/X-RateLimit-Resetsites first-class (audit H-10). The publiclorica::proxy_wiring::*paths are preserved via re-exports. -
Circuit-breaker lookups no longer allocate two
Stringkeys per call: the composite-key map becomes nested per-route / per-backend maps sois_available/record_*run on&strborrows with zero steady-state allocations (v1.5.9 perf audit #41b, closing the last open item of that pass). State-machine semantics unchanged. -
update_settingsfield validation is centralised into documented helpers instead of 33 copy-pasted if-let blocks; bounds and error messages are byte-for-byte identical, with NOTE comments marking the historical bound inconsistencies for the next audit instead of silently normalising them (audit H-11, backlog #34). -
docs/architecture/source-tree.mdis rewritten as a crate-responsibility map at a granularity that does not drift per release, covering the newcli/startupandproxy_wiringlayouts (#42f). -
Every SQLite access from async management-API handlers now runs on the blocking thread pool (backlog #31, audit H-3 + M-16). New
lorica-api/src/db.rshelpersdb_blocking(ConfigStore, acquires the cross-task mutex as an owned guard thenspawn_blocking) andlog_db_blocking(LogStore) replace ~100 inlinestore.lock().await+ synchronous rusqlite call sites across certificates, backends, routes, settings, SLA, probes, load tests, DNS providers, WAF, config import/export, status, auth, cert-export, the ACME subsystem (provision, renewal loop, expiry check), and the session store (login persistence, session DB fallback, logout, renew, GC). A contended SQLite WAL (up tobusy_timeout5 s) now stalls one blocking-pool thread instead of the tokio reactor serving every other request. The six hand-rolled v1.5.2 M-7spawn_blockingsites collapse onto the same helpers. -
Bot-protection challenge stash (SQLite backend) moved off the async hot path (backlog #30, audit H-2):
BotEngineinsert / take / captcha-image / prune / len now run the SQL on the blocking pool via an owned store guard. Under bot flood the challenge issuance rate equals the WAF-block rate; one slow WAL fsync no longer stalls every async task on the shared store mutex. -
Access-log and WAF-event persistence is decoupled from the request path through a bounded queue + dedicated writer thread with batched transactions (backlog #24, audit M-8). The hot path does a non-blocking enqueue (one clone) instead of a synchronous
INSERTper request (and per matched WAF rule); the writer drains up to 256 rows into one transaction (one WAL fsync per batch instead of one per row). On overflow entries are shed and counted in the newlorica_log_write_dropped_total{kind="access|waf"}metric; batch persistence failures keep thelorica_waf_event_persist_failed_totalguarantee from v1.5.1 L-6. Multi-rule WAF matches now cost one queue push each instead of one mutex round-trip + INSERT each. -
EwmaTrackerscores moved from a single globalparking_lot::RwLock<HashMap>to a shardedDashMap(backlog #40, audit L-17): per-samplerecordcalls from concurrent worker threads and the health prober contend per shard instead of serialising on one write lock. Selection semantics are unchanged (bounded explore/exploit from v1.5.8 preserved). -
Hot-path allocation and lock-scope pass (backlog #41, v1.5.9 perf-auditor lot 5): (a) smooth-WRR backend selection no longer allocates 2N
Stringkeys per request inside its mutex (zero allocations once backends are seen); (c) the WAF event ring buffer no longer clones events while holding the buffer lock (clone first, push owned values under a short lock); (d)WafEngine::evaluateacquires the custom-rules read lock once per request instead of N+1 times; (e) response-rewrite content-type checks are fully allocation-free via an ASCII case-insensitive prefix compare (no more per-response lowercasing of the header and the configured prefix list); (f) the client IP is parsed once per request intoRequestCtx.client_ip_addrand reused by the WAF whitelist, IP blocklist, GeoIP, and bot-protection checks; (g)apply_worker_generic_countersbuilds its per-worker delta batch outside the supervisor snapshot lock and holds it only for the delta math, so/metricsscrapes are no longer starved during worker-report application. Item (b) (circuit-breaker key interning) is deferred to the #7proxy_wiringsplit whereRouteEntry/RequestCtxget restructured. No benchmark figures were captured for this pass; the changes are allocation-count and lock-scope reductions verified by review and the regression suite.
v1.5.9
Security
- WAF now inspects every request header, not a fixed allowlist. Header collection in
request_filter(lorica/src/proxy_wiring.rs) scanned only nine named headers (user-agent,referer,cookie,x-forwarded-for,content-type,content-length,authorization,origin,transfer-encoding) plus anyx-*header. Any other attacker-controllable header that a backend trusts (Forwarded,True-Client-IP,Profile,SOAPAction, application-specific headers) carried SQLi/XSS/traversal payloads straight to the upstream with no WAF event logged, a silent bypass for header-borne injection on Blocking-mode routes. Collection now scans every header whose value is valid UTF-8 (binary values are still skipped); the Aho-Corasick prefilter keeps clean traffic cheap. - The first-run admin password is no longer written to the systemd journal. It was printed to stdout, which the service captures via
StandardOutput=journal, leaving the bootstrap credential in/var/log/journalreadable by thesystemd-journalgroup long after the one-time login (CWE-532). It is now written to/var/lib/lorica/initial-admin-passwordwith mode 0600 and only the path is printed; if the file write fails the password still falls back to stdout so the sole copy is never lost..deband.rpmpost-install messages and the install scripts point at the file.must_change_password=truestill forces rotation on first login. - Removed the permissive CORS configuration from the management API. The router used
AllowOrigin::mirror_request()withallow_headers(Any)(lorica-api/src/server.rs), reflecting anyOriginback (CWE-942). It was inert today (the session cookie isSameSite=Strictand the API binds to loopback) but became a credential-bearing cross-origin hole the moment credentials or a non-loopback front-end were added. The CORS layer is removed entirely: the dashboard is served same-origin/same-port, so cross-origin requests are never part of normal operation; the browser now default-denies them and same-origin requests are unaffected. - Config loading fails closed on a corrupt security blob. The optional JSON columns on a route (
mtls,rate_limit,geoip,bot_protection,forward_auth,mirror,response_rewrite) decoded withand_then(|s| from_str(&s).ok())inrow_to_route(lorica-config/src/store/row_helpers.rs), silently degrading a malformed blob toNone. For a security field that means a route meant to enforce a protection (mTLS, rate limit) silently served it disabled. A newparse_optional_json_fieldhelper keepsNonefor an absent column (bisect resilience) but returns an error for a present-but-malformed blob; the two-phase config reload keeps the previously committed config active on error rather than serving the route unprotected. - Added
ProtectKernelLogs=yesto the systemd unit (dist/lorica.service), a mandatory directive from the unit hardening checklist that was missing. It blockssyslog(2)and/proc/kmsg,/dev/kmsgreads so a post-RCE process cannot harvest kernel log lines (KASLR offsets, pointer leaks) to chain a local privilege escalation. - Aligned the
cargo denyadvisory ignore list withcargo audit.deny.tomlhadignore = []while.cargo/audit.tomlignored the three route53-onlyrustls-webpkiadvisories (RUSTSEC-2026-0098/0099/0104), socargo deny checkwould fail on advisoriescargo auditcorrectly passes; the three IDs are now mirrored intodeny.tomlwith a pointer to the authoritative rationale. Floored thelettrerequirement at0.11.22inlorica-notify/Cargo.toml(the RUSTSEC-2026-0141 fix) so no resolve can drop below it, and refreshed the staleaws-smithy-http-clientversion reference in the audit rationale (1.1.12 -> 1.1.13).
Fixed
- The WAF IP blocklist no longer panics on lock poisoning.
IpBlocklist(lorica-waf/src/ip_blocklist.rs) sits on the hot path (is_blockedruns per request) but usedstd::sync::RwLockwith.expect()on poisoning at eleven sites; a writer panicking mid-reload would poison the lock and panic every subsequent request on that worker. Switched toparking_lot::RwLock(already a crate dependency), which does not poison, removing the panic surface entirely.
Changed
- Dropped the per-request clean-path WAF log and trimmed two hot-path allocations. The
info!("WAF evaluation passed")line fired for every passing request at info level (the systemd default) with no operator value beyond the existing Prometheus latency metric, and is removed.url_decode_once(lorica-waf/src/engine/url_decode.rs) allocated unconditionally viafrom_utf8_lossy().into_owned(); it now usesfrom_utf8(), which reuses the buffer when the decoded bytes are already valid UTF-8 (the common case), keeping the lossy U+FFFD fallback only for the invalid path.should_rewrite_response(lorica/src/proxy_wiring/mirror_rewrite.rs) no longer builds aVec<String>of lowercased content-type prefixes per response: the empty-list common case compares against a static prefix with no allocation, and the configured case lowercases each prefix inline.
Lorica v1.5.8
Lorica v1.5.7
v1.5.6
v1.5.6 - ConsistentHash load balancing wired
A small reliability patch release. One bug fix, no new feature, no schema migration, no config change. Upgrade is a hot drop-in.
Fixed
-
ConsistentHashload-balancing strategy was a silent no-op: a route configured withload_balancing = "consistent_hash"behaved exactly likeround_robin, the operator's intent for client-IP-based session stickiness was discarded server-side. Root cause: the selectionmatchinproxy_wiring::upstream_peer(lorica/src/proxy_wiring.rs) handledPeakEwma,Random,LeastConnexplicitly, then routed bothRoundRobinandConsistentHashthrough the same_ =>catch-all that calls the smooth weighted round-robin selector. The original comment on that arm even acknowledged the gap ("covers RoundRobin and ConsistentHash") but no one wired the consistent path. Production shape: an operator who flipped a route toconsistent_hashto keep a given client IP pinned to one backend (typical use case: sticky cache or stateful upstream that does not share state across replicas) saw the request stream spread across all healthy backends like before, with no log line indicating the strategy had been silently downgraded. The dashboard, API, and config schema all accepted the value, so the only way to detect the bug was to compare actual upstream selection against the expected hash.Fix: a dedicated
LoadBalancing::ConsistentHasharm now feedsctx.client_ip(already populated inrequest_filter, honouring X-Forwarded-For whentrusted_proxiessays the immediate peer is trusted) into the Ketama hash ring (lorica-ketama::Continuum). The ring is built from each healthy backend's(address, weight), weight maps to ketama'spoints-per-bucket = weight * 160so a backend withweight=2gets twice the slice of the keyspace. Backend addresses that do not parse asSocketAddr(Unix-domain backends prefixed withunix:) are skipped at ring construction; if every healthy backend is UDS, the selection falls back to smooth weighted round-robin for that request rather than erroring 502. Same fallback applies whenctx.client_ipisNone. Sticky-session cookies still take precedence: the existingsticky_backend_idxshort-circuit fires before the load-balancingmatch, so a route with bothsticky_session = trueANDload_balancing = "consistent_hash"honours the cookie first and only falls through to the hash on the first request.Selection is extracted to
proxy_wiring::helpers::pick_consistent_hash(&[(addr, weight)], client_ip)so the pure path is unit-tested in isolation. Tests: 5 new inproxy_wiring::tests(same-client stickiness, distinct-clients spread,None/empty client IP falls back via caller, UDS-only pool falls back via caller, stable selection when an unselected backend is removed - the consistent-hash invariant vs hash-modulo-N).Known limitations carried as backlog for v1.6.0: (a) the ring is rebuilt per request (
O(n * 160 * weight); on a route with 5 backends and weight=1 each this is ~3 us measured cold, acceptable for a hotfix but a fixed cost worth caching against the route entry likewrr_state), (b) UDS backends are silently excluded from the hash key space, matching the upstreamlorica-lb/src/selection/consistent.rsbehaviour - making UDS hashable needs a different ketama variant.
Upgrade notes
- No schema migration, no config change.
- No operator action required for routes that did NOT use
consistent_hash: behaviour is unchanged. - Operator action recommended for any route that was configured with
load_balancing = "consistent_hash": before v1.5.6, that route was effectively runninground_robin. After upgrade it will start hashing on client IP, so the load distribution will change shape (the per-IP stickiness the operator originally asked for). If the upstream is stateless or session state is shared across backends, no further action; if the upstream depends on stickiness (cache locality, in-memory session, etc.) the v1.5.6 behaviour is the correct one. - A v1.5.5 -> v1.5.6 upgrade is a hot drop-in: stop the service, replace the binary / .deb / .rpm, restart. The SQLite store, certificates, configs, sessions all carry forward unchanged.
Artifacts
.deb(Ubuntu / Debian, amd64): built viadist/build-deb.sh.rpm(RHEL / Rocky / Alma, x86_64): built viadist/rpm/lorica.spec- Source tarball: auto-generated by GitHub from the tag
Verification (operator-side after deploy)
- Identify a route configured with
load_balancing = "consistent_hash"(or temporarily configure one for the test with two backends). - From a client at a fixed IP, send 10 requests to the route. All 10 must hit the same backend (visible in the access log per-backend counter, or
lorica_backend_requests_totalPrometheus counter). - Repeat from a second client IP. The second client should hit a backend, possibly the same possibly a different one depending on the hash; what matters is that it stays stable across its own 10 requests.
- Confirm previously-working
round_robin/peak_ewma/least_conn/randomroutes still behave as before.
Full changelog
v1.5.5
v1.5.5 - response_rewrite bypass for SSE / gRPC / multipart
A small reliability patch release. One bug fix, no new feature, no schema migration, no config change. Upgrade is a hot drop-in.
Fixed
- response_rewrite vs stream-by-design content-types: a route with
response_rewriteenabled silently buffered every response whoseContent-Typematched the rewrite filter (defaulttext/) until eitherend_of_streamormax_body_bytesoverflow. For SSE (text/event-stream) on a long-poll subscription, neither happens - the upstream keeps the connection open and emits chunks indefinitely - so the client received zero bytes for the lifetime of the stream. Production shape: a downstream app behind Lorica using SSE for an event push (/v1/events) saw the browser'sEventSourceopen the connection successfully but nomessageevent ever fired ; nginx / Traefik in the same role both passed the same workload through. Root cause:should_rewrite_response(lorica/src/proxy_wiring/mirror_rewrite.rs:177-200) defaulted the empty operator prefix list to["text/"], which matchestext/event-stream; the matchingresponse_body_filter(lorica/src/proxy_wiring.rs:3441-3470) suppressed every chunk and only emitted the rewritten body atend_of_stream. The same trap applied to operators who configuredapplication/as a prefix (matchesapplication/grpc, where buffering corrupts gRPC's length-prefixed framing in addition to stalling) ormultipart/(matchesmultipart/x-mixed-replace, MJPEG-style server-push). Fix: a hard-coded bypass list (STREAM_CONTENT_TYPE_PREFIXES) is checked before the operator's prefix match. Any response whoseContent-Typestarts withtext/event-stream,multipart/x-mixed-replace, orapplication/grpc(covering plain gRPC, gRPC-Web, gRPC-Web-Text, andapplication/grpc+proto) is unconditionally streamed through verbatim, regardless of the route'scontent_type_prefixes. The bypass cannot be overridden : opt-in to a protocol-breaking bug is not a feature offered. Charset-suffixed types (text/event-stream; charset=utf-8) and case variants (TEXT/EVENT-STREAM) match bystarts_withafter lowercasing, so any RFC-conformant variant is caught. Tests : 4 new unit tests inproxy_wiring::tests(SSE bypass under default empty prefix list, SSE bypass when operator explicitly opts in, multipart/x-mixed-replace bypass with boundary suffix, gRPC family +application/jsonstill rewriting on the same["application/"]prefix list) + 1 new e2e test (rewrite_does_not_buffer_sse_event_stream) that drives a streaming origin emitting three SSE chunks separated by 300ms and asserts the first chunk arrives at the client in well under 300ms (proves no per-body buffering) and the body is byte-for-byte preserved. Pre-fix the e2e test would block ~600ms and fail with a clear "proxy is buffering" message that names the regression.
Security
- RUSTSEC-2026-0119 + RUSTSEC-2026-0120 (hickory-dns): bumped
hickory-netandhickory-protofrom 0.26.0 to 0.26.1 (lockfile-only update). Two paired advisories on the same dep family : (a) NSEC3 closest-encloser proof validation enters an unbounded loop on cross-zone responses (hickory-net), (b) CPU exhaustion during message encoding due to O(n^2) name compression (hickory-proto). Both reach Lorica viahickory-resolver 0.26.0(DNS resolution path used by the proxy upstream resolver). Caught by CIcargo auditon this release branch ; lockfile-only resolution because the existing version constraints already accept 0.26.1.
Upgrade notes
- No schema migration, no config change.
- No operator action required: every existing route with
response_rewritecontinues to rewrite the sametext/html/application/json/ etc. responses it did before. Only the previously-broken stream content-types start working. - A v1.5.4 -> v1.5.5 upgrade is a hot drop-in : stop the service, replace the binary / .deb / .rpm, restart. The SQLite store, certificates, configs, sessions all carry forward unchanged.
Artifacts
.deb(Ubuntu / Debian, amd64) : built viadist/build-deb.sh.rpm(RHEL / Rocky / Alma, x86_64) : built viadist/rpm/lorica.spec- Source tarball : auto-generated by GitHub from the tag
Verification (operator-side after deploy)
- Identify any route with
response_rewriteenabled that proxies an SSE / gRPC / MJPEG upstream (or temporarily configure one for the test). - From a client, open the SSE endpoint (
curl -N https://your-route/sse-pathor a browserEventSource). - Confirm
event:/data:lines arrive in real time (not after a long stall). - Confirm previously-working
text/html/application/jsonrewrite rules still apply : trigger a regular request to a rewrite-covered route and verify the body is rewritten as before.
Full changelog
v1.5.4
v1.5.4 - cert SAN cross-check in the route editor
A small UX-only patch release. One feature, no Rust code change, no schema migration, no config change. Upgrade is drop-in.
Added
- Issue #11 (route editor / TLS cert SAN cross-check) : the Routing tab in the route drawer now flags a non-blocking warning when the selected certificate's SAN list does not cover the route's
hostnameor any of itshostname_aliases. Pre-fix the mismatch was only visible at runtime as a TLS handshake failure (SSL_ERROR_BAD_CERT_DOMAINbrowser-side,unknown certificateworker-side) ; an operator who picked the wrong cert would not learn about it until a real client tried to reach the route. The check runs RFC 6125 strict matching - the same rules Chrome / Firefox / Safari enforce on the live handshake - so a "covered" verdict in the editor predicts a successful handshake, and an "uncovered" verdict predicts a real failure rather than a guess that can diverge from the browser. Specifically : exact match (case-insensitive, trailing-dot tolerant), wildcard match where*.example.comcovers exactly one subdomain label (sopki.example.comis in,a.b.example.comand the bare apexexample.comare out), partial-label wildcards likepki*.example.comrejected as ambiguous (Chrome / Firefox both reject these). The warning is rendered in the existingwarn-bannerstyle under the cert select with the offending hostname(s) named and the cert's SAN list echoed for context. Save remains allowed - a wildcard or multi-SAN cert that the operator knows covers the host can still be saved without friction. Newlib/cert-san-match.ts(3 exports :hostnameMatchesSan,hostnameCoveredByAny,findUncoveredHostnames) + 23 vitest cases pinning the contract (exact, wildcard single-label, apex rejection, multi-label rejection, partial-label rejection, case-insensitive, trailing-dot, label-boundary straddle, empty inputs, deduplication). No backend change : theCertificateResponse.san_domainsfield already carried the SAN list since v1.5.3 SAN extraction. 343 frontend tests green (was 320).
Upgrade notes
- No schema migration, no config change.
- No new dependency.
- Rust binaries are unchanged in behaviour ; the bump is metadata-only on the Rust side.
- A v1.5.3 -> v1.5.4 upgrade is a hot drop-in : stop the service, replace the binary / .deb / .rpm, restart. The SQLite store, certificates, configs, sessions all carry forward unchanged.
Artifacts
.deb(Ubuntu / Debian, amd64) : built viadist/build-deb.sh.rpm(RHEL / Rocky / Alma, x86_64) : built viadist/rpm/lorica.spec- Source tarball : auto-generated by GitHub from the tag
Verification (operator-side after deploy)
- Open the dashboard, edit any route that has a TLS certificate selected.
- Switch to a certificate whose SAN list does NOT cover the route hostname (or temporarily change the hostname).
- Confirm the orange warn banner appears under the TLS Certificate select with the offending hostname named.
- Confirm the Save button remains enabled (the warning is informational, not blocking).
- Switch back to a covering cert ; the banner clears in real time.