From 1fbefda06af8bef5ee8d4ba501328948d1fb7d88 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:08:54 +0100 Subject: [PATCH 01/15] json-tls for koa, fastify, hono-node and node-h3 Each entry serves the JSON workload but had no TLS listener, so json-tls scored 0. All four now bind 8081 with the same app that serves 8080: koa app.callback() behind node:https hono-node the same app.fetch, via the adapter's createServer option node-h3 srvx takes the PEM paths and builds the https server itself fastify Fastify binds one instance per server, so routes moved into build(); both ports register from that one definition The certs are only mounted for the TLS profiles, so every listener is guarded on their presence and stays closed otherwise. validate: koa 37/0, fastify 47/0, hono-node 37/0, node-h3 37/0 --- frameworks/fastify/app.js | 22 +++++++++++++++++++--- frameworks/fastify/meta.json | 1 + frameworks/hono-node/meta.json | 1 + frameworks/hono-node/server.ts | 18 +++++++++++++++++- frameworks/koa/app.js | 15 +++++++++++++++ frameworks/koa/meta.json | 1 + frameworks/node-h3/app.js | 10 ++++++++++ frameworks/node-h3/meta.json | 1 + 8 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frameworks/fastify/app.js b/frameworks/fastify/app.js index 9c7a666d3..dbcd58658 100644 --- a/frameworks/fastify/app.js +++ b/frameworks/fastify/app.js @@ -20,8 +20,11 @@ if (cluster.isPrimary) { start(); } -async function start() { - const fastify = require('fastify')({ logger: false }); +// Fastify binds one instance to one server, so the TLS listener needs its own +// instance. build() is the single definition of the app — both ports register +// the identical plugins and routes from it, rather than a hand-copied subset. +async function build(serverOpts) { + const fastify = require('fastify')({ logger: false, ...serverOpts }); const fs = require('fs'); const Database = require('better-sqlite3'); @@ -178,5 +181,18 @@ async function start() { } }); - await fastify.listen({ port: 8080, host: '0.0.0.0' }); + return fastify; +} + +async function start() { + const fs = require('fs'); + await (await build({})).listen({ port: 8080, host: '0.0.0.0' }); + + // json-tls on 8081. The harness only mounts /certs for the TLS profiles. + const cert = '/certs/server.crt'; + const key = '/certs/server.key'; + if (fs.existsSync(cert) && fs.existsSync(key)) { + const tls = await build({ https: { key: fs.readFileSync(key), cert: fs.readFileSync(cert) } }); + await tls.listen({ port: 8081, host: '0.0.0.0' }); + } } diff --git a/frameworks/fastify/meta.json b/frameworks/fastify/meta.json index 9afdb993f..8c71e11f6 100644 --- a/frameworks/fastify/meta.json +++ b/frameworks/fastify/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "api-4", diff --git a/frameworks/hono-node/meta.json b/frameworks/hono-node/meta.json index 4ac214653..def3c818e 100644 --- a/frameworks/hono-node/meta.json +++ b/frameworks/hono-node/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/hono-node/server.ts b/frameworks/hono-node/server.ts index 26a6d5dfd..0b57a06a9 100644 --- a/frameworks/hono-node/server.ts +++ b/frameworks/hono-node/server.ts @@ -3,7 +3,8 @@ import { Hono } from "hono"; import { compress } from "hono/compress"; import cluster from "node:cluster"; import os from "node:os"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import { createServer as createHttpsServer } from "node:https"; const SERVER_NAME = "hono-node"; @@ -110,4 +111,19 @@ if (cluster.isPrimary) { // Start — node:http through the Hono adapter, one worker per core serve({ fetch: app.fetch, port: 8080 }); + + // json-tls on 8081: the same app.fetch behind node:https. The adapter takes the + // server factory, so this is the identical Hono instance and middleware chain, + // not a second copy of the routes. Certs are only mounted for the TLS profiles. + if (existsSync("/certs/server.crt") && existsSync("/certs/server.key")) { + serve({ + fetch: app.fetch, + port: 8081, + createServer: createHttpsServer, + serverOptions: { + key: readFileSync("/certs/server.key"), + cert: readFileSync("/certs/server.crt"), + }, + }); + } } diff --git a/frameworks/koa/app.js b/frameworks/koa/app.js index aed79ed99..ba479360f 100644 --- a/frameworks/koa/app.js +++ b/frameworks/koa/app.js @@ -1,4 +1,5 @@ const cluster = require('cluster'); +const https = require('node:https'); const os = require('os'); function getCPUCount() { @@ -92,4 +93,18 @@ if (cluster.isPrimary) { app.use(router.routes()); app.listen(8080); + + // json-tls on 8081: the same app, behind a TLS server. app.callback() is the + // node request handler Koa already builds for its own listen(), so this is + // the same middleware chain rather than a second copy. Every worker binds it + // as they all bind 8080. The harness only mounts /certs for the TLS + // profiles, so without them it is not opened. + const cert = '/certs/server.crt'; + const key = '/certs/server.key'; + if (fs.existsSync(cert) && fs.existsSync(key)) { + https.createServer( + { key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, + app.callback() + ).listen(8081); + } } diff --git a/frameworks/koa/meta.json b/frameworks/koa/meta.json index f91d9a044..7a6b7d0a1 100644 --- a/frameworks/koa/meta.json +++ b/frameworks/koa/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/node-h3/app.js b/frameworks/node-h3/app.js index a62f31852..6fbe9b2e5 100644 --- a/frameworks/node-h3/app.js +++ b/frameworks/node-h3/app.js @@ -104,4 +104,14 @@ if (cluster.isPrimary) { // under cluster only the first worker gets port 8080 and the others fail with // EADDRINUSE, silently: srvx catches the listen error, so they stay up serving nothing serve(app, { port: 8080, hostname: '0.0.0.0', reusePort: true, silent: true }); + + // json-tls on 8081. srvx (h3's server layer) takes the PEM paths directly and + // builds the node:https server itself, so this is the same h3 app on both + // ports. Certs are only mounted for the TLS profiles, hence the guard. + if (fs.existsSync('/certs/server.crt') && fs.existsSync('/certs/server.key')) { + serve(app, { + port: 8081, hostname: '0.0.0.0', reusePort: true, silent: true, + tls: { cert: '/certs/server.crt', key: '/certs/server.key' }, + }); + } } diff --git a/frameworks/node-h3/meta.json b/frameworks/node-h3/meta.json index 6aec7dfe6..ec5133bf0 100644 --- a/frameworks/node-h3/meta.json +++ b/frameworks/node-h3/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], From 2c4b40d545e4fa1066de6c6dafeff1e9cf2da872 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:12:55 +0100 Subject: [PATCH 02/15] json-tls for rocket and axum Both serve the JSON workload with no TLS listener, so json-tls scored 0. rocket enables its own tls feature and configures a second instance on 8081. Routes moved into build() so the two ports mount the same set; both configs start from Config::figment(), which keeps the ROCKET_* env from the Dockerfile applying to each. axum serves the same Router over axum-server's rustls acceptor, the one axum's own tls-rustls example uses. ring rather than aws-lc-rs, so the build image needs no C toolchain. Guarded on the PEMs, which the harness only mounts for the TLS profiles. validate: rocket 34/0, axum 37/0 --- frameworks/axum/Cargo.toml | 2 ++ frameworks/axum/meta.json | 1 + frameworks/axum/src/main.rs | 20 +++++++++++++++++ frameworks/rocket/Cargo.toml | 2 +- frameworks/rocket/meta.json | 1 + frameworks/rocket/src/main.rs | 41 ++++++++++++++++++++++++++++++----- 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/frameworks/axum/Cargo.toml b/frameworks/axum/Cargo.toml index 618c0262e..106ac1b05 100644 --- a/frameworks/axum/Cargo.toml +++ b/frameworks/axum/Cargo.toml @@ -5,6 +5,8 @@ edition = "2021" [dependencies] axum = "0.8" +axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } tokio = { version = "1", features = ["full"] } tower-http = { version = "0.6", features = ["compression-gzip"] } serde = { version = "1", features = ["derive"] } diff --git a/frameworks/axum/meta.json b/frameworks/axum/meta.json index 6d82d7f88..7dabce816 100644 --- a/frameworks/axum/meta.json +++ b/frameworks/axum/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/axum/src/main.rs b/frameworks/axum/src/main.rs index 1deb1215f..b5b965999 100644 --- a/frameworks/axum/src/main.rs +++ b/frameworks/axum/src/main.rs @@ -4,6 +4,7 @@ use axum::body::Bytes; use axum::extract::{DefaultBodyLimit, Path, Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; +use axum_server::tls_rustls::RustlsConfig; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; @@ -128,6 +129,25 @@ async fn main() { .layer(DefaultBodyLimit::max(MAX_BODY)) .with_state(dataset); + // json-tls on 8081, served by the same Router. axum-server is axum's own + // TLS companion (it is what the axum tls-rustls example uses), so the + // accept loop is the framework's rather than hand-rolled here. The harness + // only mounts /certs for the TLS profiles, hence the guard. + let cert = std::path::Path::new("/certs/server.crt"); + let key = std::path::Path::new("/certs/server.key"); + if cert.exists() && key.exists() { + // ring rather than aws-lc-rs: same TLS, no C toolchain in the build image + rustls::crypto::ring::default_provider().install_default().ok(); + let tls_config = RustlsConfig::from_pem_file(cert, key).await.unwrap(); + let tls_app = app.clone(); + tokio::spawn(async move { + axum_server::bind_rustls("0.0.0.0:8081".parse().unwrap(), tls_config) + .serve(tls_app.into_make_service()) + .await + .unwrap(); + }); + } + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); } diff --git a/frameworks/rocket/Cargo.toml b/frameworks/rocket/Cargo.toml index 729f3efb7..1fb211187 100644 --- a/frameworks/rocket/Cargo.toml +++ b/frameworks/rocket/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -rocket = { version = "0.5", features = ["json"] } +rocket = { version = "0.5", features = ["json", "tls"] } [profile.release] opt-level = 3 diff --git a/frameworks/rocket/meta.json b/frameworks/rocket/meta.json index 7dbcc4f18..d35a910ad 100644 --- a/frameworks/rocket/meta.json +++ b/frameworks/rocket/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "upload" ], "maintainers": [] diff --git a/frameworks/rocket/src/main.rs b/frameworks/rocket/src/main.rs index 003824e27..47932c71d 100644 --- a/frameworks/rocket/src/main.rs +++ b/frameworks/rocket/src/main.rs @@ -128,14 +128,43 @@ async fn upload(data: Data<'_>) -> String { } } -#[launch] -fn rocket() -> _ { - // Leaked once at startup so handlers borrow the items instead of cloning - // every string into the response. - let dataset: &'static [DatasetItem] = Box::leak(load_dataset().into_boxed_slice()); - +// Single definition of the app; both the plaintext and the TLS listener are +// configured from it, so the two ports cannot drift apart. +fn build(dataset: &'static [DatasetItem]) -> rocket::Rocket { rocket::build().manage(dataset).mount( "/", routes![pipeline, baseline11_get, baseline11_post, json_items, upload], ) } + +#[rocket::main] +async fn main() -> Result<(), rocket::Error> { + // Leaked once at startup so handlers borrow the items instead of cloning + // every string into the response. + let dataset: &'static [DatasetItem] = Box::leak(load_dataset().into_boxed_slice()); + + // Rocket binds one address per instance, so json-tls needs a second one. + // Both start from Config::figment(), which keeps the ROCKET_* env from the + // Dockerfile (address, log level) applying to each. + let plain = build(dataset).configure(rocket::Config::figment().merge(("port", 8080))); + + // Rocket's own TLS, driven off the mounted PEMs. The harness only mounts + // /certs for the TLS profiles, so without them only 8080 comes up. + let cert = std::path::Path::new("/certs/server.crt"); + let key = std::path::Path::new("/certs/server.key"); + if cert.exists() && key.exists() { + let tls = build(dataset).configure( + rocket::Config::figment() + .merge(("port", 8081)) + .merge(("tls.certs", cert)) + .merge(("tls.key", key)), + ); + let (plain_res, tls_res) = rocket::tokio::join!(plain.launch(), tls.launch()); + plain_res?; + tls_res?; + } else { + plain.launch().await?; + } + + Ok(()) +} From 5fd521b59c218fd9fde7185f1605a46074309d21 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:24:04 +0100 Subject: [PATCH 03/15] json-tls for plug-cowboy and phoenix-bandit plug-cowboy a second Plug.Cowboy listener in front of the same router. Plug.Cowboy derives its ref from plug + scheme, so the two children do not collide. phoenix-bandit the endpoint's https: config already holds 8443 for the h2 profiles and Phoenix binds one https listener per endpoint, so json-tls gets a second Bandit listener in front of the same endpoint plug -- the identical pipeline, not a copy. Both guarded on the PEMs, which the harness only mounts for TLS profiles. validate: plug-cowboy 37/0, phoenix-bandit 70/0 (the latter needs #1272, which fixes a probe bug that fails any container running as non-root) --- .../lib/phoenix_bandit/application.ex | 31 +++++++++++++++- frameworks/phoenix-bandit/meta.json | 3 +- .../plug-cowboy/lib/httparena/application.ex | 35 ++++++++++++++++++- frameworks/plug-cowboy/meta.json | 1 + 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/frameworks/phoenix-bandit/lib/phoenix_bandit/application.ex b/frameworks/phoenix-bandit/lib/phoenix_bandit/application.ex index 6c530b2ae..32bfcd24f 100644 --- a/frameworks/phoenix-bandit/lib/phoenix_bandit/application.ex +++ b/frameworks/phoenix-bandit/lib/phoenix_bandit/application.ex @@ -18,7 +18,7 @@ defmodule PhoenixBandit.Application do # Start to serve requests, typically the last entry {DynamicSupervisor, strategy: :one_for_one, name: PhoenixBandit.DB.Supervisor}, PhoenixBanditWeb.Endpoint - ] + ] ++ json_tls_child() # See https://elixir.hexdocs.pm/Supervisor.html # for other strategies and supported options @@ -26,6 +26,35 @@ defmodule PhoenixBandit.Application do Supervisor.start_link(children, opts) end + # json-tls needs HTTP/1.1 over TLS on 8081. The endpoint's own https: config + # already holds 8443 for the h2 profiles and Phoenix binds one https listener + # per endpoint, so this is a second Bandit listener in front of the same + # endpoint plug -- the identical pipeline, not a copy of it. The harness only + # mounts /certs for the TLS profiles, so without them the child is not added. + defp json_tls_child do + cert = System.get_env("TLS_CERT_PATH", "/certs/server.crt") + key = System.get_env("TLS_KEY_PATH", "/certs/server.key") + + if File.exists?(cert) and File.exists?(key) do + [ + Supervisor.child_spec( + {Bandit, + plug: PhoenixBanditWeb.Endpoint, + scheme: :https, + port: 8081, + ip: {0, 0, 0, 0}, + thousand_island_options: [ + num_acceptors: 100, + transport_options: [certfile: Path.expand(cert), keyfile: Path.expand(key)] + ]}, + id: :json_tls_listener + ) + ] + else + [] + end + end + # Tell Phoenix to update the endpoint configuration # whenever the application is updated. @impl true diff --git a/frameworks/phoenix-bandit/meta.json b/frameworks/phoenix-bandit/meta.json index 77415d95c..651dc740e 100644 --- a/frameworks/phoenix-bandit/meta.json +++ b/frameworks/phoenix-bandit/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "api-4", @@ -30,4 +31,4 @@ "baseline-h2", "static-h2" ] -} \ No newline at end of file +} diff --git a/frameworks/plug-cowboy/lib/httparena/application.ex b/frameworks/plug-cowboy/lib/httparena/application.ex index 982a88e6c..e0daf35c2 100644 --- a/frameworks/plug-cowboy/lib/httparena/application.ex +++ b/frameworks/plug-cowboy/lib/httparena/application.ex @@ -28,8 +28,41 @@ defmodule HttpArena.Application do max_connections: :infinity ] ]} - ] + ] ++ tls_child() Supervisor.start_link(children, strategy: :one_for_one, name: HttpArena.Supervisor) end + + # json-tls on 8081: a second Plug.Cowboy listener in front of the same router, + # so both ports run the identical plug pipeline. Plug.Cowboy derives its ref + # from plug + scheme, so the two children do not collide. The harness only + # mounts /certs for the TLS profiles, so without them only 8080 comes up. + defp tls_child do + cert = "/certs/server.crt" + key = "/certs/server.key" + + if File.exists?(cert) and File.exists?(key) do + [ + {Plug.Cowboy, + scheme: :https, + plug: HttpArena.Router, + options: [ + port: 8081, + certfile: cert, + keyfile: key, + compress: true, + protocol_options: [ + max_keepalive: :infinity, + idle_timeout: :infinity + ], + transport_options: [ + num_acceptors: System.schedulers_online() * 2, + max_connections: :infinity + ] + ]} + ] + else + [] + end + end end diff --git a/frameworks/plug-cowboy/meta.json b/frameworks/plug-cowboy/meta.json index 1f1fef9eb..a0ab909de 100644 --- a/frameworks/plug-cowboy/meta.json +++ b/frameworks/plug-cowboy/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], From 4b2afc364f1f179d183301177021d03c1684e724 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:39:43 +0100 Subject: [PATCH 04/15] json-tls for ring and ring-jetty9-adapter Both Jetty adapters take a KeyStore instance for :keystore, so the mounted PEMs are converted in-process and merged into the existing run-jetty opts as :ssl? / :ssl-port 8081 -- one server, same handler on both ports. The conversion uses CertificateFactory + KeyFactory rather than java.security.PEMDecoder: PEMDecoder is still a preview API on the JDK 25 in the clojure base image, so it would need --enable-preview to load. Verified against each adapter's source rather than assumed: ring sets .setKeyStore for a non-string :keystore and builds its SslConnectionFactory with "http/1.1", and sunng's does the same with h2? defaulting off. http-kit is deliberately absent -- 2.9.0-beta4's run-server has no TLS support at all (no ssl/tls anywhere in server.clj, none in its documented options), so it cannot serve json-tls without a hand-rolled TLS layer. validate: ring 52/0, ring-jetty9-adapter 46/0 --- frameworks/ring-jetty9-adapter/meta.json | 1 + .../httparena/ring_jetty9_adapter/core.clj | 56 ++++++++++++++--- frameworks/ring/meta.json | 1 + frameworks/ring/src/httparena/ring/core.clj | 60 +++++++++++++++---- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/frameworks/ring-jetty9-adapter/meta.json b/frameworks/ring-jetty9-adapter/meta.json index 5b7297d03..6c767d06a 100644 --- a/frameworks/ring-jetty9-adapter/meta.json +++ b/frameworks/ring-jetty9-adapter/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "static", diff --git a/frameworks/ring-jetty9-adapter/src/httparena/ring_jetty9_adapter/core.clj b/frameworks/ring-jetty9-adapter/src/httparena/ring_jetty9_adapter/core.clj index 66fbd8530..30f2391c7 100644 --- a/frameworks/ring-jetty9-adapter/src/httparena/ring_jetty9_adapter/core.clj +++ b/frameworks/ring-jetty9-adapter/src/httparena/ring_jetty9_adapter/core.clj @@ -11,7 +11,11 @@ [ring.middleware.params :as params] [ring.util.response :as response]) (:import - [java.io InputStream OutputStream] + [java.io FileInputStream InputStream OutputStream] + [java.security KeyFactory KeyStore] + [java.security.cert Certificate CertificateFactory] + [java.security.spec PKCS8EncodedKeySpec] + [java.util Base64] [java.net URI] [org.eclipse.jetty.server.handler.gzip GzipHandler] [org.postgresql.util PGobject])) @@ -244,17 +248,51 @@ (defn handler [request] (app (params/params-request request))) +(def ^:private ^:const tls-cert-path "/certs/server.crt") +(def ^:private ^:const tls-key-path "/certs/server.key") + +;; The harness mounts PEMs; Jetty wants a KeyStore. Built with the plain JDK +;; crypto APIs rather than java.security.PEMDecoder, which is still a preview +;; API on the JDK in this image. +(defn- pem->keystore ^KeyStore [^String cert-path ^String key-path] + (let [certs (with-open [in (FileInputStream. cert-path)] + (.generateCertificates (CertificateFactory/getInstance "X.509") in)) + chain (into-array Certificate certs) + der (->> (-> (slurp key-path) + (str/replace #"-----(BEGIN|END) PRIVATE KEY-----" "") + (str/replace #"\s" "")) + (.decode (Base64/getDecoder))) + pk (.generatePrivate (KeyFactory/getInstance "RSA") + (PKCS8EncodedKeySpec. der)) + pw (char-array 0)] + (doto (KeyStore/getInstance "PKCS12") + (.load nil pw) + (.setKeyEntry "server" pk pw chain)))) + +;; json-tls on 8081, same handler as 8080. Only opened when the certs are +;; mounted, which the harness does just for the TLS profiles. +(defn- tls-opts [] + (if (and (.exists (io/file tls-cert-path)) (.exists (io/file tls-key-path))) + {:ssl? true + :ssl-port 8081 + :keystore (pem->keystore tls-cert-path tls-key-path) + :key-password "" + :sni-host-check? false} + {})) + (defn -main [& _args] (when-not (vector? @dataset) (throw (ex-info "dataset.json must contain a JSON array" {:path "/data/dataset.json"}))) (.addShutdownHook (Runtime/getRuntime) (Thread. ^Runnable close-datasource!)) (jetty/run-jetty handler - {:host "0.0.0.0" - :join? true - :port 8080 - :virtual-threads? true - :wrap-jetty-handler (fn [^org.eclipse.jetty.server.Handler ring-handler] - (doto (GzipHandler.) - (.setExcludedPaths (into-array String ["/static/*"])) - (.setHandler ring-handler)))})) + (merge + {:host "0.0.0.0" + :join? true + :port 8080 + :virtual-threads? true + :wrap-jetty-handler (fn [^org.eclipse.jetty.server.Handler ring-handler] + (doto (GzipHandler.) + (.setExcludedPaths (into-array String ["/static/*"])) + (.setHandler ring-handler)))} + (tls-opts)))) diff --git a/frameworks/ring/meta.json b/frameworks/ring/meta.json index 3ccbd5820..b0bec1706 100644 --- a/frameworks/ring/meta.json +++ b/frameworks/ring/meta.json @@ -19,6 +19,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "static", diff --git a/frameworks/ring/src/httparena/ring/core.clj b/frameworks/ring/src/httparena/ring/core.clj index 2cd2669dc..a0d02aece 100644 --- a/frameworks/ring/src/httparena/ring/core.clj +++ b/frameworks/ring/src/httparena/ring/core.clj @@ -12,7 +12,11 @@ [ring.util.response :as response] [selmer.parser :as selmer]) (:import - [java.io InputStream OutputStream] + [java.io FileInputStream InputStream OutputStream] + [java.security KeyFactory KeyStore] + [java.security.cert Certificate CertificateFactory] + [java.security.spec PKCS8EncodedKeySpec] + [java.util Base64] [java.net URI] [org.eclipse.jetty.server Server] [org.eclipse.jetty.server.handler.gzip GzipHandler] @@ -269,19 +273,53 @@ (doto (QueuedThreadPool.) (.setVirtualThreadsExecutor (VirtualThreads/getDefaultVirtualThreadsExecutor)))) +(def ^:private ^:const tls-cert-path "/certs/server.crt") +(def ^:private ^:const tls-key-path "/certs/server.key") + +;; The harness mounts PEMs; Jetty wants a KeyStore. Built with the plain JDK +;; crypto APIs rather than java.security.PEMDecoder, which is still a preview +;; API on the JDK in this image. +(defn- pem->keystore ^KeyStore [^String cert-path ^String key-path] + (let [certs (with-open [in (FileInputStream. cert-path)] + (.generateCertificates (CertificateFactory/getInstance "X.509") in)) + chain (into-array Certificate certs) + der (->> (-> (slurp key-path) + (str/replace #"-----(BEGIN|END) PRIVATE KEY-----" "") + (str/replace #"\s" "")) + (.decode (Base64/getDecoder))) + pk (.generatePrivate (KeyFactory/getInstance "RSA") + (PKCS8EncodedKeySpec. der)) + pw (char-array 0)] + (doto (KeyStore/getInstance "PKCS12") + (.load nil pw) + (.setKeyEntry "server" pk pw chain)))) + +;; json-tls on 8081, same handler as 8080. Only opened when the certs are +;; mounted, which the harness does just for the TLS profiles. +(defn- tls-opts [] + (if (and (.exists (io/file tls-cert-path)) (.exists (io/file tls-key-path))) + {:ssl? true + :ssl-port 8081 + :keystore (pem->keystore tls-cert-path tls-key-path) + :key-password "" + :sni-host-check? false} + {})) + (defn -main [& _args] (when-not (vector? @dataset) (throw (ex-info "dataset.json must contain a JSON array" {:path "/data/dataset.json"}))) (.addShutdownHook (Runtime/getRuntime) (Thread. ^Runnable close-datasource!)) (jetty/run-jetty handler - {:host "0.0.0.0" - :configurator (fn [^Server server] - (let [gzip-handler (doto (GzipHandler.) - (.setExcludedPaths - (into-array String ["/static/*"])) - (.setHandler (.getHandler server)))] - (.setHandler server gzip-handler))) - :join? true - :port 8080 - :thread-pool (virtual-thread-pool)})) + (merge + {:host "0.0.0.0" + :configurator (fn [^Server server] + (let [gzip-handler (doto (GzipHandler.) + (.setExcludedPaths + (into-array String ["/static/*"])) + (.setHandler (.getHandler server)))] + (.setHandler server gzip-handler))) + :join? true + :port 8080 + :thread-pool (virtual-thread-pool)} + (tls-opts)))) From d0c00a988ba2b6b508b65d2f75735edc6d0aeb20 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:39:43 +0100 Subject: [PATCH 05/15] json-tls for http4k, http4s and micronaut http4k an https listener on the same Undertow builder, so 8081 runs the identical handler chain as 8080 http4s Ember binds one port per builder, so server() is the single definition and the two resources run parTupled micronaut dual-protocol with ssl.port 8081; the listener stays Micronaut's, only the key material is prepared before it starts All three want a KeyStore/SSLContext and the harness mounts PEMs, so each converts the pair in-process with CertificateFactory + KeyFactory rather than java.security.PEMDecoder, which is still a preview API on these JDKs. validate: http4k 37/0, http4s 37/0, micronaut 37/0 --- frameworks/http4k/meta.json | 1 + frameworks/http4k/src/main/kotlin/Main.kt | 47 +++++++++++- frameworks/http4s/meta.json | 1 + frameworks/http4s/src/main/scala/Main.scala | 60 +++++++++++++-- frameworks/micronaut/meta.json | 1 + .../src/main/java/httparena/Application.java | 74 +++++++++++++++++++ 6 files changed, 178 insertions(+), 6 deletions(-) diff --git a/frameworks/http4k/meta.json b/frameworks/http4k/meta.json index 8e9f6f351..2f1ba346e 100644 --- a/frameworks/http4k/meta.json +++ b/frameworks/http4k/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/http4k/src/main/kotlin/Main.kt b/frameworks/http4k/src/main/kotlin/Main.kt index e49c9b3dd..4d0a08f0a 100644 --- a/frameworks/http4k/src/main/kotlin/Main.kt +++ b/frameworks/http4k/src/main/kotlin/Main.kt @@ -1,5 +1,14 @@ import io.undertow.UndertowOptions import java.io.File +import java.io.FileInputStream +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext import org.http4k.core.ContentType import org.http4k.core.Method import org.http4k.core.Request @@ -106,6 +115,37 @@ val app = routes( "/upload" bind Method.POST to ::upload ) +// json-tls needs HTTP/1.1 over TLS on 8081. Undertow takes an SSLContext, and +// the harness mounts PEMs, so the pair is converted in-process. Plain JDK +// crypto APIs rather than java.security.PEMDecoder, which is still a preview +// API on this JDK. Null when the certs are absent -- the harness only mounts +// them for the TLS profiles. +fun tlsContext(): SSLContext? { + val cert = File("/certs/server.crt") + val key = File("/certs/server.key") + if (!cert.exists() || !key.exists()) return null + + val chain: Array = FileInputStream(cert).use { + CertificateFactory.getInstance("X.509").generateCertificates(it).toTypedArray() + } + val der = Base64.getDecoder().decode( + key.readText() + .replace(Regex("-----(BEGIN|END) PRIVATE KEY-----"), "") + .replace(Regex("\\s"), "") + ) + val privateKey = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der)) + val password = CharArray(0) + val store = KeyStore.getInstance("PKCS12").apply { + load(null, password) + setKeyEntry("server", privateKey, password, chain) + } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(store, password) } + .keyManagers + + return SSLContext.getInstance("TLS").apply { init(keyManagers, null, null) } +} + fun main() { // The stock http4k Undertow config caps requests at 10 MB and the upload // profile sends 20 MB, so the same server is built with a larger limit, the @@ -113,11 +153,16 @@ fun main() { val handler = ServerFilters.GZip().then(app) val (httpHandler, rootHandler) = buildUndertowHandlers(handler, null, null, Immediate) - io.undertow.Undertow.builder() + val builder = io.undertow.Undertow.builder() .addHttpListener(8080, "0.0.0.0") .setServerOption(UndertowOptions.MAX_ENTITY_SIZE, 30L * 1024 * 1024) .setWorkerThreads(32 * Runtime.getRuntime().availableProcessors()) .setHandler(rootHandler) + + // Same builder, so 8081 runs the identical handler chain as 8080. + tlsContext()?.let { builder.addHttpsListener(8081, "0.0.0.0", it) } + + builder .buildHttp4kUndertowServer(httpHandler, Immediate, 8080) .start() } diff --git a/frameworks/http4s/meta.json b/frameworks/http4s/meta.json index df3eb7fd6..920e085e2 100644 --- a/frameworks/http4s/meta.json +++ b/frameworks/http4s/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/http4s/src/main/scala/Main.scala b/frameworks/http4s/src/main/scala/Main.scala index d419e1017..343779fba 100644 --- a/frameworks/http4s/src/main/scala/Main.scala +++ b/frameworks/http4s/src/main/scala/Main.scala @@ -1,5 +1,8 @@ import cats.effect.{IO, IOApp} +import cats.syntax.all.* import com.comcast.ip4s.{Host, Port} +import fs2.io.net.Network +import fs2.io.net.tls.TLSContext import io.circe.{Json, JsonObject, parser} import org.http4s.circe.* import org.http4s.dsl.io.* @@ -7,8 +10,16 @@ import org.http4s.ember.server.EmberServerBuilder import org.http4s.server.middleware.GZip import org.http4s.{HttpRoutes, Request} +import java.io.{File, FileInputStream} +import java.security.cert.{Certificate, CertificateFactory} +import java.security.spec.PKCS8EncodedKeySpec +import java.security.{KeyFactory, KeyStore} +import java.util.Base64 +import javax.net.ssl.{KeyManagerFactory, SSLContext} + import scala.io.Source import scala.util.Using +import scala.jdk.CollectionConverters.* object Main extends IOApp.Simple: @@ -58,12 +69,51 @@ object Main extends IOApp.Simple: .flatMap(size => Ok(size.toString)) } - def run: IO[Unit] = - EmberServerBuilder + // json-tls needs HTTP/1.1 over TLS on 8081. The harness mounts PEMs and Ember + // wants a TLSContext, so the pair is converted in-process. Plain JDK crypto + // rather than java.security.PEMDecoder, which is still a preview API here. + private def sslContext: Option[SSLContext] = + val cert = File("/certs/server.crt") + val key = File("/certs/server.key") + Option.when(cert.exists() && key.exists()): + val chain = Using.resource(FileInputStream(cert)): in => + CertificateFactory + .getInstance("X.509") + .generateCertificates(in) + .asScala + .toArray[Certificate] + val der = Base64.getDecoder.decode( + Source + .fromFile(key) + .mkString + .replaceAll("-----(BEGIN|END) PRIVATE KEY-----", "") + .replaceAll("\\s", "") + ) + val privateKey = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der)) + val password = Array.empty[Char] + val store = KeyStore.getInstance("PKCS12") + store.load(null, password) + store.setKeyEntry("server", privateKey, password, chain) + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + kmf.init(store, password) + val ctx = SSLContext.getInstance("TLS") + ctx.init(kmf.getKeyManagers, null, null) + ctx + + // One builder definition, so both ports serve the identical routes. + private def server(port: Int, tls: Option[TLSContext[IO]]) = + val base = EmberServerBuilder .default[IO] .withHost(Host.fromString("0.0.0.0").get) - .withPort(Port.fromInt(8080).get) + .withPort(Port.fromInt(port).get) .withHttpApp(GZip(routes).orNotFound) .withMaxConnections(16384) - .build - .useForever + tls.fold(base)(base.withTLS(_)).build + + def run: IO[Unit] = + sslContext match + case Some(ctx) => + val tls = Network[IO].tlsContext.fromSSLContext(ctx) + (server(8080, None), server(8081, Some(tls))).parTupled.useForever + case None => + server(8080, None).useForever diff --git a/frameworks/micronaut/meta.json b/frameworks/micronaut/meta.json index d1556ed9b..dbf37d2a2 100644 --- a/frameworks/micronaut/meta.json +++ b/frameworks/micronaut/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/micronaut/src/main/java/httparena/Application.java b/frameworks/micronaut/src/main/java/httparena/Application.java index 0f1257ff7..18f7c4d21 100644 --- a/frameworks/micronaut/src/main/java/httparena/Application.java +++ b/frameworks/micronaut/src/main/java/httparena/Application.java @@ -2,9 +2,83 @@ import io.micronaut.runtime.Micronaut; +import javax.net.ssl.KeyManagerFactory; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Collection; + public class Application { + private static final Path CERT = Path.of("/certs/server.crt"); + private static final Path KEY = Path.of("/certs/server.key"); + private static final Path KEYSTORE = Path.of("/tmp/server.p12"); + public static void main(String[] args) { + enableJsonTls(); Micronaut.run(Application.class, args); } + + /** + * json-tls needs HTTP/1.1 over TLS on 8081 alongside plaintext on 8080. + * Micronaut serves both when dual-protocol is on, but its SSL config wants a + * key store and the harness mounts PEMs, so the pair is converted here and + * the resulting PKCS12 handed to Micronaut through its own configuration. + * The listener is still Micronaut's; only the key material is prepared. + * + *

The harness mounts /certs for the TLS profiles only, so on every other + * profile this is a no-op and the server comes up plaintext-only. + */ + private static void enableJsonTls() { + if (!Files.exists(CERT) || !Files.exists(KEY)) { + return; + } + try { + writeKeyStore(); + } catch (Exception e) { + System.err.println("json-tls: could not build a key store from the mounted PEMs: " + e); + return; + } + System.setProperty("micronaut.server.dual-protocol", "true"); + System.setProperty("micronaut.server.ssl.enabled", "true"); + System.setProperty("micronaut.server.ssl.port", "8081"); + System.setProperty("micronaut.server.ssl.key-store.path", "file:" + KEYSTORE); + System.setProperty("micronaut.server.ssl.key-store.type", "PKCS12"); + System.setProperty("micronaut.server.ssl.key-store.password", ""); + } + + private static void writeKeyStore() throws Exception { + Collection certs; + try (FileInputStream in = new FileInputStream(CERT.toFile())) { + certs = CertificateFactory.getInstance("X.509").generateCertificates(in); + } + + // Plain JDK crypto rather than java.security.PEMDecoder, which is still a + // preview API on the JDK in this image. + byte[] der = Base64.getDecoder().decode( + Files.readString(KEY) + .replaceAll("-----(BEGIN|END) PRIVATE KEY-----", "") + .replaceAll("\\s", "")); + PrivateKey privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + + char[] password = new char[0]; + KeyStore store = KeyStore.getInstance("PKCS12"); + store.load(null, password); + store.setKeyEntry("server", privateKey, password, certs.toArray(new Certificate[0])); + try (FileOutputStream out = new FileOutputStream(KEYSTORE.toFile())) { + store.store(out, password); + } + // sanity: fail loudly here rather than with an opaque Netty error later + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).init(store, password); + } } From 39ac8f9a5efdb04dfa7f71d6b4bafbfb8b011873 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:39:51 +0100 Subject: [PATCH 06/15] gitignore: clojure .cpcache tools-deps writes .cpcache into the framework directory on any classpath resolution, including from inside a container where the files land root owned. Two of them were picked up by a git add -A. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c4338978c..1463bb237 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ node_modules/ # Python bytecode; a .pyc was tracked and churned on every build __pycache__/ *.pyc + +# clojure tools-deps classpath cache +.cpcache/ From 8cee6ceadddbd2acab014d469e9bdd6e3261d2a6 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 18:50:18 +0100 Subject: [PATCH 07/15] json-tls for nestjs and sisk nestjs the adapter's instance is the very Express app Nest wired the controllers and compression onto, so putting it behind node:https serves the same pipeline rather than a second copy. sisk Cadente applies the certificate to the whole listening host rather than per port -- setting SslOptions, or calling UseSsl, on the plaintext host turns 8080 into HTTPS and it starts 301-ing plaintext to https. Verified by running it: the log lists both ports as https:// and curl gets a redirect. Two ListeningHosts do not work either, since a Router binds to one host and sharing an instance leaves both serving nothing. So TLS runs as its own server with its own Router, and BuildRouter() is now the single definition both are built from. validate: sisk 47/0, nestjs 32 passed / 5 failed. The nestjs failures are the POST TCP-fragmentation checks and are pre-existing: main without this change fails the same five (29/5, the three extra passes here being the json-tls checks). Not addressed here. --- frameworks/nestjs/meta.json | 1 + frameworks/nestjs/src/main.ts | 16 ++- frameworks/sisk/Program.cs | 201 ++++++++++++++++++++-------------- frameworks/sisk/meta.json | 3 +- 4 files changed, 137 insertions(+), 84 deletions(-) diff --git a/frameworks/nestjs/meta.json b/frameworks/nestjs/meta.json index bd4292108..120c77981 100644 --- a/frameworks/nestjs/meta.json +++ b/frameworks/nestjs/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/nestjs/src/main.ts b/frameworks/nestjs/src/main.ts index 1be7556ac..504142bef 100644 --- a/frameworks/nestjs/src/main.ts +++ b/frameworks/nestjs/src/main.ts @@ -1,7 +1,8 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import cluster from 'node:cluster'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import { createServer as createHttpsServer } from 'node:https'; import os from 'node:os'; import compression from 'compression'; import { AppModule } from './app.module'; @@ -25,6 +26,19 @@ async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false, logger: false }); app.use(compression()); await app.listen(8080); + + // json-tls on 8081. The adapter's instance is the very Express app Nest just + // wired the controllers and compression onto, so putting it behind node:https + // serves the same pipeline rather than a second copy. Certs are only mounted + // for the TLS profiles, hence the guard. + const cert = '/certs/server.crt'; + const key = '/certs/server.key'; + if (existsSync(cert) && existsSync(key)) { + createHttpsServer( + { key: readFileSync(key), cert: readFileSync(cert) }, + app.getHttpAdapter().getInstance(), + ).listen(8081); + } } if (cluster.isPrimary) { diff --git a/frameworks/sisk/Program.cs b/frameworks/sisk/Program.cs index 4f669524e..12886d813 100644 --- a/frameworks/sisk/Program.cs +++ b/frameworks/sisk/Program.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Net.Http.Json; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Npgsql; using sisk; @@ -16,93 +17,129 @@ config.EnableAutomaticResponseCompression = true; } ); -Router router = new Router (); -var staticRoute = HttpFileServer.CreateServingRoute ( "/static", new HttpFileServerHandler () { - RootDirectoryPath = "/data/static", - AllowDirectoryListing = false -} ); - -router.SetRoute ( staticRoute ); - -router.MapGet ( "/baseline11", r => new HttpResponse ( Sum ( r ) ) ); -router.MapPost ( "/baseline11", r => new HttpResponse ( Sum ( r ) ) ); - -router.MapGet ( "/baseline2", r => new HttpResponse ( Sum ( r ) ) ); - -router.MapGet ( "/pipeline", r => new HttpResponse ( "ok" ) ); - -router.MapPost ( "/upload", r => { - var body = r.GetBodyContents (); - return new HttpResponse ( body.Length.ToString () ); -} ); - -var datasetItems = LoadItems (); - -router.MapGet ( "/json/", r => { - int count = Math.Clamp ( int.Parse ( r.RouteParameters [ "count" ].GetString () ), 0, datasetItems!.Count ); - int m = 1; - if (r.Query.TryGetValue ( "m", out var mStr )) { int.TryParse ( mStr, out m ); if (m == 0) m = 1; } - var processed = new ProcessedItem [ count ]; - - for (int i = 0; i < count; i++) { - var d = datasetItems [ i ]; - processed [ i ] = new ProcessedItem { - Id = d.Id, - Name = d.Name, - Category = d.Category, - Price = d.Price, - Quantity = d.Quantity, - Active = d.Active, - Tags = d.Tags, - Rating = d.Rating, - Total = d.Price * d.Quantity * m +// Sisk binds a Router to one listening host, and Cadente applies TLS per host +// rather than per port, so json-tls needs a second host with its own Router. +// This is the single definition both hosts are built from -- neither port can +// drift from the other. +Router BuildRouter () { + Router router = new Router (); + + var staticRoute = HttpFileServer.CreateServingRoute ( "/static", new HttpFileServerHandler () { + RootDirectoryPath = "/data/static", + AllowDirectoryListing = false + } ); + + router.SetRoute ( staticRoute ); + + router.MapGet ( "/baseline11", r => new HttpResponse ( Sum ( r ) ) ); + router.MapPost ( "/baseline11", r => new HttpResponse ( Sum ( r ) ) ); + + router.MapGet ( "/baseline2", r => new HttpResponse ( Sum ( r ) ) ); + + router.MapGet ( "/pipeline", r => new HttpResponse ( "ok" ) ); + + router.MapPost ( "/upload", r => { + var body = r.GetBodyContents (); + return new HttpResponse ( body.Length.ToString () ); + } ); + + var datasetItems = LoadItems (); + + router.MapGet ( "/json/", r => { + int count = Math.Clamp ( int.Parse ( r.RouteParameters [ "count" ].GetString () ), 0, datasetItems!.Count ); + int m = 1; + if (r.Query.TryGetValue ( "m", out var mStr )) { int.TryParse ( mStr, out m ); if (m == 0) m = 1; } + var processed = new ProcessedItem [ count ]; + + for (int i = 0; i < count; i++) { + var d = datasetItems [ i ]; + processed [ i ] = new ProcessedItem { + Id = d.Id, + Name = d.Name, + Category = d.Category, + Price = d.Price, + Quantity = d.Quantity, + Active = d.Active, + Tags = d.Tags, + Rating = d.Rating, + Total = d.Price * d.Quantity * m + }; + } + + return new HttpResponse { + Content = JsonContent.Create ( new ListWithCount ( processed.ToList () ) ) }; - } + } ); + + var pgDataSource = OpenPgPool (); + + router.MapGet ( "/async-db", async ( HttpRequest request ) => { + var min = request.Query.TryGetValue ( "min", out var vmin ) ? vmin.GetInteger () : 10; + var max = request.Query.TryGetValue ( "max", out var vmax ) ? vmax.GetInteger () : 50; + var limit = request.Query.TryGetValue ( "limit", out var vlim ) ? Math.Clamp ( vlim.GetInteger (), 1, 50 ) : 50; + + Debug.Assert ( pgDataSource != null, "PostgreSQL data source is not available. Please set the DATABASE_URL environment variable." ); + + await using var cmd = pgDataSource.CreateCommand ( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3" ); + + cmd.Parameters.AddWithValue ( min ); + cmd.Parameters.AddWithValue ( max ); + cmd.Parameters.AddWithValue ( limit ); + await using var reader = await cmd.ExecuteReaderAsync (); + + var items = new List (); + + while (await reader.ReadAsync ()) { + items.Add ( new { + id = reader.GetInt32 ( 0 ), + name = reader.GetString ( 1 ), + category = reader.GetString ( 2 ), + price = reader.GetInt32 ( 3 ), + quantity = reader.GetInt32 ( 4 ), + active = reader.GetBoolean ( 5 ), + tags = JsonSerializer.Deserialize> ( reader.GetString ( 6 ) ), + rating = new { score = reader.GetInt32 ( 7 ), count = reader.GetInt32 ( 8 ) }, + } ); + } + + return new HttpResponse { + Content = JsonContent.Create ( new ListWithCount ( items ) ) + }; + } ); - return new HttpResponse { - Content = JsonContent.Create ( new ListWithCount ( processed.ToList () ) ) - }; -} ); - -var pgDataSource = OpenPgPool (); - -router.MapGet ( "/async-db", async ( HttpRequest request ) => { - var min = request.Query.TryGetValue ( "min", out var vmin ) ? vmin.GetInteger () : 10; - var max = request.Query.TryGetValue ( "max", out var vmax ) ? vmax.GetInteger () : 50; - var limit = request.Query.TryGetValue ( "limit", out var vlim ) ? Math.Clamp ( vlim.GetInteger (), 1, 50 ) : 50; - - Debug.Assert ( pgDataSource != null, "PostgreSQL data source is not available. Please set the DATABASE_URL environment variable." ); - - await using var cmd = pgDataSource.CreateCommand ( - "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3" ); - - cmd.Parameters.AddWithValue ( min ); - cmd.Parameters.AddWithValue ( max ); - cmd.Parameters.AddWithValue ( limit ); - await using var reader = await cmd.ExecuteReaderAsync (); - - var items = new List (); - - while (await reader.ReadAsync ()) { - items.Add ( new { - id = reader.GetInt32 ( 0 ), - name = reader.GetString ( 1 ), - category = reader.GetString ( 2 ), - price = reader.GetInt32 ( 3 ), - quantity = reader.GetInt32 ( 4 ), - active = reader.GetBoolean ( 5 ), - tags = JsonSerializer.Deserialize> ( reader.GetString ( 6 ) ), - rating = new { score = reader.GetInt32 ( 7 ), count = reader.GetInt32 ( 8 ) }, - } ); - } + return router; +} - return new HttpResponse { - Content = JsonContent.Create ( new ListWithCount ( items ) ) - }; -} ); +server.UseRouter ( BuildRouter () ); + +// json-tls on 8081. Cadente applies the certificate to the whole listening host +// rather than to individual ports -- setting SslOptions (or calling UseSsl) on +// the plaintext host turns 8080 into HTTPS too and it starts 301-ing plaintext +// to https. So TLS runs as its own server with its own Router built from the +// same BuildRouter(). The harness only mounts /certs for the TLS profiles, so +// without them the second server is never created. +if (File.Exists ( "/certs/server.crt" ) && File.Exists ( "/certs/server.key" )) { + // A CreateFromPemFile certificate carries its key ephemerally, which + // SslStream will not accept, so it is round-tripped through PKCS12. + using var pem = X509Certificate2.CreateFromPemFile ( "/certs/server.crt", "/certs/server.key" ); + var tlsCertificate = X509CertificateLoader.LoadPkcs12 ( pem.Export ( X509ContentType.Pkcs12 ), null ); + + var tlsServer = HttpServer.CreateBuilder () + .UseEngine () + .UseListeningPort ( new ListeningPort ( true, "0.0.0.0", 8081 ) ) + .UseMinimalConfiguration () + .UseConfiguration ( config => { + config.EnableAutomaticResponseCompression = true; + } ) + .UseSsl ( tlsCertificate ) + .UseRouter ( BuildRouter () ); + + _ = tlsServer.Build ().StartAsync (); +} -await server.UseRouter ( router ).Build ().StartAsync (); +await server.Build ().StartAsync (); return; diff --git a/frameworks/sisk/meta.json b/frameworks/sisk/meta.json index c3a6e7b68..55c99376d 100644 --- a/frameworks/sisk/meta.json +++ b/frameworks/sisk/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "static", @@ -26,4 +27,4 @@ "api-16" ], "maintainers": [] -} \ No newline at end of file +} From 3937274f6f3adab216d9ae2590d116422087d75d Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 19:02:54 +0100 Subject: [PATCH 08/15] json-tls for slim; record why sanic cannot have it slim runs on FrankenPHP, so json-tls is an 8081 site block with the same php_server as 8080 -- TLS terminated by the server the entry already uses. The site cannot be declared unconditionally: Caddy refuses to start when a tls directive points at files that are not there, and the harness mounts /certs only for the TLS profiles. Caught by running it -- the container exited outright with no certs mounted, which would have taken out every non-TLS profile. So the block ships as a separate file the entrypoint drops into an imported directory only when the certificate exists. Verified both ways: 8080 serves with no certs mounted, both ports serve with them. sanic keeps no json-tls, but the comment now records what was actually found: 25.3.0 binds and accepts on a TLS listener and then never sends a ServerHello, at any worker count, whether it is the only listener or a second prepare(). A second plaintext prepare() answers fine, so it is TLS specifically, and single_process=True serves it -- which would pin the entry to one core. A prebuilt SSLContext is not a way around it either, since the worker manager spawns and an SSLContext cannot be pickled. validate: slim 37/0 --- frameworks/sanic/app.py | 12 +++++++++--- frameworks/slim/Caddyfile | 6 ++++++ frameworks/slim/Dockerfile | 5 ++++- frameworks/slim/entrypoint.sh | 9 +++++++++ frameworks/slim/json-tls.caddy | 12 ++++++++++++ frameworks/slim/meta.json | 1 + 6 files changed, 41 insertions(+), 4 deletions(-) create mode 100755 frameworks/slim/entrypoint.sh create mode 100644 frameworks/slim/json-tls.caddy diff --git a/frameworks/sanic/app.py b/frameworks/sanic/app.py index cdfbdab91..042c33b18 100644 --- a/frameworks/sanic/app.py +++ b/frameworks/sanic/app.py @@ -348,9 +348,15 @@ async def compress_response(request, response): if __name__ == "__main__": # Sanic's own worker manager, not gunicorn: the main process opens the # listening socket and hands it to one worker process per core. - # Sanic serves one port per app.run(). A second app.prepare() for a TLS - # listener on 8081 starts without error but never answers, so json-tls and - # static-tls are left unsubscribed rather than shipped broken. + # json-tls and static-tls are unsubscribed: sanic 25.3.0 cannot serve TLS + # under its worker manager. The listener binds and accepts, then never + # sends a ServerHello -- at any worker count, and whether it is the only + # listener or a second prepare() alongside 8080. A second *plaintext* + # prepare() on another port answers fine, so it is TLS specifically. + # single_process=True does serve it, which would pin the entry to one core + # and publish a number that is not comparable to anything else here. + # (A prebuilt ssl.SSLContext is not an option either: the manager spawns, + # and an SSLContext cannot be pickled.) app.run( host="0.0.0.0", port=8080, diff --git a/frameworks/slim/Caddyfile b/frameworks/slim/Caddyfile index 035312ff0..a3718a898 100644 --- a/frameworks/slim/Caddyfile +++ b/frameworks/slim/Caddyfile @@ -12,3 +12,9 @@ file_server off } } + +# json-tls, when the TLS profiles mount the certificate. Caddy refuses to start +# if a tls directive points at files that are not there, so the 8081 site is +# dropped in by the entrypoint rather than declared unconditionally. A glob that +# matches nothing is not an error. +import /etc/caddy/tls.d/*.caddy diff --git a/frameworks/slim/Dockerfile b/frameworks/slim/Dockerfile index 1ba2d5943..f28701b88 100644 --- a/frameworks/slim/Dockerfile +++ b/frameworks/slim/Dockerfile @@ -15,6 +15,9 @@ COPY public ./public # with timestamp validation off as a PHP deployment would run it. COPY php.ini $PHP_INI_DIR/conf.d/99-httparena.ini COPY Caddyfile /etc/caddy/Caddyfile +COPY json-tls.caddy /etc/caddy/json-tls.caddy +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN mkdir -p /etc/caddy/tls.d && chmod +x /usr/local/bin/entrypoint.sh EXPOSE 8080 -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/frameworks/slim/entrypoint.sh b/frameworks/slim/entrypoint.sh new file mode 100755 index 000000000..de8d158d6 --- /dev/null +++ b/frameworks/slim/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# The harness mounts /certs only for the TLS profiles. Caddy will not start with +# a tls directive pointing at absent files, so the json-tls site is added only +# when the certificate is actually there. +set -e +if [ -f /certs/server.crt ] && [ -f /certs/server.key ]; then + cp /etc/caddy/json-tls.caddy /etc/caddy/tls.d/ +fi +exec frankenphp run --config /etc/caddy/Caddyfile --adapter caddyfile diff --git a/frameworks/slim/json-tls.caddy b/frameworks/slim/json-tls.caddy new file mode 100644 index 000000000..fc04289eb --- /dev/null +++ b/frameworks/slim/json-tls.caddy @@ -0,0 +1,12 @@ +# Same app, same php_server as :8080 -- TLS terminated by the server the entry +# already runs on. Copied into /etc/caddy/tls.d by the entrypoint only when the +# certificate is mounted. +:8081 { + tls /certs/server.crt /certs/server.key + encode br gzip + root * /app/public + php_server { + index index.php + file_server off + } +} diff --git a/frameworks/slim/meta.json b/frameworks/slim/meta.json index 4ebbdbaa5..d50bfd72a 100644 --- a/frameworks/slim/meta.json +++ b/frameworks/slim/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], From d0bc83bae4399d9e4c74f709f38be8ef085b612d Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 19:12:08 +0100 Subject: [PATCH 09/15] json-tls for hyperf A second Hyperf HTTP server on 8081 behind Swoole's own TLS. Three things the obvious version gets wrong, each found by running it: - Swoole port options go under 'settings'; 'options' is Hyperf's own and the ssl_cert_file/ssl_key_file put there never reach the port, which aborts startup with "require ssl_cert_file and ssl_key_file options". - Hyperf scopes routes to a server by name, so the TLS server needs the same set registered against it. routes.php now registers one closure twice. - Hyperf keys the ON_REQUEST callback by class, so pointing both servers at Hyperf\HttpServer\Server makes the second replace the first -- it logs "http will be replaced by http-tls" and then neither port answers. The TLS server gets its own Server subclass so it has its own router. The listener is only added when the certificate is mounted, which the harness does for the TLS profiles only; Swoole aborts at startup if a listener names certificate files that are not there. validate: hyperf 53/0 --- frameworks/hyperf/app/TlsServer.php | 22 +++++++++++++++ frameworks/hyperf/config/autoload/server.php | 28 ++++++++++++++++++++ frameworks/hyperf/config/routes.php | 10 +++++-- frameworks/hyperf/meta.json | 3 ++- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 frameworks/hyperf/app/TlsServer.php diff --git a/frameworks/hyperf/app/TlsServer.php b/frameworks/hyperf/app/TlsServer.php new file mode 100644 index 000000000..10d07506f --- /dev/null +++ b/frameworks/hyperf/app/TlsServer.php @@ -0,0 +1,22 @@ + 'http-tls', + 'type' => Server::SERVER_HTTP, + 'host' => '0.0.0.0', + 'port' => 8081, + 'sock_type' => SWOOLE_SOCK_TCP | SWOOLE_SSL, + 'callbacks' => [ + Event::ON_REQUEST => [App\TlsServer::class, 'onRequest'], + ], + 'options' => [ + 'enable_request_lifecycle' => false, + ], + // Swoole port options live under settings; options is Hyperf's own + 'settings' => [ + 'ssl_cert_file' => '/certs/server.crt', + 'ssl_key_file' => '/certs/server.key', + ], + ]; +} + return [ 'mode' => SWOOLE_PROCESS, 'servers' => [ @@ -45,6 +72,7 @@ Event::ON_CLOSE => [Hyperf\WebSocketServer\Server::class, 'onClose'], ], ], + ...$jsonTlsServer, ], 'settings' => [ Constant::OPTION_ENABLE_COROUTINE => true, diff --git a/frameworks/hyperf/config/routes.php b/frameworks/hyperf/config/routes.php index 56a7b78df..2bdca4bd6 100644 --- a/frameworks/hyperf/config/routes.php +++ b/frameworks/hyperf/config/routes.php @@ -13,13 +13,19 @@ use App\Controller\WebSocketController; use Hyperf\HttpServer\Router\Router; -Router::addServer('http', function () { +// Hyperf scopes routes to a server by name, so the json-tls listener needs the +// same set registered against its own server. One closure, registered twice, +// so the two ports cannot drift apart. +$httpRoutes = function () { Router::addRoute(['GET', 'POST'], '/baseline11', [IndexController::class, 'handleBaseline11']); Router::get('/pipeline', [IndexController::class, 'handlePipeline']); Router::get('/json/{count}', [IndexController::class, 'handleJson']); Router::post('/upload', [IndexController::class, 'handleUpload']); Router::get('/async-db', [IndexController::class, 'handleAsyncDb']); -}); +}; + +Router::addServer('http', $httpRoutes); +Router::addServer('http-tls', $httpRoutes); Router::addServer('ws', function () { Router::get('/ws', WebSocketController::class); diff --git a/frameworks/hyperf/meta.json b/frameworks/hyperf/meta.json index 648b18a3f..8b7d17bec 100644 --- a/frameworks/hyperf/meta.json +++ b/frameworks/hyperf/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "static", @@ -30,4 +31,4 @@ "maintainers": [ "suyar" ] -} \ No newline at end of file +} From 5daa461ec32c7eaed6e053cb01c7bdef01d04f9f Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 19:16:40 +0100 Subject: [PATCH 10/15] json-tls for laravel Octane's Caddyfile carries a {$CADDY_EXTRA_CONFIG} placeholder that Octane itself never sets, so Caddy resolves it from the process environment. The json-tls site goes there -- same worker, same app as 8080, only the listener differs -- rather than forking Octane's Caddyfile through --caddyfile. Two things worth recording, both found by running it: - The site cannot be declared unconditionally. Caddy refuses to start when a tls directive points at files that are not there, and the harness mounts /certs only for the TLS profiles, so the entrypoint sets the variable only when the certificate exists. Verified 8080 still serves with none mounted. - root * "{$APP_PUBLIC_PATH}" does not work inside the extra config. Octane substitutes that placeholder into its own template rather than exporting it, so in the extra config it resolves empty: TLS handshakes fine and then the request hangs with no response at all. The path is written out. validate: laravel 37/0 --- frameworks/laravel/Dockerfile | 7 +++++-- frameworks/laravel/entrypoint.sh | 28 ++++++++++++++++++++++++++++ frameworks/laravel/meta.json | 1 + 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100755 frameworks/laravel/entrypoint.sh diff --git a/frameworks/laravel/Dockerfile b/frameworks/laravel/Dockerfile index 106cb394b..5781a3082 100644 --- a/frameworks/laravel/Dockerfile +++ b/frameworks/laravel/Dockerfile @@ -22,5 +22,8 @@ ENV APP_ENV=production \ LOG_CHANNEL=null \ SERVER_NAME=:8080 -EXPOSE 8080 -CMD ["php", "artisan", "octane:start", "--server=frankenphp", "--host=0.0.0.0", "--port=8080", "--admin-port=2019"] +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 8080 8081 +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/frameworks/laravel/entrypoint.sh b/frameworks/laravel/entrypoint.sh new file mode 100755 index 000000000..eccdfd539 --- /dev/null +++ b/frameworks/laravel/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# json-tls on 8081. Octane's Caddyfile carries a {$CADDY_EXTRA_CONFIG} +# placeholder that Octane itself never sets, so Caddy resolves it from the +# environment -- which is where the TLS site goes. Same worker, same app as +# 8080; only the listener differs. +# +# It has to be conditional: Caddy refuses to start when a tls directive points +# at files that are not there, and the harness mounts /certs only for the TLS +# profiles. +set -e + +if [ -f /certs/server.crt ] && [ -f /certs/server.key ]; then + CADDY_EXTRA_CONFIG=':8081 { + tls /certs/server.crt /certs/server.key + route { + root * /app/public + encode zstd br gzip + php_server { + index frankenphp-worker.php + try_files {path} frankenphp-worker.php + resolve_root_symlink + } + } +}' + export CADDY_EXTRA_CONFIG +fi + +exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8080 --admin-port=2019 diff --git a/frameworks/laravel/meta.json b/frameworks/laravel/meta.json index 44f82610d..d5e6523ce 100644 --- a/frameworks/laravel/meta.json +++ b/frameworks/laravel/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], From ea689950c341bb26dae71e0164a201e50c37203f Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 19:24:08 +0100 Subject: [PATCH 11/15] json-tls for drogon, lapis and mojolicious drogon addListener on 8081 with TLS, same handlers as 8080 mojolicious a second listen URL on the same Mojo::Server::Prefork lapis an ssl server block running the same lapis app All three abort at startup on a tls/ssl directive naming certificate files that are not there, and the harness mounts /certs only for the TLS profiles, so each is conditional -- drogon and mojolicious check in code, and nginx cannot, so lapis's block ships as a file the entrypoint drops into an included directory. Each verified running both ways: 8080 serves with no certs mounted, both ports serve with them. validate: drogon 37/0, lapis 37/0, mojolicious 37/0 --- frameworks/drogon/main.cc | 11 +++++++++++ frameworks/drogon/meta.json | 1 + frameworks/lapis/Dockerfile | 6 +++++- frameworks/lapis/entrypoint.sh | 9 +++++++++ frameworks/lapis/json-tls.conf | 14 ++++++++++++++ frameworks/lapis/meta.json | 1 + frameworks/lapis/nginx.conf | 6 ++++++ frameworks/mojolicious/app.pl | 11 ++++++++++- frameworks/mojolicious/meta.json | 1 + 9 files changed, 58 insertions(+), 2 deletions(-) create mode 100755 frameworks/lapis/entrypoint.sh create mode 100644 frameworks/lapis/json-tls.conf diff --git a/frameworks/drogon/main.cc b/frameworks/drogon/main.cc index d11454f25..ce5a9cb1c 100644 --- a/frameworks/drogon/main.cc +++ b/frameworks/drogon/main.cc @@ -1,3 +1,4 @@ +#include #include #include @@ -109,6 +110,16 @@ int main() }, {Post}); + // json-tls on 8081, served by the same handlers as 8080 through Drogon's + // own TLS listener. The harness mounts /certs for the TLS profiles only, + // so without them the listener is not added. + const std::string certFile = "/certs/server.crt"; + const std::string keyFile = "/certs/server.key"; + if (std::filesystem::exists(certFile) && std::filesystem::exists(keyFile)) + { + app().addListener("0.0.0.0", 8081, true, certFile, keyFile); + } + app().setLogLevel(trantor::Logger::kError) .addListener("0.0.0.0", 8080) .setThreadNum(0) diff --git a/frameworks/drogon/meta.json b/frameworks/drogon/meta.json index 6b8d36447..2ec4e8da4 100644 --- a/frameworks/drogon/meta.json +++ b/frameworks/drogon/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/lapis/Dockerfile b/frameworks/lapis/Dockerfile index c722c1f3c..e345719e3 100644 --- a/frameworks/lapis/Dockerfile +++ b/frameworks/lapis/Dockerfile @@ -10,4 +10,8 @@ RUN mkdir -p logs ENV LAPIS_ENVIRONMENT=production EXPOSE 8080 -CMD ["openresty", "-p", "/app", "-c", "/app/nginx.conf", "-g", "daemon off;"] +COPY json-tls.conf /app/json-tls.conf +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN mkdir -p /app/tls.d && chmod +x /usr/local/bin/entrypoint.sh + +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/frameworks/lapis/entrypoint.sh b/frameworks/lapis/entrypoint.sh new file mode 100755 index 000000000..36552d1ee --- /dev/null +++ b/frameworks/lapis/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# The harness mounts /certs only for the TLS profiles, and nginx will not start +# with an ssl_certificate pointing at an absent file, so the json-tls server +# block is added only when the certificate is actually there. +set -e +if [ -f /certs/server.crt ] && [ -f /certs/server.key ]; then + cp /app/json-tls.conf /app/tls.d/ +fi +exec openresty -p /app -c /app/nginx.conf -g "daemon off;" diff --git a/frameworks/lapis/json-tls.conf b/frameworks/lapis/json-tls.conf new file mode 100644 index 000000000..3ea0b7897 --- /dev/null +++ b/frameworks/lapis/json-tls.conf @@ -0,0 +1,14 @@ +# Same app as :8080, over TLS. Copied into /app/tls.d by the entrypoint only +# when the certificate is mounted. +server { + listen 8081 ssl reuseport backlog=16384; + + ssl_certificate /certs/server.crt; + ssl_certificate_key /certs/server.key; + + location / { + content_by_lua_block { + require("lapis").serve("app") + } + } +} diff --git a/frameworks/lapis/meta.json b/frameworks/lapis/meta.json index 1390549a9..f9f017351 100644 --- a/frameworks/lapis/meta.json +++ b/frameworks/lapis/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], diff --git a/frameworks/lapis/nginx.conf b/frameworks/lapis/nginx.conf index 70c52e3d6..8d8e4d544 100644 --- a/frameworks/lapis/nginx.conf +++ b/frameworks/lapis/nginx.conf @@ -28,4 +28,10 @@ http { } } } + + # json-tls, when the TLS profiles mount the certificate. nginx refuses to + # start if ssl_certificate points at a file that is not there, so the server + # block is dropped in by the entrypoint rather than declared unconditionally. + # A wildcard include matching nothing is not an error. + include /app/tls.d/*.conf; } diff --git a/frameworks/mojolicious/app.pl b/frameworks/mojolicious/app.pl index 37f5da61d..2d7568121 100644 --- a/frameworks/mojolicious/app.pl +++ b/frameworks/mojolicious/app.pl @@ -151,9 +151,18 @@ ($c) app->log->level('info'); +# json-tls on 8081: the same app, over Mojo's own TLS listener. The harness +# mounts /certs for the TLS profiles only, so without them only 8080 is opened +# -- Mojo aborts at startup on a listen URL naming certificate files that are +# not there. +my @listen = ('http://*:8080'); +if (-f '/certs/server.crt' && -f '/certs/server.key') { + push @listen, 'https://*:8081?cert=/certs/server.crt&key=/certs/server.key'; +} + Mojo::Server::Prefork->new( app => app, - listen => ['http://*:8080'], + listen => \@listen, workers => cpu_count(), # A worker is not recycled in the middle of a run: limited-conn opens a new diff --git a/frameworks/mojolicious/meta.json b/frameworks/mojolicious/meta.json index be4242e27..f51fd8cff 100644 --- a/frameworks/mojolicious/meta.json +++ b/frameworks/mojolicious/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], From 60102b737f7738718be3732e7aa708a00846921e Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 22 Aug 2026 19:25:17 +0100 Subject: [PATCH 12/15] json-tls for h2o-mruby An 8081 listener inside the existing cert-conditional block. 8443 advertises h2 through ALPN, so it cannot double as the HTTP/1.1 listener. Also adds libbrotli1 to the runtime image. The build stage installs libbrotli-dev, so h2o links against brotli, but the runtime stage only carried libssl3 and jq -- the container died on startup with "error while loading shared libraries: libbrotlidec.so.1". Pre-existing and unrelated to json-tls: the entry builds h2o from a --depth 1 clone of upstream master, so it picks up whatever that links against today. validate: h2o-mruby 53/0 (the clang build of mruby SIGSEGVs at random on this machine and needed several attempts -- see the toolchain note; the tree itself builds clean) --- frameworks/h2o-mruby/Dockerfile | 5 ++++- frameworks/h2o-mruby/entrypoint.sh | 8 ++++++++ frameworks/h2o-mruby/meta.json | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frameworks/h2o-mruby/Dockerfile b/frameworks/h2o-mruby/Dockerfile index 3b39d4b1d..86e0b804d 100644 --- a/frameworks/h2o-mruby/Dockerfile +++ b/frameworks/h2o-mruby/Dockerfile @@ -16,8 +16,11 @@ RUN git clone --recurse-submodules --depth 1 https://github.com/h2o/h2o.git . && cmake --install build FROM debian:bookworm-slim +# libbrotli1 carries libbrotlidec/libbrotlienc: the build stage installs +# libbrotli-dev, so h2o links against brotli and will not start without it +# ("error while loading shared libraries: libbrotlidec.so.1"). RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl3 jq && \ + libssl3 libbrotli1 jq && \ rm -rf /var/lib/apt/lists/* COPY --from=build /usr/local/bin/h2o /usr/local/bin/h2o COPY --from=build /usr/local/share/h2o /usr/local/share/h2o diff --git a/frameworks/h2o-mruby/entrypoint.sh b/frameworks/h2o-mruby/entrypoint.sh index fe4193bc4..b73b3b89e 100644 --- a/frameworks/h2o-mruby/entrypoint.sh +++ b/frameworks/h2o-mruby/entrypoint.sh @@ -33,6 +33,14 @@ listen: &ssl_listen listen: <<: *ssl_listen type: quic + +# json-tls: HTTP/1.1 over TLS on its own port. 8443 advertises h2 through +# ALPN, so it cannot double as the HTTP/1.1 listener. +listen: + port: 8081 + ssl: + certificate-file: ${CERT_FILE} + key-file: ${KEY_FILE} EOF fi diff --git a/frameworks/h2o-mruby/meta.json b/frameworks/h2o-mruby/meta.json index 7f0b65c5f..ea474d735 100644 --- a/frameworks/h2o-mruby/meta.json +++ b/frameworks/h2o-mruby/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "static", @@ -27,4 +28,4 @@ "static-h3" ], "maintainers": [] -} \ No newline at end of file +} From d05b7042f80257be61901d4db2114c0fc6e06819 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sun, 23 Aug 2026 03:00:37 +0100 Subject: [PATCH 13/15] json-tls for warp and humming-bird warp the same handler behind warp-tls on a forked thread; Debian ships libghc-warp-tls-dev, so it is one more build dep humming-bird a second Application over the same router, using Hummingbird's own TLS server. One Application binds one address, so the TLS listener cannot share the first. ALPN is pinned to http/1.1 so json-tls does not fall into h2. Both guarded on the PEMs, which the harness only mounts for TLS profiles. validate: warp 37/0; humming-bird 46 passed / 1 failed. The humming-bird failure is the static staleness probe and is pre-existing: the entry loads every static file into an in-memory [String: StaticFile] map at startup, .br and .gz buffers included, so it never revalidates against disk. That is a cache assembled in the entry, which both the standard and tuned rules exclude, and it fails the same way on main. Untouched here -- this change only adds a listener -- but it needs its own fix. --- frameworks/humming-bird/Package.swift | 1 + frameworks/humming-bird/meta.json | 3 +- frameworks/humming-bird/src/main.swift | 44 +++++++++++++++++++++++++- frameworks/warp/Dockerfile | 2 ++ frameworks/warp/Main.hs | 21 ++++++++++++ frameworks/warp/meta.json | 1 + 6 files changed, 70 insertions(+), 2 deletions(-) diff --git a/frameworks/humming-bird/Package.swift b/frameworks/humming-bird/Package.swift index 3f7f9ab98..a48d4befb 100644 --- a/frameworks/humming-bird/Package.swift +++ b/frameworks/humming-bird/Package.swift @@ -24,6 +24,7 @@ let package = Package( dependencies: [ "CSQLite", .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdTLS", package: "hummingbird"), .product(name: "HummingbirdCompression", package: "hummingbird-compression"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), diff --git a/frameworks/humming-bird/meta.json b/frameworks/humming-bird/meta.json index 610657f7c..d8ec19e80 100644 --- a/frameworks/humming-bird/meta.json +++ b/frameworks/humming-bird/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "api-4", @@ -26,4 +27,4 @@ "static" ], "maintainers": [] -} \ No newline at end of file +} diff --git a/frameworks/humming-bird/src/main.swift b/frameworks/humming-bird/src/main.swift index 5c1ba8c1f..521b4ef1e 100644 --- a/frameworks/humming-bird/src/main.swift +++ b/frameworks/humming-bird/src/main.swift @@ -1,6 +1,8 @@ import Foundation import Hummingbird import HummingbirdCompression +import HummingbirdTLS +import NIOSSL import NIOCore import NIOFoundationCompat import PostgresNIO @@ -442,4 +444,44 @@ var app = Application( if let client = pgClient { app.addServices(client) } -try await app.runService() + +// json-tls on 8081: the same router behind Hummingbird's own TLS server. One +// Application binds one address, so the TLS listener is a second Application +// over that same router rather than a copy of the routes. The harness only +// mounts /certs for the TLS profiles, so without them only 8080 comes up. +func loadTLSConfiguration() -> TLSConfiguration? { + let certPath = "/certs/server.crt" + let keyPath = "/certs/server.key" + guard FileManager.default.fileExists(atPath: certPath), + FileManager.default.fileExists(atPath: keyPath) + else { return nil } + + do { + let chain = try NIOSSLCertificate.fromPEMFile(certPath).map { + NIOSSLCertificateSource.certificate($0) + } + let key = try NIOSSLPrivateKey(file: keyPath, format: .pem) + var configuration = TLSConfiguration.makeServerConfiguration( + certificateChain: chain, + privateKey: .privateKey(key) + ) + // http/1.1 only: json-tls requires the ALPN not to fall into h2. + configuration.applicationProtocols = ["http/1.1"] + return configuration + } catch { + return nil + } +} + +if let tlsConfiguration = loadTLSConfiguration() { + let tlsApp = Application( + router: router, + server: try .tls(tlsConfiguration: tlsConfiguration), + configuration: .init(address: .hostname("0.0.0.0", port: 8081), serverName: "hummingbird") + ) + async let tls: Void = tlsApp.runService() + async let plain: Void = app.runService() + _ = try await (tls, plain) +} else { + try await app.runService() +} diff --git a/frameworks/warp/Dockerfile b/frameworks/warp/Dockerfile index cc5b0e9b7..45cb609cb 100644 --- a/frameworks/warp/Dockerfile +++ b/frameworks/warp/Dockerfile @@ -2,6 +2,8 @@ FROM debian:trixie-slim AS build RUN apt-get update && apt-get install -y --no-install-recommends \ ghc \ libghc-warp-dev \ + libghc-warp-tls-dev \ + libghc-directory-dev \ libghc-wai-dev \ libghc-wai-extra-dev \ libghc-http-types-dev \ diff --git a/frameworks/warp/Main.hs b/frameworks/warp/Main.hs index 76c780e58..2b00ad5bb 100644 --- a/frameworks/warp/Main.hs +++ b/frameworks/warp/Main.hs @@ -5,7 +5,9 @@ module Main (main) where +import Control.Concurrent (forkIO) import Control.Exception (SomeException, try) +import Control.Monad (void, when) import Data.Aeson (FromJSON (..), (.:)) import qualified Data.Aeson as A import qualified Data.Aeson.Encoding as E @@ -25,7 +27,9 @@ import Network.HTTP.Types (Query, hContentLength, import Network.Wai import Network.Wai.Handler.Warp (defaultSettings, runSettings, setPort) +import Network.Wai.Handler.WarpTLS (runTLS, tlsSettings) import Network.Wai.Middleware.Gzip (defaultGzipSettings, gzip) +import System.Directory (doesFileExist) import System.Environment (lookupEnv) import System.IO (hPutStrLn, stderr) @@ -210,4 +214,21 @@ main = do -- standard mode: compression is the stock wai-extra Gzip middleware with its -- defaults, so it only fires when the request negotiates it. let handler = gzip defaultGzipSettings (app items total) + + -- json-tls on 8081: the same handler behind warp-tls. The harness only mounts + -- /certs for the TLS profiles, so without them only 8080 is opened. + hasCert <- doesFileExist tlsCertPath + hasKey <- doesFileExist tlsKeyPath + when (hasCert && hasKey) $ + void $ forkIO $ + runTLS (tlsSettings tlsCertPath tlsKeyPath) + (setPort 8081 defaultSettings) + handler + runSettings (setPort 8080 defaultSettings) handler + +tlsCertPath :: FilePath +tlsCertPath = "/certs/server.crt" + +tlsKeyPath :: FilePath +tlsKeyPath = "/certs/server.key" diff --git a/frameworks/warp/meta.json b/frameworks/warp/meta.json index 84ac0730a..d700ef787 100644 --- a/frameworks/warp/meta.json +++ b/frameworks/warp/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload" ], From 4320f2deb654cfe38c262d577b81eeaf2c1b4b99 Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sun, 23 Aug 2026 03:05:31 +0100 Subject: [PATCH 14/15] json-tls for slimeweb; record why robyn cannot have it slimeweb's serve() makes its own listener TLS rather than adding one, so the second port needs a second serve(). It cannot share the process: asyncpg pools are bound to the loop that created them, and @app.start() runs per serve, so the second pool lands on the other loop and /async-db dies with "got Future attached to a different loop". Measured, not guessed -- the threaded version validated 51 passed / 6 failed against a 51/0 baseline. So the TLS listener runs as its own process, started without DATABASE_URL so it creates no pool at all. 8081 only carries json-tls, which never touches the database, and the connection budget is untouched -- the harness runs Postgres with max_connections=256 and this pool already asks for that many. robyn keeps no json-tls: 0.83.0 has no TLS support at all. start() takes host, port, _check_port, client_timeout and keep_alive_timeout, and there is no ssl/certificate/keyfile reference anywhere in the package. validate: slimeweb 54/0 (51 baseline + the three json-tls checks) --- frameworks/robyn/app.py | 5 +++++ frameworks/slimeweb/main.py | 37 +++++++++++++++++++++++++++++++++-- frameworks/slimeweb/meta.json | 5 ++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/frameworks/robyn/app.py b/frameworks/robyn/app.py index b762bc899..66542b9ab 100644 --- a/frameworks/robyn/app.py +++ b/frameworks/robyn/app.py @@ -91,5 +91,10 @@ def upload_endpoint(request: Request): # -- APP executor ----------------------------------------------------------- if __name__ == "__main__": + # json-tls is unsubscribed: robyn 0.83.0 has no TLS support at all. start() + # takes host, port, _check_port, client_timeout and keep_alive_timeout and + # nothing else, and there is no ssl/certificate/keyfile reference anywhere + # in the package. Serving HTTPS would mean putting another server in front, + # which is not this entry. app.start(host="0.0.0.0", port=8080) diff --git a/frameworks/slimeweb/main.py b/frameworks/slimeweb/main.py index d81ace645..fcbe644c6 100644 --- a/frameworks/slimeweb/main.py +++ b/frameworks/slimeweb/main.py @@ -1,8 +1,10 @@ import json import os +import subprocess +import sys import asyncpg as pg -from slimeweb import Slime, SlimeCompression +from slimeweb import Slime, SlimeCompression, SlimeTls app = Slime(__file__) @@ -145,4 +147,35 @@ async def init(): if __name__ == "__main__": - app.serve(host="0.0.0.0", port=8080, static_path="/data/static") + tls_cert = "/certs/server.crt" + tls_key = "/certs/server.key" + + # json-tls on 8081. serve() owns an event loop and makes its own listener + # TLS rather than adding one, so the second port needs a second serve() -- + # and it cannot share this process: asyncpg pools are bound to the loop that + # created them, so handlers on the other loop fail with "got Future attached + # to a different loop" and /async-db returns nothing. + # + # So the TLS listener is its own process, started without DATABASE_URL so it + # creates no pool at all. 8081 only carries json-tls, which never touches + # the database, and the connection budget is left exactly as it was -- the + # harness runs Postgres with max_connections=256 and this pool already asks + # for that many. + # + # The harness only mounts /certs for the TLS profiles, so on every other + # profile no child is started. + if os.environ.get("HTTPARENA_TLS_LISTENER") == "1": + app.serve( + host="0.0.0.0", + port=8081, + static_path="/data/static", + https=SlimeTls(cert=tls_cert, key=tls_key), + ) + else: + if os.path.exists(tls_cert) and os.path.exists(tls_key): + child_env = os.environ.copy() + child_env["HTTPARENA_TLS_LISTENER"] = "1" + child_env.pop("DATABASE_URL", None) + subprocess.Popen([sys.executable, __file__], env=child_env) + + app.serve(host="0.0.0.0", port=8080, static_path="/data/static") diff --git a/frameworks/slimeweb/meta.json b/frameworks/slimeweb/meta.json index c5c4d4a3d..0bd2d9fca 100644 --- a/frameworks/slimeweb/meta.json +++ b/frameworks/slimeweb/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "echo-ws", @@ -27,5 +28,7 @@ "static", "async-db" ], - "maintainers": ["ATOMMAX-2001"] + "maintainers": [ + "ATOMMAX-2001" + ] } From ec178eb5499dbc90250c2fb56f2bbae32bccb17f Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sun, 23 Aug 2026 03:10:28 +0100 Subject: [PATCH 15/15] json-tls for frankenphp-trueasync An :8081 site alongside the existing :8443, same worker as :8080, with the ALPN pinned to http/1.1 so json-tls does not fall into h2. /certs is mounted for every profile (scripts/lib/framework.sh), which is why :8443 is already unconditional and this can be too. Also adds libwebpdemux2 and libwebpmux3. The frankenphp binary in trueasync/php-true-async:latest-frankenphp links libwebp but the image does not carry it, so the container exited at startup with "error while loading shared libraries: libwebpdemux.so.2" -- the entry could not run at all. Found the full set with ldd on the binary rather than one failure at a time. The tag is :latest, so this arrives with whatever upstream last pushed. validate: 55 passed / 1 failed. The failure is the static staleness probe, and it is pre-existing: worker.php scandirs /data/static at startup and file_get_contents each file, .br and .gz included, into $staticFiles. That is a cache assembled in the entry, which the rules exclude, and it never revalidates against disk. Untouched here. symfony-spawn-franken is deliberately absent: it does not build on main either -- composer exits 127 in the same base image -- so its json-tls could not be validated. --- frameworks/frankenphp-trueasync/Caddyfile | 22 ++++++++++++++++++++++ frameworks/frankenphp-trueasync/Dockerfile | 8 ++++++++ frameworks/frankenphp-trueasync/meta.json | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/frameworks/frankenphp-trueasync/Caddyfile b/frameworks/frankenphp-trueasync/Caddyfile index 7e0907e9e..376dd343c 100644 --- a/frameworks/frankenphp-trueasync/Caddyfile +++ b/frameworks/frankenphp-trueasync/Caddyfile @@ -35,3 +35,25 @@ } } } + +# json-tls: the same worker as :8080, over TLS. /certs is mounted for every +# profile (scripts/lib/framework.sh), which is why :8443 above is unconditional +# too. +:8081 { + tls /certs/server.crt /certs/server.key { + # json-tls is HTTP/1.1 over TLS; 8443 keeps the h2 ALPN for its own profiles + alpn http/1.1 + } + root * /app + php_server { + index off + file_server off + worker { + file /app/worker.php + num {$WORKERS:0} + async + buffer_size 1 + match /* + } + } +} diff --git a/frameworks/frankenphp-trueasync/Dockerfile b/frameworks/frankenphp-trueasync/Dockerfile index 82b14ec76..a3b76ca5e 100644 --- a/frameworks/frankenphp-trueasync/Dockerfile +++ b/frameworks/frankenphp-trueasync/Dockerfile @@ -1,5 +1,13 @@ FROM trueasync/php-true-async:latest-frankenphp +# The frankenphp binary in this image links libwebp, but the image does not +# carry it -- it exits at startup with "error while loading shared libraries: +# libwebpdemux.so.2". The tag is :latest, so this arrives with whatever +# upstream last pushed rather than with anything in this entry. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libwebpdemux2 libwebpmux3 libwebp7 && \ + rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY worker.php /app/worker.php diff --git a/frameworks/frankenphp-trueasync/meta.json b/frameworks/frankenphp-trueasync/meta.json index 7371bb4f1..e0785c8e0 100644 --- a/frameworks/frankenphp-trueasync/meta.json +++ b/frameworks/frankenphp-trueasync/meta.json @@ -18,6 +18,7 @@ "pipelined", "limited-conn", "json", + "json-tls", "json-comp", "upload", "static", @@ -30,4 +31,4 @@ "static-h3" ], "maintainers": [] -} \ No newline at end of file +}