Skip to content

Releases: Rwx-G/Lorica

Lorica v1.5.13

Choose a tag to compare

@github-actions github-actions released this 08 Jul 09:51
8d7ef9a

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

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 16 Jun 19:56
e5326f7

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

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 10 Jun 13:08
7e47d1b

Fixed

  • Cron-scheduled load tests now run in supervisor/worker mode: the supervisor created a LoadTestEngine for 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 shared startup::start_load_test_engine helper 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

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 10 Jun 11:56
184e635

Fixed

  • DELETE /api/v1/logs and DELETE /api/v1/waf/events no longer share one logs_clear rate-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.overrides postcss security floor (>=8.5.10) in lorica-dashboard/frontend/package.json; its absence (the lockfile still carried it) made pnpm install --frozen-lockfile fail 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 --build rebuilds 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 into cli.rs + startup/{mod, supervisor, worker, single}.rs with main() reduced to ~70 LOC of dispatch (backlog #8). Before the split, the duplicated background-task wiring across run_supervisor / run_worker / run_single_process was deduplicated into shared startup helpers (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 keeping LoricaProxy and the ProxyHttp impl (backlog #7). Beforehand, request_filter (~1700 LOC, cyclomatic ~70) was decomposed into 22 documented check_* stage methods (audit H-8), and the 18 duplicated terminal error-response blocks collapsed onto one write_error_response helper whose extra_headers parameter makes the previously drifting Retry-After / X-RateLimit-Reset sites first-class (audit H-10). The public lorica::proxy_wiring::* paths are preserved via re-exports.

  • Circuit-breaker lookups no longer allocate two String keys per call: the composite-key map becomes nested per-route / per-backend maps so is_available / record_* run on &str borrows with zero steady-state allocations (v1.5.9 perf audit #41b, closing the last open item of that pass). State-machine semantics unchanged.

  • update_settings field 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.md is rewritten as a crate-responsibility map at a granularity that does not drift per release, covering the new cli/startup and proxy_wiring layouts (#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.rs helpers db_blocking (ConfigStore, acquires the cross-task mutex as an owned guard then spawn_blocking) and log_db_blocking (LogStore) replace ~100 inline store.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 to busy_timeout 5 s) now stalls one blocking-pool thread instead of the tokio reactor serving every other request. The six hand-rolled v1.5.2 M-7 spawn_blocking sites collapse onto the same helpers.

  • Bot-protection challenge stash (SQLite backend) moved off the async hot path (backlog #30, audit H-2): BotEngine insert / 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 INSERT per 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 new lorica_log_write_dropped_total{kind="access|waf"} metric; batch persistence failures keep the lorica_waf_event_persist_failed_total guarantee from v1.5.1 L-6. Multi-rule WAF matches now cost one queue push each instead of one mutex round-trip + INSERT each.

  • EwmaTracker scores moved from a single global parking_lot::RwLock<HashMap> to a sharded DashMap (backlog #40, audit L-17): per-sample record calls 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 String keys 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::evaluate acquires 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 into RequestCtx.client_ip_addr and reused by the WAF whitelist, IP blocklist, GeoIP, and bot-protection checks; (g) apply_worker_generic_counters builds its per-worker delta batch outside the supervisor snapshot lock and holds it only for the delta math, so /metrics scrapes are no longer starved during worker-report application. Item (b) (circuit-breaker key interning) is deferred to the #7 proxy_wiring split where RouteEntry/RequestCtx get 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

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 10 Jun 01:03
d62bfd4

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 any x-* 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/journal readable by the systemd-journal group long after the one-time login (CWE-532). It is now written to /var/lib/lorica/initial-admin-password with 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. .deb and .rpm post-install messages and the install scripts point at the file. must_change_password=true still forces rotation on first login.
  • Removed the permissive CORS configuration from the management API. The router used AllowOrigin::mirror_request() with allow_headers(Any) (lorica-api/src/server.rs), reflecting any Origin back (CWE-942). It was inert today (the session cookie is SameSite=Strict and 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 with and_then(|s| from_str(&s).ok()) in row_to_route (lorica-config/src/store/row_helpers.rs), silently degrading a malformed blob to None. For a security field that means a route meant to enforce a protection (mTLS, rate limit) silently served it disabled. A new parse_optional_json_field helper keeps None for 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=yes to the systemd unit (dist/lorica.service), a mandatory directive from the unit hardening checklist that was missing. It blocks syslog(2) and /proc/kmsg, /dev/kmsg reads so a post-RCE process cannot harvest kernel log lines (KASLR offsets, pointer leaks) to chain a local privilege escalation.
  • Aligned the cargo deny advisory ignore list with cargo audit. deny.toml had ignore = [] while .cargo/audit.toml ignored the three route53-only rustls-webpki advisories (RUSTSEC-2026-0098/0099/0104), so cargo deny check would fail on advisories cargo audit correctly passes; the three IDs are now mirrored into deny.toml with a pointer to the authoritative rationale. Floored the lettre requirement at 0.11.22 in lorica-notify/Cargo.toml (the RUSTSEC-2026-0141 fix) so no resolve can drop below it, and refreshed the stale aws-smithy-http-client version 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_blocked runs per request) but used std::sync::RwLock with .expect() on poisoning at eleven sites; a writer panicking mid-reload would poison the lock and panic every subsequent request on that worker. Switched to parking_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 via from_utf8_lossy().into_owned(); it now uses from_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 a Vec<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

Choose a tag to compare

@github-actions github-actions released this 05 Jun 13:15
0912f5f

What's Changed

Full Changelog: v1.5.7...v1.5.8

Lorica v1.5.7

Choose a tag to compare

@github-actions github-actions released this 18 May 19:22
5fc017e

What's Changed

Full Changelog: v1.5.6...v1.5.7

v1.5.6

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 10 May 20:23
984fd3f

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

  • ConsistentHash load-balancing strategy was a silent no-op: a route configured with load_balancing = "consistent_hash" behaved exactly like round_robin, the operator's intent for client-IP-based session stickiness was discarded server-side. Root cause: the selection match in proxy_wiring::upstream_peer (lorica/src/proxy_wiring.rs) handled PeakEwma, Random, LeastConn explicitly, then routed both RoundRobin and ConsistentHash through 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 to consistent_hash to 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::ConsistentHash arm now feeds ctx.client_ip (already populated in request_filter, honouring X-Forwarded-For when trusted_proxies says 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's points-per-bucket = weight * 160 so a backend with weight=2 gets twice the slice of the keyspace. Backend addresses that do not parse as SocketAddr (Unix-domain backends prefixed with unix:) 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 when ctx.client_ip is None. Sticky-session cookies still take precedence: the existing sticky_backend_idx short-circuit fires before the load-balancing match, so a route with both sticky_session = true AND load_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 in proxy_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 like wrr_state), (b) UDS backends are silently excluded from the hash key space, matching the upstream lorica-lb/src/selection/consistent.rs behaviour - 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 running round_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 via dist/build-deb.sh
  • .rpm (RHEL / Rocky / Alma, x86_64): built via dist/rpm/lorica.spec
  • Source tarball: auto-generated by GitHub from the tag

Verification (operator-side after deploy)

  1. Identify a route configured with load_balancing = "consistent_hash" (or temporarily configure one for the test with two backends).
  2. 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_total Prometheus counter).
  3. 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.
  4. Confirm previously-working round_robin / peak_ewma / least_conn / random routes still behave as before.

Full changelog

Compare v1.5.5...v1.5.6

v1.5.5

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 10 May 15:12
7b7094e

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_rewrite enabled silently buffered every response whose Content-Type matched the rewrite filter (default text/) until either end_of_stream or max_body_bytes overflow. 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's EventSource open the connection successfully but no message event 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 matches text/event-stream ; the matching response_body_filter (lorica/src/proxy_wiring.rs:3441-3470) suppressed every chunk and only emitted the rewritten body at end_of_stream. The same trap applied to operators who configured application/ as a prefix (matches application/grpc, where buffering corrupts gRPC's length-prefixed framing in addition to stalling) or multipart/ (matches multipart/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 whose Content-Type starts with text/event-stream, multipart/x-mixed-replace, or application/grpc (covering plain gRPC, gRPC-Web, gRPC-Web-Text, and application/grpc+proto) is unconditionally streamed through verbatim, regardless of the route's content_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 by starts_with after lowercasing, so any RFC-conformant variant is caught. Tests : 4 new unit tests in proxy_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/json still 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-net and hickory-proto from 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 via hickory-resolver 0.26.0 (DNS resolution path used by the proxy upstream resolver). Caught by CI cargo audit on 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_rewrite continues to rewrite the same text/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 via dist/build-deb.sh
  • .rpm (RHEL / Rocky / Alma, x86_64) : built via dist/rpm/lorica.spec
  • Source tarball : auto-generated by GitHub from the tag

Verification (operator-side after deploy)

  1. Identify any route with response_rewrite enabled that proxies an SSE / gRPC / MJPEG upstream (or temporarily configure one for the test).
  2. From a client, open the SSE endpoint (curl -N https://your-route/sse-path or a browser EventSource).
  3. Confirm event: / data: lines arrive in real time (not after a long stall).
  4. Confirm previously-working text/html / application/json rewrite rules still apply : trigger a regular request to a rewrite-covered route and verify the body is rewritten as before.

Full changelog

Compare v1.5.4...v1.5.5

v1.5.4

Choose a tag to compare

@Romain-Grosos Romain-Grosos released this 29 Apr 20:06
37c07a0

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 hostname or any of its hostname_aliases. Pre-fix the mismatch was only visible at runtime as a TLS handshake failure (SSL_ERROR_BAD_CERT_DOMAIN browser-side, unknown certificate worker-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.com covers exactly one subdomain label (so pki.example.com is in, a.b.example.com and the bare apex example.com are out), partial-label wildcards like pki*.example.com rejected as ambiguous (Chrome / Firefox both reject these). The warning is rendered in the existing warn-banner style 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. New lib/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 : the CertificateResponse.san_domains field 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 via dist/build-deb.sh
  • .rpm (RHEL / Rocky / Alma, x86_64) : built via dist/rpm/lorica.spec
  • Source tarball : auto-generated by GitHub from the tag

Verification (operator-side after deploy)

  1. Open the dashboard, edit any route that has a TLS certificate selected.
  2. Switch to a certificate whose SAN list does NOT cover the route hostname (or temporarily change the hostname).
  3. Confirm the orange warn banner appears under the TLS Certificate select with the offending hostname named.
  4. Confirm the Save button remains enabled (the warning is informational, not blocking).
  5. Switch back to a covering cert ; the banner clears in real time.

Full changelog

Compare v1.5.3...v1.5.4