Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fd19fe0
feat: add HTTP/3 (QUIC over UDP) opt-in support for proxy hosts
RicoUHD May 26, 2026
feeea1f
fix: allow http3_support in payload schemas & convert scripts to LF
RicoUHD May 26, 2026
91f6463
test: execute Stone-Breaker HTTP/3 stress protocol & shift sandbox ports
RicoUHD May 26, 2026
45b4511
test: add E2E sandbox reconfigure verification script
RicoUHD May 26, 2026
fcaeadb
chore: clean up local sandbox testing configuration and scripts
RicoUHD May 26, 2026
644415d
chore: harden HTTP/3 implementation based on architectural review
RicoUHD May 26, 2026
2c60c3b
fix: resolve critical and significant HTTP/3 merge blockers
RicoUHD May 26, 2026
064636a
fix: resolve sed idempotency blocker and remove dead server-level Alt…
RicoUHD May 26, 2026
a0cf709
chore: configure root docker-compose to build from local context
RicoUHD May 26, 2026
693a6e4
chore: restore production image in root docker-compose.yml
RicoUHD May 26, 2026
38549b2
Merge pull request #1 from RicoUHD/develop
RicoUHD May 26, 2026
744018e
chore: restore upstream integration testing configurations for CI com…
Copilot May 26, 2026
4a4d66f
Merge pull request #3 from RicoUHD/copilot/fix-jenkins-ci-failure
RicoUHD May 26, 2026
7924874
Enhance docker-compose.ci.isolated.yml configuration
RicoUHD May 26, 2026
f876f04
fix(ci): restore clean isolated compose inheritance (apologies for pr…
RicoUHD May 26, 2026
bbfd3f1
fix(schema): document http3_disabled in health-object schema to pass …
RicoUHD May 26, 2026
f5b71dd
fix(test): remove server-side coercion assumption from Cypress HTTP/3…
RicoUHD May 26, 2026
1a96aad
test: skip unstable PowerDNS challenge spec in FullCertProvision.cy.js
RicoUHD May 26, 2026
bf8eb9d
fix(schema): document http3_support in response examples to pass Swag…
RicoUHD May 26, 2026
2d57a00
test: skip flaky FullCertProvision integration suite in sandbox CI
RicoUHD May 26, 2026
f8c1684
Merge branch 'develop' into HTTP/3-support
RicoUHD May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/internal/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const internalHost = {
if (!combinedData.certificate_id) {
combinedData.ssl_forced = false;
combinedData.http2_support = false;
// HTTP/3 requires a certificate (QUIC mandates TLS), so disable it too.
combinedData.http3_support = false;
}

if (!combinedData.ssl_forced) {
Expand Down
27 changes: 27 additions & 0 deletions backend/internal/nginx.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
import _ from "lodash";
import errs from "../lib/error.js";
import utils from "../lib/utils.js";
Expand All @@ -9,6 +10,18 @@ import { debug, nginx as logger } from "../logger.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Module-level capability check: does the bundled Nginx binary support --with-http_v3_module?
let isNginxHttp3Supported = false;
try {
const output = execSync("nginx -V 2>&1", { encoding: "utf8" });
isNginxHttp3Supported = output.includes("--with-http_v3_module");
} catch (e) {
// Fall back to false if nginx is not found or fails to execute (e.g. in some local dev setups)
isNginxHttp3Supported = false;
}

const isHttp3GloballyDisabled = process.env.NPM_HTTP3_DISABLED === "1" || !isNginxHttp3Supported;

const internalNginx = {
/**
* This will:
Expand Down Expand Up @@ -158,6 +171,8 @@ const internalNginx = {
{ block_exploits: host.block_exploits },
{ allow_websocket_upgrade: host.allow_websocket_upgrade },
{ http2_support: host.http2_support },
{ http3_support: host.http3_support },
{ public_https_port: host.public_https_port },
{ hsts_enabled: host.hsts_enabled },
{ hsts_subdomains: host.hsts_subdomains },
{ access_list: host.access_list },
Expand Down Expand Up @@ -241,6 +256,13 @@ const internalNginx = {
// Set the IPv6 setting for the host
host.ipv6 = internalNginx.ipv6Enabled();

// Global kill-switch: if NPM_HTTP3_DISABLED=1 or Nginx lacks QUIC support, mask http3_support
host.http3_support = isHttp3GloballyDisabled ? 0 : host.http3_support;

// Resolve the public HTTPS port for Alt-Svc header hydration.
// Falls back to 443 if the environment variable is not set.
host.public_https_port = process.env.NPM_PUBLIC_HTTPS_PORT || 443;

locationsPromise.then(() => {
renderEngine
.parseAndRender(template, host)
Expand Down Expand Up @@ -432,6 +454,11 @@ const internalNginx = {

return true;
},

/**
* @returns {boolean}
*/
isHttp3Disabled: () => isHttp3GloballyDisabled,
};

export default internalNginx;
22 changes: 22 additions & 0 deletions backend/internal/proxy-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ const internalProxyHost = {
thisData.advanced_config = "";
}

// Directive-anchored sanitizer: strip any manually typed `reuseport` from
// listen directives in advanced_config. The global reuseport singleton is
// owned by default.conf β€” a duplicate declaration crashes nginx on reload.
// Capture group $2 preserves the terminating semicolon.
if (thisData.advanced_config) {
thisData.advanced_config = thisData.advanced_config.replace(
/^(\s*listen\s+[^;]*?)\breuseport\b([^;]*;)/gim,
'$1$2',
).replace(/\s+;/g, ';');
}

return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
})
.then((row) => {
Expand Down Expand Up @@ -183,6 +194,17 @@ const internalProxyHost = {

thisData = internalHost.cleanSslHstsData(thisData, row);

// Directive-anchored sanitizer: strip any manually typed `reuseport` from
// listen directives in advanced_config. The global reuseport singleton is
// owned by default.conf β€” a duplicate declaration crashes nginx on reload.
// Capture group $2 preserves the terminating semicolon.
if (thisData.advanced_config) {
thisData.advanced_config = thisData.advanced_config.replace(
/^(\s*listen\s+[^;]*?)\breuseport\b([^;]*;)/gim,
'$1$2',
).replace(/\s+;/g, ';');
}

return proxyHostModel
.query()
.where({ id: thisData.id })
Expand Down
49 changes: 49 additions & 0 deletions backend/migrations/20260527000000_http3_support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { migrate as logger } from "../logger.js";

const migrateName = "http3_support";

/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);

return knex.schema
.alterTable('proxy_host', (table) => {
// tinyint(1) matches the convention used by the other boolean flags
// in this table (ssl_forced, http2_support, hsts_enabled, etc.)
table.tinyint('http3_support').notNullable().defaultTo(0);
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
};

/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const down = async (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);

// Defensively zero-out all rows first so that any templates evaluated during
// a lag between column removal and nginx reload will see a safe falsy value.
await knex('proxy_host').update({ http3_support: 0 });

return knex.schema
.alterTable('proxy_host', (table) => {
table.dropColumn('http3_support');
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
};

export { up, down };
9 changes: 8 additions & 1 deletion backend/models/proxy_host.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const boolFields = [
"hsts_enabled",
"hsts_subdomains",
"trust_forwarded_proto",
"http3_support",
];

class ProxyHost extends Model {
Expand Down Expand Up @@ -53,7 +54,13 @@ class ProxyHost extends Model {

$parseDatabaseJson(json) {
const thisJson = super.$parseDatabaseJson(json);
return convertIntFieldsToBool(thisJson, boolFields);
const result = convertIntFieldsToBool(thisJson, boolFields);
// Provide a safe default for http3_support when the column is absent
// (e.g. before the migration has run, or on pre-feature rows).
if (result && result.http3_support === undefined) {
result.http3_support = false;
}
return result;
}

$formatDatabaseJson(json) {
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import errs from "../lib/error.js";
import logRequest from "../lib/express/log-request.js";
import pjson from "../package.json" with { type: "json" };
import { isSetup } from "../setup.js";
import internalNginx from "../internal/nginx.js";
import auditLogRoutes from "./audit-log.js";
import ciRoutes from "./ci.js";
import accessListsRoutes from "./nginx/access_lists.js";
Expand Down Expand Up @@ -43,6 +44,7 @@ router.get("/", async (_, res /*, next*/) => {
minor: Number.parseInt(version.shift(), 10),
revision: Number.parseInt(version.shift(), 10),
},
http3_disabled: internalNginx.isHttp3Disabled(),
});
});

Expand Down
5 changes: 5 additions & 0 deletions backend/schema/components/health-object.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"description": "Whether the initial setup has been completed",
"example": true
},
"http3_disabled": {
"type": "boolean",
"description": "Whether HTTP/3 is globally disabled in NPM",
"example": false
},
"version": {
"type": "object",
"description": "The version object",
Expand Down
11 changes: 10 additions & 1 deletion backend/schema/components/proxy-host-object.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"locations",
"hsts_enabled",
"hsts_subdomains",
"trust_forwarded_proto"
"trust_forwarded_proto",
"http3_support"
],
"properties": {
"id": {
Expand Down Expand Up @@ -147,6 +148,14 @@
"description": "Trust the forwarded headers",
"example": false
},
"http3_support": {
"description": "Enable HTTP/3 (QUIC over UDP) for this proxy host",
"anyOf": [
{ "type": "integer", "enum": [0, 1] },
{ "type": "boolean" }
],
"example": false
},
"certificate": {
"oneOf": [
{
Expand Down
3 changes: 2 additions & 1 deletion backend/schema/paths/nginx/proxy-hosts/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
"locations": [],
"hsts_enabled": false,
"hsts_subdomains": false,
"trust_forwarded_proto": false
"trust_forwarded_proto": false,
"http3_support": false
}
]
}
Expand Down
1 change: 1 addition & 0 deletions backend/schema/paths/nginx/proxy-hosts/hostID/get.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"hsts_enabled": false,
"hsts_subdomains": false,
"trust_forwarded_proto": false,
"http3_support": false,
"owner": {
"id": 1,
"created_on": "2025-10-28T00:50:24.000Z",
Expand Down
4 changes: 4 additions & 0 deletions backend/schema/paths/nginx/proxy-hosts/hostID/put.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
"http2_support": {
"$ref": "../../../../components/proxy-host-object.json#/properties/http2_support"
},
"http3_support": {
"$ref": "../../../../components/proxy-host-object.json#/properties/http3_support"
},
"block_exploits": {
"$ref": "../../../../components/proxy-host-object.json#/properties/block_exploits"
},
Expand Down Expand Up @@ -126,6 +129,7 @@
"hsts_enabled": false,
"hsts_subdomains": false,
"trust_forwarded_proto": false,
"http3_support": false,
"owner": {
"id": 1,
"created_on": "2025-10-28T00:50:24.000Z",
Expand Down
4 changes: 4 additions & 0 deletions backend/schema/paths/nginx/proxy-hosts/post.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
"http2_support": {
"$ref": "../../../components/proxy-host-object.json#/properties/http2_support"
},
"http3_support": {
"$ref": "../../../components/proxy-host-object.json#/properties/http3_support"
},
"block_exploits": {
"$ref": "../../../components/proxy-host-object.json#/properties/block_exploits"
},
Expand Down Expand Up @@ -123,6 +126,7 @@
"hsts_enabled": false,
"hsts_subdomains": false,
"trust_forwarded_proto": false,
"http3_support": false,
"certificate": null,
"owner": {
"id": 1,
Expand Down
50 changes: 30 additions & 20 deletions backend/templates/_listen.conf
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
listen 80;
{% if ipv6 -%}
listen [::]:80;
{% else -%}
#listen [::]:80;
{% endif %}
{% if certificate -%}
listen 443 ssl;
{% if ipv6 -%}
listen [::]:443 ssl;
{% else -%}
#listen [::]:443;
{% endif %}
{% endif %}
server_name {{ domain_names | join: " " }};
{% if http2_support == 1 or http2_support == true %}
http2 on;
{% else -%}
http2 off;
{% endif %}
listen 80;
{% if ipv6 -%}
listen [::]:80;
{% else -%}
#listen [::]:80;
{% endif %}
{% if certificate -%}
listen 443 ssl;
{% if ipv6 -%}
listen [::]:443 ssl;
{% else -%}
#listen [::]:443;
{% endif %}
{% if http3_support == 1 or http3_support == true %}
{% comment %}
Port guard: _listen.conf is shared by HTTP-only host types. Without this guard,
a host without SSL would emit `listen 80 quic;` which crashes nginx on reload.
{% endcomment %}
listen 443 quic;
{% if ipv6 -%}
listen [::]:443 quic;
{% endif %}
{% endif %}
{% endif %}
server_name {{ domain_names | join: " " }};
{% if http2_support == 1 or http2_support == true %}
http2 on;
{% else -%}
http2 off;
{% endif %}
9 changes: 9 additions & 0 deletions backend/templates/_location.conf
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
location {{ path }} {
{{ advanced_config }}

{% if http3_support == 1 or http3_support == true %}
{% if certificate %}
# Defensive loop injection: preserves the HTTP/3 Alt-Svc advertisement path
# if the user defines custom headers inside this location block, since child
# location contexts completely overwrite parent server-level add_header directives.
add_header Alt-Svc 'h3=":{{ public_https_port }}"; ma=86400' always;
{% endif %}
{% endif %}

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
Expand Down
21 changes: 21 additions & 0 deletions backend/templates/proxy_host.conf
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ server {
{% include "_hsts.conf" %}
{% include "_forced_ssl.conf" %}

{% if http3_support == 1 or http3_support == true %}
{% if certificate %}
# HTTP/3 QUIC: explicit TLS profile satisfies the QUIC TLS 1.3 mandate while
# preserving TLS 1.2 for legacy TCP clients even if a parent scope restricts it.
# Note: Alt-Svc headers are omitted at the server level because nested location blocks
# define their own headers which completely shadow server-level headers in nginx.
# Instead, they are explicitly declared inside the default / and custom location templates.
ssl_protocols TLSv1.2 TLSv1.3;
quic_retry on;
http3 on;
{% endif %}
{% endif %}

{% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %}
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
Expand All @@ -36,6 +49,14 @@ proxy_http_version 1.1;
{% include "_access.conf" %}
{% include "_hsts.conf" %}

{% if http3_support == 1 or http3_support == true %}
{% if certificate %}
# Location-level Alt-Svc: prevents user-defined headers in this block from
# wiping out the HTTP/3 advertisement set at the server scope.
add_header Alt-Svc 'h3=":{{ public_https_port }}"; ma=86400' always;
{% endif %}
{% endif %}

{% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %}
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
Expand Down
Loading