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/ 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/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/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/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 +} 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 +} 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/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/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/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 +} 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/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/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" ], 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); + } } 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" ], 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/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" ], 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" ], 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)))) 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/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(()) +} 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/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 +} 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" ], 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" + ] } 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" ],