Both Dockerfiles copied 0.1.0-SNAPSHOT jars that the reactor no longer produces
(version is ${revision}), so neither built a released version. They now resolve the
released qbit-document-renderer and qqq-remote-renderer-transport (and their runtime
libraries) at an exact, required RENDERER_VERSION / TRANSPORT_VERSION from the
QRun-IO Gitea Maven registry and Maven Central, refusing SNAPSHOT and local
(*forge*) versions. The reviewed hardening is unchanged: digest-pinned
BUILD_IMAGE/JAVA_IMAGE, non-root 65532, read-only-root friendly, Zint built from
vendor/. The reactor build context is no longer needed.
Adds IMAGE-PUBLISH.md (repeatable build/scan/publish steps and the 1.0.0 pins) and
image-pins.sh (prints the gg-forge-cd classpath and SHA256 pins from a published
image). Images for 1.0.0 were built, smoke-tested over mTLS and published to GHCR
under gg-engineering by hand; no workflow publishes images.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Optional remote renderer transport
This module connects GenerationPorts.IsolatedRenderer to a private mTLS HTTP endpoint.
The application needs no Docker executable/socket. It reuses the existing
IsolatedRendererBridge for current asset authorization, immutable asset hashes, pure-data
snapshots, request limits, barcode catalog/version checks, output hash/length checks and
independent PDF/ZPL output policy. No engine or business API is duplicated.
The reactor has two new artifacts:
qqq-remote-renderer-transport: fixed HTTP/TLS transport and standalone child-process service; JDK and existing Jackson only. It imports no QQQ, scripting, retained-file or printing implementation. It does not load a renderer engine into its JVM.qbit-document-generation-remote-renderer: optionalGenerationPortsadapter, depending on the existing renderer bridge and transport. The generation foundation is unchanged.
The reactor also builds the existing renderer bridge so the new public transport constructor is used from the same source tuple. No installation or adoption of any shared artifact is implicit. Existing Docker production construction and qualification checks remain intact.
Application configuration
Supply operator-owned SSLContext with client key and server trust. There is no insecure
fallback or default trust-all implementation. Construct a DeploymentIdentity containing
an explicit TRUSTED_LAB or QUALIFIED_DEPLOYMENT mode, the exact namespace, profile,
evidence, required evidence, and immutable image ID; evidence
must equal required evidence. Construct RemoteRendererClient.Configuration with HTTPS
endpoint /render, a resolved InetSocketAddress, the identity, and a 100–30000 ms deadline.
Resolve DNS during application startup/discovery, outside requests; DNS itself is not claimed
to be bounded. The URI hostname is independently checked against the server certificate even
when the connection uses a resolved service IP. No redirect, URL userinfo, query or fragment
is accepted. Rotate/recreate this operator configuration when service discovery changes.
Create RemoteRendererBridge(RendererLimits, AssetReader, Configuration) and register it
through the existing generation configuration's renderer port. Close the adapter at normal
application shutdown. RendererLimits has admissionIdentity, maximumBytes, maximumPages,
maximumConcurrent. Use one stable operator deployment identity across adapter instances;
that identity has one immutable JVM-wide concurrency limit. Cluster-wide capacity belongs
to the server slots and deployment, not the application JVM semaphore.
The public IsolatedRendererBridge(RendererLimits, AssetReader, Exchange) seam is also
available for explicit trusted local transports. Exchange.invoke(requestId,input,maximumOutput)
must enforce transport deadlines, output bounds and owned-work termination before returning.
It cannot bypass the bridge's authorization and byte validation. The original Docker
constructor still uses the original Docker configuration, sandbox and canonical admission key.
Private HTTP protocol: qqq-remote-renderer-1
One TLS 1.3 connection carries one HTTP/1.1 POST /render, then closes. Both sides authenticate
TLS certificates. The client requires a negotiated client certificate; the service additionally
maps the actual peer leaf certificate SHA256 to its configured namespace. Merely presenting a
trusted certificate does not authorize another namespace. Rotation is an operator allowlist change.
Headers use this closed lower-case profile. Unknown/duplicate headers, chunked encoding, compression, cookies, upgrades, redirects and connection reuse are refused:
content-type: application/json
content-length: <positive exact decimal>
connection: close
x-qqq-protocol: qqq-remote-renderer-1
x-qqq-mode: <TRUSTED_LAB or QUALIFIED_DEPLOYMENT>
x-qqq-namespace: <operator namespace>
x-qqq-profile: <operator profile>
x-qqq-evidence: <operator evidence>
x-qqq-image: sha256:<64 lowercase hex>
x-qqq-exchange: <fresh random UUID for this transport call>
Requests also carry host and x-qqq-maximum-output. The body is the unchanged, single
newline-terminated existing renderer JSON request. No HTTP request carries actor/session
credentials, storage URLs or keys. Current source/asset authorization remains in the host.
Response success is HTTP/1.1 200 OK, exact matching identity/exchange headers and
x-qqq-retired: true, followed by unchanged existing renderer JSON response bytes. The
service, not the guest's JSON, produces retirement evidence after child completion/cleanup.
The client rejects missing/mismatched retirement, extra body bytes and truncation, and the
existing bridge then rejects bad renderer/catalog/hash/format metadata. Failures close the
socket without private diagnostics. There is no automatic retry, including after a send.
The client also checks its monotonic elapsed deadline after the complete body and EOF;
a delayed watchdog task cannot turn an expired complete response into success.
This is a private operator transport profile, not another public business REST API. It is intended for direct TLS routing (including Kubernetes Service TCP forwarding); HTTP ingress that changes headers, encodings or terminates client TLS is not silently supported.
Standalone service settings
RendererServiceMain takes exactly one absolute operator JSON path. config.example.json
is intentionally invalid until all pins and hashes are supplied. Every top-level key is
required; unknown keys, duplicate JSON keys, numeric coercions and trailing JSON are refused.
Configure the complete ordered renderer JAR classpath and exact SHA256 for each JAR and
Java executable, plus native Zint/provider stamp when those capabilities are offered. Files
are verified at startup and must remain on immutable read-only image/mount inputs afterward.
The service invokes only fixed com.kingsrook.qbits.renderer.RendererMain, with no shell,
author arguments or arbitrary command configuration. Existing RendererMain enforces its
Java version/container marker and owns all template/parser/assets/fonts/barcode semantics.
The only implemented service execution backend is the same-UID local child. Its configured
identity.mode must be exactly TRUSTED_LAB. QUALIFIED_DEPLOYMENT is rejected during
startup identity validation, before loading TLS credentials or creating the child runner.
The public Java RendererServer(Configuration, RendererChild) constructor enforces the
same restriction before binding. Matching profile/evidence/image labels cannot override it.
PKCS12 passwords come only from operator environment variables
QQQ_RENDERER_KEYSTORE_PASSWORD and QQQ_RENDERER_TRUSTSTORE_PASSWORD; do not put them in
JSON or source. Keep configuration and secret mounts owner-private. The renderer child
inherits neither those variables nor arbitrary application environment; its environment is
only container marker, private HOME and fixed locale. Child temporary directories are fresh
per invocation. No source/context, credential, endpoint or child stderr is logged.
Limits are global per service process:
| Boundary | Limit |
|---|---|
| HTTP headers | 8192 bytes total before body allocation |
| Original renderer input | 16 MiB, one JSON line |
| Output | configured maximumOutput, no greater than existing wire limit |
| Memory admission estimate | slots × (2 × 16 MiB + 3 × maximumOutput) ≤ 256 MiB |
| Active calls | slots, 1–16 subject to aggregate memory admission |
| Waiting connections | at most slots; no source parsed while waiting |
| Total accepted sockets | at most 2 × slots; excess closes immediately |
| Deadline | from socket acceptance through handshake, queue, child and response, ≤30 s |
| Child stdout / stderr | output cap / 4096 bytes; no stderr disclosure |
| Child stream threads | fixed 3 × slots; completion required before success |
| Cleanup | tracked processes up to 2 s, stream retirement up to 2 s; failure poisons child runner |
The child heap is fixed at 256 MiB, headless mode, one advertised Java processor. These Java flags are not RSS/CPU isolation. Configure actual Pod/container memory/CPU/PID limits, bounded writable tmpfs, non-root UID, read-only root, dropped capabilities, no privilege escalation, restricted network policy and an appropriate qualified runtime/security profile. For the example one-slot service, account for the service heap, child heap, native renderer allocations and temporary storage when setting the Pod memory limit. Hard cgroup limits remain the authoritative aggregate limit; the memory admission estimate is not a JVM proof.
Pipe closure belongs only to the fixed child I/O tasks. Cleanup does not synchronously close
those pipes or call Java Process.destroyForcibly (which may close pipes); it signals the
exact process handles, then waits a bounded time for I/O retirement. A blocked pipe poisons
the runner and refuses subsequent work without producing success. Its fixed daemon I/O
thread is not replaced, so failures cannot create an unbounded thread pool; unresolved
retirement requires operator service restart and cleanup of remaining private temporary data.
The Dockerfile adds only the new service artifact/libraries to an already-built immutable
renderer image supplied as RENDERER_IMAGE; it neither rebuilds nor downloads engines. Pin
the final service image by digest in Kubernetes settings and mount its explicit operator
configuration/TLS secrets. Existing renderer corresponding-source/licensing directories stay
in the base image. No Kubernetes commands, deployment, image pull or runtime change are part
of this source change.
Qualification limits
Client mode is explicit, never inferred from labels or endpoint. The current local-child
service supports only TRUSTED_LAB; it cannot be enabled for qualified mode by configuration.
A QUALIFIED_DEPLOYMENT client refuses a TRUSTED_LAB service even with matching certificate
and other pins. The qualified client wire contract remains available for a future independently
isolated server. No such production backend is implemented here. The example uses trusted-lab
mode and cannot be used as a production evidence recipe. There is no automatic downgrade.
Identity headers report configured operator pins; a service cannot attest its own image or isolation by echoing them. Valid evidence must be supplied and reviewed externally. This new service has not been claimed hostile-input or Kubernetes qualified. Java ProcessHandle tracks the fixed renderer and observed descendants; it is not atomic containment of arbitrary forking hostile processes. The fixed existing renderer synchronously manages its Zint child, but an engine escape requires separately qualified OS/cgroup containment. A child with the same filesystem authority may reach mounted service credentials after an escape: production qualification must address that boundary rather than treating environment filtering as a sandbox. Existing Docker path is not silently replaced or weakened.
Fresh child processes trade startup cost for state separation. No warm engine pool or latency claim is introduced. Cancellation closes transport; the server's independent deadline/child cleanup remains authoritative. An uncertain client failure is not replayed automatically.
The proposed production follow-up is a bounded pool of single-use, credential-free renderer sandboxes, with TLS/runtime credentials retained only by an external trusted controller. It must prove complete guest retirement through the runtime boundary before releasing output, keep document bytes out of container logs, and refill capacity outside the request path. No Kubernetes controller, pool, new dependency or deployment is included in this change. Actual isolation and pool latency/refill/saturation measurements are prerequisites, not properties supplied by the existing operator identity labels.
Build and verification handoff
Root/operator runs the cached source reactor only after the shared source lease is frozen:
qbit-document-generation/mvnw.cmd -o -f qbit-document-generation/bridges/remote-renderer/pom.xml -Dmaven.repo.local=<private-cache> verify
On Unix use mvnw. The selected cache must contain the matching generation foundation and
reviewed dependencies. Do not install/replace shared artifacts as a side effect.
The production-mode refusal gate completed successfully on 2026-11-01: 19 tests,
zero failures/errors/skips, zero Checkstyle violations, Maven verify success. The log is
qbit-document-generation/target/remote-renderer-production-gate-verification.log:
3 framing, 5 child-lifecycle, 8 mTLS server/client and 3 service-mode cases. The mode cases
reject valid matching qualified labels at JSON startup and the public local-child server
constructor, while retaining trusted-lab configuration and the future qualified-client
contract. This result does not replace or alter the earlier sealed 43-test transport/bridge
evidence, and does not establish a production isolation boundary.
Tests include real loopback mTLS with ephemeral certificates, exact sequential transport, namespace/hostname/missing-client-cert refusal, late-result deadline refusal, no replay after retirement failure, malformed framing/identity limits, shutdown cleanup, and actual fixed Java child I/O/timeout/hash/temp-retirement cases. The child test JAR contains a clearly labeled test-only fixed fixture, never author JavaScript or the real engine. Existing bridge tests exercise unchanged ZPL output policy and shared admission through the public constructor. These are transport/lifecycle proofs; final image execution with the existing renderer corpus, real remote bridge end-to-end output, and deployment security qualification remain separate.