diff --git a/README.md b/README.md index 5cd9be1c96..232407d6f5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ use the `2.13.7` image tag if this applies to you. I won't go into too much detail here, but here are the basics for someone new to this self-hosted world. 1. Your home router will have a Port Forwarding section somewhere. Log in and find it -2. Add port forwarding for ports 80 and 443 to the server hosting this project +2. Add port forwarding for TCP ports 80 and 443, plus UDP port 443 for HTTP/3, to the server hosting this project 3. Configure your domain name details to point to your home, either with a static ip or a service like - DuckDNS - [Amazon Route53](https://github.com/jc21/route53-ddns) @@ -66,7 +66,8 @@ services: ports: - '80:80' - '81:81' - - '443:443' + - '443:443/tcp' + - '443:443/udp' volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 6498422c61..4fac12102e 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -158,6 +158,7 @@ const internalCertificate = { await internalNginx.reload(); // 6. Re-instate previously disabled hosts await internalCertificate.enableInUseHosts(inUseResult); + await internalNginx.reload(); } catch (err) { // In the event of failure, revert things and throw err back await internalCertificate.enableInUseHosts(inUseResult); @@ -177,6 +178,7 @@ const internalCertificate = { await internalNginx.reload(); // 6. Re-instate previously disabled hosts await internalCertificate.enableInUseHosts(inUseResult); + await internalNginx.reload(); } catch (err) { // In the event of failure, revert things and throw err back await internalNginx.deleteLetsEncryptRequestConfig(certificate); diff --git a/backend/internal/http3.js b/backend/internal/http3.js new file mode 100644 index 0000000000..a8b9ecafe9 --- /dev/null +++ b/backend/internal/http3.js @@ -0,0 +1,248 @@ +import fs from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import errs from "../lib/error.js"; +import utils from "../lib/utils.js"; +import proxyHostModel from "../models/proxy_host.js"; +import streamModel from "../models/stream.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const listenerConfigPath = "/data/nginx/http3/listener.conf"; +const listenerConfigTempPath = `${listenerConfigPath}.${process.pid}.tmp`; +const resourceLockName = "udp-443"; + +const isEnabled = (value) => value === true || value === 1; +const hasCertificate = (value) => value === "new" || Number(value) > 0; +const advancedConfigHasQuicListener = (config) => { + const uncommentedConfig = String(config || "").replace(/#.*$/gm, ""); + return /(?:^|[;{}\r\n])\s*listen\s+[^;]*\bquic\b[^;]*;/i.test(uncommentedConfig); +}; +const isManagedHttp3Host = (host) => + isEnabled(host.enabled) && isEnabled(host.http3_support) && hasCertificate(host.certificate_id); +const isManualHttp3Host = (host) => isEnabled(host.enabled) && advancedConfigHasQuicListener(host.advanced_config); +let listenerSyncQueue = Promise.resolve(); + +const syncListenerNow = async (ipv6) => { + const hosts = await proxyHostModel + .query() + .select("id") + .where("is_deleted", 0) + .andWhere("enabled", 1) + .andWhere("http3_support", 1) + .whereNot("certificate_id", 0); + + const hasRenderedHttp3Host = hosts.some((host) => fs.existsSync(`/data/nginx/proxy_host/${host.id}.conf`)); + + if (!hasRenderedHttp3Host) { + if (fs.existsSync(listenerConfigPath)) { + fs.unlinkSync(listenerConfigPath); + } + return false; + } + + let template; + try { + template = fs.readFileSync(`${__dirname}/../templates/http3_listener.conf`, { encoding: "utf8" }); + } catch (err) { + throw new errs.ConfigurationError(err.message); + } + + const configText = await utils.getRenderEngine().parseAndRender(template, { + ipv6, + public_https_port: internalHttp3.publicHttpsPort(), + }); + if (fs.existsSync(listenerConfigPath) && fs.readFileSync(listenerConfigPath, "utf8") === configText) { + return true; + } + + fs.mkdirSync(dirname(listenerConfigPath), { recursive: true }); + try { + fs.writeFileSync(listenerConfigTempPath, configText, { encoding: "utf8" }); + fs.renameSync(listenerConfigTempPath, listenerConfigPath); + } finally { + if (fs.existsSync(listenerConfigTempPath)) { + fs.unlinkSync(listenerConfigTempPath); + } + } + return true; +}; + +const internalHttp3 = { + /** + * @returns {number} + */ + publicHttpsPort: () => { + const value = process.env.NPM_PUBLIC_HTTPS_PORT; + if (typeof value === "string" && /^\d+$/.test(value)) { + const port = Number.parseInt(value, 10); + if (port >= 1 && port <= 65535) { + return port; + } + } + return 443; + }, + + /** + * Serializes the UDP/443 validation and mutation in every supported database. + * Incrementing a single row holds a write lock until the callback transaction commits. + * + * @param {Function} callback + * @returns {Promise<*>} + */ + withPort443Lock: async (callback) => { + const knex = proxyHostModel.knex(); + return knex.transaction(async (trx) => { + const updated = await trx("resource_lock").where("name", resourceLockName).increment("version", 1); + if (!updated) { + throw new errs.ConfigurationError("UDP port 443 resource lock is not initialized"); + } + return callback(trx); + }); + }, + + /** + * Rejects a Proxy Host that would claim UDP/443 while an enabled UDP stream owns it. + * + * @param {Object} host + * @returns {Promise} + */ + assertProxyHostCanUseHttp3: async (host, trx) => { + const enabled = typeof host.enabled === "undefined" ? true : isEnabled(host.enabled); + const managedHttp3 = enabled && isEnabled(host.http3_support) && hasCertificate(host.certificate_id); + const manualHttp3 = enabled && advancedConfigHasQuicListener(host.advanced_config); + if (!managedHttp3 && !manualHttp3) { + return; + } + + if (managedHttp3 && manualHttp3) { + throw new errs.ValidationError( + "Managed HTTP/3 cannot be combined with manual QUIC listen directives in Advanced configuration", + ); + } + + const otherHosts = await proxyHostModel + .query(trx) + .select("id", "enabled", "certificate_id", "http3_support", "advanced_config") + .where("is_deleted", 0) + .andWhere("enabled", 1) + .modify((query) => { + if (host.id) { + query.whereNot("id", host.id); + } + }); + const otherManagedHttp3 = otherHosts.some(isManagedHttp3Host); + const otherManualHttp3 = otherHosts.some(isManualHttp3Host); + if ((managedHttp3 && otherManualHttp3) || (manualHttp3 && otherManagedHttp3)) { + throw new errs.ValidationError( + "Managed HTTP/3 cannot share UDP port 443 with manual QUIC listen directives on another Proxy Host", + ); + } + + const claim = await trx("resource_lock").where("name", resourceLockName).first(); + if (!claim) { + throw new errs.ConfigurationError("UDP port 443 resource lock is not initialized"); + } + if (claim.mode === "udp_stream") { + throw new errs.ValidationError( + "HTTP/3 cannot use UDP port 443 while an enabled UDP stream is configured on that port", + ); + } + if (claim.mode !== "http3") { + await trx("resource_lock").where("name", resourceLockName).update({ mode: "http3" }); + } + }, + + /** + * Rejects a stream that would claim UDP/443 while an enabled HTTP/3 Proxy Host owns it. + * + * @param {Object} stream + * @returns {Promise} + */ + assertStreamCanUseUdp443: async (stream, trx) => { + const enabled = typeof stream.enabled === "undefined" ? true : isEnabled(stream.enabled); + if (!enabled || !isEnabled(stream.udp_forwarding) || Number(stream.incoming_port) !== 443) { + return; + } + + const proxyHosts = await proxyHostModel + .query(trx) + .select("enabled", "certificate_id", "http3_support", "advanced_config") + .where("is_deleted", 0) + .andWhere("enabled", 1); + if (proxyHosts.some((host) => isManagedHttp3Host(host) || isManualHttp3Host(host))) { + throw new errs.ValidationError( + "UDP port 443 cannot be used by a stream while an enabled Proxy Host has HTTP/3 support", + ); + } + + const claim = await trx("resource_lock").where("name", resourceLockName).first(); + if (!claim) { + throw new errs.ConfigurationError("UDP port 443 resource lock is not initialized"); + } + if (claim.mode === "http3") { + throw new errs.ValidationError( + "UDP port 443 cannot be used by a stream while an enabled Proxy Host has HTTP/3 support", + ); + } + if (claim.mode !== "udp_stream") { + await trx("resource_lock").where("name", resourceLockName).update({ mode: "udp_stream" }); + } + }, + + /** + * Reconciles the persistent UDP/443 claim after Nginx configuration changes. + * Enabled records intentionally retain the claim even when their generated config is offline. + * + * @returns {Promise} + */ + syncPort443Claim: () => { + return internalHttp3.withPort443Lock(async (trx) => { + const proxyHosts = await proxyHostModel + .query(trx) + .select("id", "enabled", "certificate_id", "http3_support", "advanced_config") + .where("is_deleted", 0) + .andWhere("enabled", 1); + const http3Host = proxyHosts.find((host) => isManagedHttp3Host(host) || isManualHttp3Host(host)); + const udpStream = await streamModel + .query(trx) + .select("id") + .where("is_deleted", 0) + .andWhere("enabled", 1) + .andWhere("incoming_port", 443) + .andWhere("udp_forwarding", 1) + .first(); + + if (http3Host && udpStream) { + throw new errs.ConfigurationError("HTTP/3 and a UDP stream both claim UDP port 443"); + } + + const mode = http3Host ? "http3" : udpStream ? "udp_stream" : null; + await trx("resource_lock").where("name", resourceLockName).update({ mode }); + return mode; + }); + }, + + /** + * Keeps exactly one reuseport owner for all generated HTTP/3 virtual hosts. + * The listener is absent unless at least one effective HTTP/3 host config exists. + * + * @param {boolean} ipv6 + * @returns {Promise} + */ + syncListener: async (ipv6) => { + const sync = async () => { + const listenerEnabled = await syncListenerNow(ipv6); + await internalHttp3.syncPort443Claim(); + return listenerEnabled; + }; + const queuedSync = listenerSyncQueue.then( + () => sync(), + () => sync(), + ); + listenerSyncQueue = queuedSync.catch(() => undefined); + return queuedSync; + }, +}; + +export default internalHttp3; diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 128c004619..ee57d95b4a 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -3,13 +3,25 @@ import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import _ from "lodash"; import errs from "../lib/error.js"; +import createPromiseQueue from "../lib/promise-queue.js"; import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; +import internalHttp3 from "./http3.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const queueConfigLifecycle = createPromiseQueue(); const internalNginx = { + /** + * Serializes database state transitions with their generated Nginx configuration. + * Callers that use this lock must use the *Now variants to avoid queueing recursively. + * + * @param {Function} callback + * @returns {Promise<*>} + */ + withConfigLock: (callback) => queueConfigLifecycle(callback), + /** * This will: * - test the nginx config first to make sure it's OK @@ -25,79 +37,63 @@ const internalNginx = { * @returns {Promise} */ configure: (model, host_type, host) => { + return internalNginx.withConfigLock(() => internalNginx.configureNow(model, host_type, host)); + }, + + /** + * Configures a host while the caller owns the configuration lifecycle lock. + * + * @param {Object|String} model + * @param {String} host_type + * @param {Object} host + * @returns {Promise} + */ + configureNow: async (model, host_type, host) => { let combined_meta = {}; + const persistedHost = await model.query().findById(host.id); + const hostIsEnabled = persistedHost?.enabled === true || persistedHost?.enabled === 1; + + // A lifecycle operation queued before this one may already have disabled or + // deleted the row. Never recreate a stale configuration in that case. + if (!persistedHost || persistedHost.is_deleted || !hostIsEnabled) { + await internalNginx.deleteConfig(host_type, host, true); + await internalNginx.syncHttp3Listener(); + return _.assign({}, host.meta); + } - return internalNginx - .test() - .then(() => { - // Nginx is OK - // We're deleting this config regardless. - // Don't throw errors, as the file may not exist at all - // Delete the .err file too - return internalNginx.deleteConfig(host_type, host, true); - }) - .then(() => { - return internalNginx.generateConfig(host_type, host); - }) - .then(() => { - // Test nginx again and update meta with result - return internalNginx - .test() - .then(() => { - // nginx is ok - combined_meta = _.assign({}, host.meta, { - nginx_online: true, - nginx_err: null, - }); - - return model.query().where("id", host.id).patch({ - meta: combined_meta, - }); - }) - .catch((err) => { - // Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported. - // It will always look like this: - // nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address) - - const valid_lines = []; - const err_lines = err.message.split("\n"); - err_lines.map((line) => { - if (line.indexOf("/var/log/nginx/error.log") === -1) { - valid_lines.push(line); - } - return true; - }); - - debug(logger, "Nginx test failed:", valid_lines.join("\n")); - - // config is bad, update meta and delete config - combined_meta = _.assign({}, host.meta, { - nginx_online: false, - nginx_err: valid_lines.join("\n"), - }); - - return model - .query() - .where("id", host.id) - .patch({ - meta: combined_meta, - }) - .then(() => { - // Keep the failed config as a .err file for inspection - return internalNginx.renameConfigAsError(host_type, host); - }) - .then(() => { - // The rename removed the live config already, don't touch the .err file - return internalNginx.deleteConfig(host_type, host, false); - }); - }); - }) - .then(() => { - return internalNginx.reload(); - }) - .then(() => { - return combined_meta; + await internalNginx.test(); + // We're deleting this config regardless. Don't throw errors if it does not exist. + await internalNginx.deleteConfig(host_type, host, true); + await internalNginx.generateConfig(host_type, host); + await internalNginx.syncHttp3Listener(); + + try { + await internalNginx.test(); + combined_meta = _.assign({}, host.meta, { + nginx_online: true, + nginx_err: null, + }); + await model.query().where("id", host.id).patch({ meta: combined_meta }); + } catch (err) { + // Remove the Docker-specific error_log warning from the user-facing error. + const valid_lines = err.message + .split("\n") + .filter((line) => line.indexOf("/var/log/nginx/error.log") === -1); + + debug(logger, "Nginx test failed:", valid_lines.join("\n")); + combined_meta = _.assign({}, host.meta, { + nginx_online: false, + nginx_err: valid_lines.join("\n"), }); + + await model.query().where("id", host.id).patch({ meta: combined_meta }); + await internalNginx.renameConfigAsError(host_type, host); + await internalNginx.deleteConfig(host_type, host, false); + await internalNginx.syncHttp3Listener(); + } + + await internalNginx.reloadNow(); + return combined_meta; }, /** @@ -111,13 +107,28 @@ const internalNginx = { /** * @returns {Promise} */ - reload: () => { - return internalNginx.test().then(() => { - logger.info("Reloading Nginx"); - return utils.execFile("/usr/sbin/nginx", ["-s", "reload"]); - }); + reload: () => internalNginx.withConfigLock(() => internalNginx.reloadNow()), + + /** + * Reloads Nginx while the caller owns the configuration lifecycle lock. + * + * @returns {Promise} + */ + reloadNow: () => { + return internalNginx + .syncHttp3Listener() + .then(() => internalNginx.test()) + .then(() => { + logger.info("Reloading Nginx"); + return utils.execFile("/usr/sbin/nginx", ["-s", "reload"]); + }); }, + /** + * @returns {Promise} + */ + syncHttp3Listener: () => internalHttp3.syncListener(internalNginx.ipv6Enabled()), + /** * @param {String} host_type * @param {Integer} host_id @@ -160,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 }, @@ -192,6 +205,10 @@ const internalNginx = { const host = JSON.parse(JSON.stringify(host_row)); const nice_host_type = internalNginx.getFileFriendlyHostType(host_type); + // Values shared by the main template and rendered custom locations. + host.ipv6 = internalNginx.ipv6Enabled(); + host.public_https_port = internalNginx.publicHttpsPort(); + debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2)); const renderEngine = utils.getRenderEngine(); @@ -243,9 +260,6 @@ const internalNginx = { locationsPromise = Promise.resolve(); } - // Set the IPv6 setting for the host - host.ipv6 = internalNginx.ipv6Enabled(); - locationsPromise.then(() => { renderEngine .parseAndRender(template, host) @@ -437,6 +451,11 @@ const internalNginx = { return true; }, + + /** + * @returns {number} + */ + publicHttpsPort: () => internalHttp3.publicHttpsPort(), }; export default internalNginx; diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2c159d48ad..d008e7beae 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -6,10 +6,18 @@ import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; import internalHost from "./host.js"; +import internalHttp3 from "./http3.js"; import internalNginx from "./nginx.js"; const omissions = () => { - return ["is_deleted", "owner.is_deleted"]; + return ["is_deleted", "owner.is_deleted", "certificate.is_deleted"]; +}; + +const cleanHttp3Data = (data) => { + if (!data.certificate_id) { + data.http3_support = false; + } + return data; }; const internalProxyHost = { @@ -21,6 +29,16 @@ const internalProxyHost = { create: (access, data) => { let thisData = data; const createCertificate = thisData.certificate_id === "new"; + const requestedSslOptions = createCertificate + ? _.pick(thisData, [ + "ssl_forced", + "http2_support", + "http3_support", + "hsts_enabled", + "hsts_subdomains", + "trust_forwarded_proto", + ]) + : {}; if (createCertificate) { delete thisData.certificate_id; @@ -49,7 +67,12 @@ const internalProxyHost = { .then(() => { // At this point the domains should have been checked thisData.owner_user_id = access.token.getUserId(1); - thisData = internalHost.cleanSslHstsData(thisData); + const http3Candidate = _.assign( + {}, + thisData, + createCertificate ? { certificate_id: "new", ...requestedSslOptions } : {}, + ); + thisData = cleanHttp3Data(internalHost.cleanSslHstsData(thisData)); // Fix for db field not having a default value // for this optional field. @@ -57,7 +80,10 @@ const internalProxyHost = { thisData.advanced_config = ""; } - return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); + return internalHttp3.withPort443Lock(async (trx) => { + await internalHttp3.assertProxyHostCanUseHttp3(http3Candidate, trx); + return proxyHostModel.query(trx).insertAndFetch(thisData).then(utils.omitRow(omissions())); + }); }) .then((row) => { if (createCertificate) { @@ -68,10 +94,15 @@ const internalProxyHost = { return internalProxyHost.update(access, { id: row.id, certificate_id: cert.id, + ...requestedSslOptions, }); }) .then(() => { return row; + }) + .catch(async (err) => { + await internalHttp3.syncPort443Claim(); + throw err; }); } return row; @@ -156,69 +187,67 @@ const internalProxyHost = { } if (createCertificate) { - return internalCertificate - .createQuickCertificate(access, { - domain_names: thisData.domain_names || row.domain_names, - meta: _.assign({}, row.meta, thisData.meta), + const http3Candidate = _.assign({}, row, thisData, { certificate_id: "new" }); + return internalHttp3 + .withPort443Lock(async (trx) => { + await internalHttp3.assertProxyHostCanUseHttp3(http3Candidate, trx); }) + .then(() => + internalCertificate.createQuickCertificate(access, { + domain_names: thisData.domain_names || row.domain_names, + meta: _.assign({}, row.meta, thisData.meta), + }), + ) .then((cert) => { // update host with cert id thisData.certificate_id = cert.id; }) .then(() => { return row; + }) + .catch(async (err) => { + await internalHttp3.syncPort443Claim(); + throw err; }); } return row; }) .then((row) => { - // Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here. - thisData = _.assign( - {}, - { - domain_names: row.domain_names, - }, - data, - ); - - thisData = internalHost.cleanSslHstsData(thisData, row); - - return proxyHostModel - .query() - .where({ id: thisData.id }) - .patch(thisData) - .then(utils.omitRow(omissions())) - .then((saved_row) => { - // Add to audit log - return internalAuditLog - .add(access, { - action: "updated", - object_type: "proxy-host", - object_id: row.id, - meta: thisData, - }) - .then(() => { - return saved_row; - }); + return internalNginx.withConfigLock(async () => { + const currentRow = await internalProxyHost.get(access, { id: row.id }); + // Include domain_names so the audit log remains useful for partial updates. + thisData = _.assign({}, { domain_names: currentRow.domain_names }, data); + thisData = cleanHttp3Data(internalHost.cleanSslHstsData(thisData, currentRow)); + + await internalHttp3.withPort443Lock(async (trx) => { + await internalHttp3.assertProxyHostCanUseHttp3(thisData, trx); + await proxyHostModel.query(trx).where({ id: thisData.id }).patch(thisData); }); - }) - .then(() => { - return internalProxyHost - .get(access, { + + const savedRow = await internalProxyHost.get(access, { id: thisData.id, expand: ["owner", "certificate", "access_list.[clients,items]"], - }) - .then((row) => { - if (!row.enabled) { - // No need to add nginx config if host is disabled - return row; - } - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { - row.meta = new_meta; - return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); - }); }); + if (!savedRow.enabled) { + await internalNginx.deleteConfig("proxy_host", savedRow); + await internalNginx.reloadNow(); + return _.omit(internalHost.cleanRowCertificateMeta(savedRow), omissions()); + } + + const newMeta = await internalNginx.configureNow(proxyHostModel, "proxy_host", savedRow); + savedRow.meta = newMeta; + return _.omit(internalHost.cleanRowCertificateMeta(savedRow), omissions()); + }); + }) + .then((row) => { + return internalAuditLog + .add(access, { + action: "updated", + object_type: "proxy-host", + object_id: row.id, + meta: thisData, + }) + .then(() => row); }); }, @@ -275,36 +304,27 @@ const internalProxyHost = { delete: (access, data) => { return access .can("proxy_hosts:delete", data.id) - .then(() => { - return internalProxyHost.get(access, { id: data.id }); - }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - - return proxyHostModel - .query() - .where("id", row.id) - .patch({ - is_deleted: 1, - }) - .then(() => { - // Delete Nginx Config - return internalNginx.deleteConfig("proxy_host", row).then(() => { - return internalNginx.reload(); - }); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "deleted", - object_type: "proxy-host", - object_id: row.id, - meta: _.omit(row, omissions()), - }); - }); - }) + .then(() => + internalNginx.withConfigLock(async () => { + const row = await internalProxyHost.get(access, { id: data.id }); + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + + await proxyHostModel.query().where("id", row.id).patch({ is_deleted: 1 }); + await internalNginx.deleteConfig("proxy_host", row); + await internalNginx.reloadNow(); + return row; + }), + ) + .then((row) => + internalAuditLog.add(access, { + action: "deleted", + object_type: "proxy-host", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ) .then(() => { return true; }); @@ -320,42 +340,37 @@ const internalProxyHost = { enable: (access, data) => { return access .can("proxy_hosts:update", data.id) - .then(() => { - return internalProxyHost.get(access, { - id: data.id, - expand: ["certificate", "owner", "access_list"], - }); - }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - if (row.enabled) { - throw new errs.ValidationError("Host is already enabled"); - } - - row.enabled = 1; - - return proxyHostModel - .query() - .where("id", row.id) - .patch({ - enabled: 1, - }) - .then(() => { - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "enabled", - object_type: "proxy-host", - object_id: row.id, - meta: _.omit(row, omissions()), - }); + .then(() => + internalNginx.withConfigLock(async () => { + const row = await internalProxyHost.get(access, { + id: data.id, + expand: ["certificate", "owner", "access_list"], }); - }) + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + if (row.enabled) { + throw new errs.ValidationError("Host is already enabled"); + } + + row.enabled = 1; + + await internalHttp3.withPort443Lock(async (trx) => { + await internalHttp3.assertProxyHostCanUseHttp3(row, trx); + await proxyHostModel.query(trx).where("id", row.id).patch({ enabled: 1 }); + }); + await internalNginx.configureNow(proxyHostModel, "proxy_host", row); + return row; + }), + ) + .then((row) => + internalAuditLog.add(access, { + action: "enabled", + object_type: "proxy-host", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ) .then(() => { return true; }); @@ -371,41 +386,32 @@ const internalProxyHost = { disable: (access, data) => { return access .can("proxy_hosts:update", data.id) - .then(() => { - return internalProxyHost.get(access, { id: data.id }); - }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - if (!row.enabled) { - throw new errs.ValidationError("Host is already disabled"); - } - - row.enabled = 0; - - return proxyHostModel - .query() - .where("id", row.id) - .patch({ - enabled: 0, - }) - .then(() => { - // Delete Nginx Config - return internalNginx.deleteConfig("proxy_host", row).then(() => { - return internalNginx.reload(); - }); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "disabled", - object_type: "proxy-host", - object_id: row.id, - meta: _.omit(row, omissions()), - }); - }); - }) + .then(() => + internalNginx.withConfigLock(async () => { + const row = await internalProxyHost.get(access, { id: data.id }); + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + if (!row.enabled) { + throw new errs.ValidationError("Host is already disabled"); + } + + row.enabled = 0; + + await proxyHostModel.query().where("id", row.id).patch({ enabled: 0 }); + await internalNginx.deleteConfig("proxy_host", row); + await internalNginx.reloadNow(); + return row; + }), + ) + .then((row) => + internalAuditLog.add(access, { + action: "disabled", + object_type: "proxy-host", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ) .then(() => { return true; }); diff --git a/backend/internal/stream.js b/backend/internal/stream.js index a68e09e02b..a108b3f58f 100644 --- a/backend/internal/stream.js +++ b/backend/internal/stream.js @@ -6,6 +6,7 @@ import streamModel from "../models/stream.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; import internalHost from "./host.js"; +import internalHttp3 from "./http3.js"; import internalNginx from "./nginx.js"; const omissions = () => { @@ -39,7 +40,10 @@ const internalStream = { const data_no_domains = structuredClone(data); delete data_no_domains.domain_names; - return streamModel.query().insertAndFetch(data_no_domains).then(utils.omitRow(omissions())); + return internalHttp3.withPort443Lock(async (trx) => { + await internalHttp3.assertStreamCanUseUdp443(data_no_domains, trx); + return streamModel.query(trx).insertAndFetch(data_no_domains).then(utils.omitRow(omissions())); + }); }) .then((row) => { if (create_certificate) { @@ -131,40 +135,40 @@ const internalStream = { return row; }) .then((row) => { - // Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here. - thisData = _.assign( - {}, - { - domain_names: row.domain_names, - }, - thisData, - ); - - return streamModel - .query() - .patchAndFetchById(row.id, thisData) - .then(utils.omitRow(omissions())) - .then((saved_row) => { - // Add to audit log - return internalAuditLog - .add(access, { - action: "updated", - object_type: "stream", - object_id: row.id, - meta: thisData, - }) - .then(() => { - return saved_row; - }); + return internalNginx.withConfigLock(async () => { + const currentRow = await internalStream.get(access, { id: row.id }); + thisData = _.assign({}, { domain_names: currentRow.domain_names }, thisData); + const effectiveStream = _.assign({}, currentRow, thisData); + + await internalHttp3.withPort443Lock(async (trx) => { + await internalHttp3.assertStreamCanUseUdp443(effectiveStream, trx); + await streamModel.query(trx).patchAndFetchById(row.id, thisData); }); - }) - .then(() => { - return internalStream.get(access, { id: thisData.id, expand: ["owner", "certificate"] }).then((row) => { - return internalNginx.configure(streamModel, "stream", row).then((new_meta) => { - row.meta = new_meta; - return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); + + const savedRow = await internalStream.get(access, { + id: thisData.id, + expand: ["owner", "certificate"], }); + if (!savedRow.enabled) { + await internalNginx.deleteConfig("stream", savedRow); + await internalNginx.reloadNow(); + return _.omit(internalHost.cleanRowCertificateMeta(savedRow), omissions()); + } + + const newMeta = await internalNginx.configureNow(streamModel, "stream", savedRow); + savedRow.meta = newMeta; + return _.omit(internalHost.cleanRowCertificateMeta(savedRow), omissions()); }); + }) + .then((row) => { + return internalAuditLog + .add(access, { + action: "updated", + object_type: "stream", + object_id: row.id, + meta: thisData, + }) + .then(() => row); }); }, @@ -222,36 +226,27 @@ const internalStream = { delete: (access, data) => { return access .can("streams:delete", data.id) - .then(() => { - return internalStream.get(access, { id: data.id }); - }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - - return streamModel - .query() - .where("id", row.id) - .patch({ - is_deleted: 1, - }) - .then(() => { - // Delete Nginx Config - return internalNginx.deleteConfig("stream", row).then(() => { - return internalNginx.reload(); - }); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "deleted", - object_type: "stream", - object_id: row.id, - meta: _.omit(row, omissions()), - }); - }); - }) + .then(() => + internalNginx.withConfigLock(async () => { + const row = await internalStream.get(access, { id: data.id }); + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + + await streamModel.query().where("id", row.id).patch({ is_deleted: 1 }); + await internalNginx.deleteConfig("stream", row); + await internalNginx.reloadNow(); + return row; + }), + ) + .then((row) => + internalAuditLog.add(access, { + action: "deleted", + object_type: "stream", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ) .then(() => { return true; }); @@ -267,42 +262,37 @@ const internalStream = { enable: (access, data) => { return access .can("streams:update", data.id) - .then(() => { - return internalStream.get(access, { - id: data.id, - expand: ["certificate", "owner"], - }); - }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - if (row.enabled) { - throw new errs.ValidationError("Stream is already enabled"); - } - - row.enabled = 1; - - return streamModel - .query() - .where("id", row.id) - .patch({ - enabled: 1, - }) - .then(() => { - // Configure nginx - return internalNginx.configure(streamModel, "stream", row); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "enabled", - object_type: "stream", - object_id: row.id, - meta: _.omit(row, omissions()), - }); + .then(() => + internalNginx.withConfigLock(async () => { + const row = await internalStream.get(access, { + id: data.id, + expand: ["certificate", "owner"], }); - }) + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + if (row.enabled) { + throw new errs.ValidationError("Stream is already enabled"); + } + + row.enabled = 1; + + await internalHttp3.withPort443Lock(async (trx) => { + await internalHttp3.assertStreamCanUseUdp443(row, trx); + await streamModel.query(trx).where("id", row.id).patch({ enabled: 1 }); + }); + await internalNginx.configureNow(streamModel, "stream", row); + return row; + }), + ) + .then((row) => + internalAuditLog.add(access, { + action: "enabled", + object_type: "stream", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ) .then(() => { return true; }); @@ -318,41 +308,32 @@ const internalStream = { disable: (access, data) => { return access .can("streams:update", data.id) - .then(() => { - return internalStream.get(access, { id: data.id }); - }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - if (!row.enabled) { - throw new errs.ValidationError("Stream is already disabled"); - } - - row.enabled = 0; - - return streamModel - .query() - .where("id", row.id) - .patch({ - enabled: 0, - }) - .then(() => { - // Delete Nginx Config - return internalNginx.deleteConfig("stream", row).then(() => { - return internalNginx.reload(); - }); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "disabled", - object_type: "stream", - object_id: row.id, - meta: _.omit(row, omissions()), - }); - }); - }) + .then(() => + internalNginx.withConfigLock(async () => { + const row = await internalStream.get(access, { id: data.id }); + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + if (!row.enabled) { + throw new errs.ValidationError("Stream is already disabled"); + } + + row.enabled = 0; + + await streamModel.query().where("id", row.id).patch({ enabled: 0 }); + await internalNginx.deleteConfig("stream", row); + await internalNginx.reloadNow(); + return row; + }), + ) + .then((row) => + internalAuditLog.add(access, { + action: "disabled", + object_type: "stream", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ) .then(() => { return true; }); diff --git a/backend/lib/promise-queue.js b/backend/lib/promise-queue.js new file mode 100644 index 0000000000..5d1f0c064f --- /dev/null +++ b/backend/lib/promise-queue.js @@ -0,0 +1,11 @@ +const createPromiseQueue = () => { + let queue = Promise.resolve(); + + return (callback) => { + const queuedTask = queue.then(callback, callback); + queue = queuedTask.catch(() => undefined); + return queuedTask; + }; +}; + +export default createPromiseQueue; diff --git a/backend/migrations/20260825180100_proxy_host_http3.js b/backend/migrations/20260825180100_proxy_host_http3.js new file mode 100644 index 0000000000..cbe435be47 --- /dev/null +++ b/backend/migrations/20260825180100_proxy_host_http3.js @@ -0,0 +1,61 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "proxy_host_http3"; + +/** + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable("proxy_host", (table) => { + table.tinyint("http3_support").notNullable().defaultTo(0); + }) + .then(() => { + return knex.schema.createTable("resource_lock", (table) => { + table.string("name", 64).notNullable().primary(); + table.bigInteger("version").notNullable().defaultTo(0); + table.string("mode", 32).nullable(); + }); + }) + .then(async () => { + const udpStream = await knex("stream") + .select("id") + .where("is_deleted", 0) + .andWhere("enabled", 1) + .andWhere("incoming_port", 443) + .andWhere("udp_forwarding", 1) + .first(); + return knex("resource_lock").insert({ + name: "udp-443", + version: 0, + mode: udpStream ? "udp_stream" : null, + }); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host and resource_lock Tables altered`); + }); +}; + +/** + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .dropTable("resource_lock") + .then(() => + knex.schema.alterTable("proxy_host", (table) => { + table.dropColumn("http3_support"); + }), + ) + .then(() => { + logger.info(`[${migrateName}] proxy_host and resource_lock Tables altered`); + }); +}; + +export { up, down }; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da9358..e5d3e68c4a 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -18,6 +18,7 @@ const boolFields = [ "block_exploits", "allow_websocket_upgrade", "http2_support", + "http3_support", "enabled", "hsts_enabled", "hsts_subdomains", diff --git a/backend/package.json b/backend/package.json index 8eda0451c5..02d1538328 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "type": "module", "scripts": { "lint": "biome lint", + "test": "node --test test/**/*.test.js", "prettier": "biome format --write .", "validate-schema": "node validate-schema.js", "regenerate-config": "node scripts/regenerate-config" diff --git a/backend/schema/common.json b/backend/schema/common.json index 00b06e005f..bc9af8454c 100644 --- a/backend/schema/common.json +++ b/backend/schema/common.json @@ -118,6 +118,11 @@ "type": "boolean", "example": true }, + "http3_support": { + "description": "HTTP3 Protocol Support over QUIC", + "type": "boolean", + "example": false + }, "block_exploits": { "description": "Should we block common exploits", "type": "boolean", diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..1e67b4be3c 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -18,6 +18,7 @@ "meta", "allow_websocket_upgrade", "http2_support", + "http3_support", "forward_scheme", "enabled", "locations", @@ -87,6 +88,9 @@ "http2_support": { "$ref": "../common.json#/properties/http2_support" }, + "http3_support": { + "$ref": "../common.json#/properties/http3_support" + }, "forward_scheme": { "type": "string", "enum": ["http", "https"], diff --git a/backend/schema/paths/nginx/proxy-hosts/get.json b/backend/schema/paths/nginx/proxy-hosts/get.json index 301e28bfdf..adfc5a2585 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -54,6 +54,7 @@ }, "allow_websocket_upgrade": false, "http2_support": false, + "http3_support": false, "forward_scheme": "http", "enabled": true, "locations": [], diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json index 2e677fed32..86887e674a 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -51,6 +51,7 @@ }, "allow_websocket_upgrade": false, "http2_support": false, + "http3_support": false, "forward_scheme": "http", "enabled": true, "locations": [], diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc3198456b..251c759ad5 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -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" }, @@ -120,6 +123,7 @@ }, "allow_websocket_upgrade": false, "http2_support": false, + "http3_support": false, "forward_scheme": "http", "enabled": true, "locations": [], diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8fc2..52b221eb0b 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -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" }, @@ -117,6 +120,7 @@ "meta": {}, "allow_websocket_upgrade": false, "http2_support": false, + "http3_support": false, "forward_scheme": "http", "enabled": true, "locations": [], diff --git a/backend/templates/_assets.conf b/backend/templates/_assets.conf index dcb183c555..d84726bdc1 100644 --- a/backend/templates/_assets.conf +++ b/backend/templates/_assets.conf @@ -1,4 +1,14 @@ {% if caching_enabled == 1 or caching_enabled == true -%} # Asset Caching - include conf.d/include/assets.conf; + location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|js\.map)$ { + # cache 200s and also 404s (not ideal but there are a few 404 images for some reason) + proxy_cache_valid any 30m; + proxy_cache_valid 404 1m; + + expires @30m; + +{% include "_http3_headers.conf" %} + + include conf.d/include/assets-common.conf; + } {% endif %} \ No newline at end of file diff --git a/backend/templates/_http3_headers.conf b/backend/templates/_http3_headers.conf new file mode 100644 index 0000000000..57fc67a035 --- /dev/null +++ b/backend/templates/_http3_headers.conf @@ -0,0 +1,9 @@ +{% if certificate -%} +{% if http3_support == 1 or http3_support == true -%} + set $npm_http3_alt_svc ""; + if ($scheme = https) { + set $npm_http3_alt_svc 'h3=":{{ public_https_port }}"; ma=86400'; + } + add_header Alt-Svc $npm_http3_alt_svc always; +{% endif %} +{% endif %} diff --git a/backend/templates/_listen.conf b/backend/templates/_listen.conf index 34a808e6a0..0cc8e5b836 100644 --- a/backend/templates/_listen.conf +++ b/backend/templates/_listen.conf @@ -11,10 +11,18 @@ {% else -%} #listen [::]:443; {% endif %} +{% if http3_support == 1 or http3_support == true -%} + listen 443 quic; +{% if ipv6 -%} + listen [::]:443 quic; +{% else -%} + #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 %} \ No newline at end of file +{% endif %} diff --git a/backend/templates/_location.conf b/backend/templates/_location.conf index a2ecb166d6..13c545fbd4 100644 --- a/backend/templates/_location.conf +++ b/backend/templates/_location.conf @@ -14,6 +14,7 @@ {% include "_exploits.conf" %} {% include "_forced_ssl.conf" %} {% include "_hsts.conf" %} + {% include "_http3_headers.conf" %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; @@ -21,4 +22,3 @@ proxy_http_version 1.1; {% endif %} } - diff --git a/backend/templates/http3_listener.conf b/backend/templates/http3_listener.conf new file mode 100644 index 0000000000..583576e74a --- /dev/null +++ b/backend/templates/http3_listener.conf @@ -0,0 +1,14 @@ +# HTTP/3 wildcard socket owner. Generated only while an enabled HTTP/3 proxy host exists. +server { + listen 443 quic reuseport default_server; +{% if ipv6 -%} + listen [::]:443 quic reuseport default_server; +{% endif %} + + server_name _; + ssl_reject_handshake on; + access_log off; + error_log /dev/null crit; + + return 444; +} diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa2..9bb30b5303 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -15,6 +15,13 @@ server { {% include "_exploits.conf" %} {% include "_hsts.conf" %} {% include "_forced_ssl.conf" %} +{% include "_http3_headers.conf" %} + +{% if certificate -%} +{% if http3_support == 1 or http3_support == true -%} + http3 on; +{% endif %} +{% endif %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; @@ -35,6 +42,7 @@ proxy_http_version 1.1; {% include "_access.conf" %} {% include "_hsts.conf" %} +{% include "_http3_headers.conf" %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; diff --git a/backend/test/http3-lifecycle.test.js b/backend/test/http3-lifecycle.test.js new file mode 100644 index 0000000000..0902113771 --- /dev/null +++ b/backend/test/http3-lifecycle.test.js @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import createPromiseQueue from "../lib/promise-queue.js"; + +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); + +test("serializes HTTP/3-aware Nginx lifecycle tasks", async () => { + const queue = createPromiseQueue(); + const events = []; + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = queue(async () => { + events.push("first:start"); + await firstGate; + events.push("first:end"); + }); + const second = queue(async () => { + events.push("second:start"); + }); + + await nextTurn(); + assert.deepEqual(events, ["first:start"]); + releaseFirst(); + await Promise.all([first, second]); + assert.deepEqual(events, ["first:start", "first:end", "second:start"]); +}); + +test("continues the HTTP/3-aware lifecycle queue after a failed task", async () => { + const queue = createPromiseQueue(); + await assert.rejects( + queue(async () => { + throw new Error("expected lifecycle failure"); + }), + /expected lifecycle failure/, + ); + + const result = await queue(async () => "next task ran"); + assert.equal(result, "next task ran"); +}); diff --git a/backend/test/http3-templates.test.js b/backend/test/http3-templates.test.js new file mode 100644 index 0000000000..022e986476 --- /dev/null +++ b/backend/test/http3-templates.test.js @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import utils from "../lib/utils.js"; + +const render = async (templateName, data) => { + const template = fs.readFileSync(new URL(`../templates/${templateName}`, import.meta.url), "utf8"); + return utils.getRenderEngine().parseAndRender(template, data); +}; + +test("renders HTTP/3 listeners and Alt-Svc only for opted-in TLS hosts", async () => { + const data = { + certificate: { provider: "other" }, + domain_names: ["example.com"], + http2_support: true, + http3_support: true, + ipv6: true, + public_https_port: 8443, + }; + const listeners = await render("_listen.conf", data); + const headers = await render("_http3_headers.conf", data); + const socketOwner = await render("http3_listener.conf", { ipv6: true, public_https_port: 8443 }); + + assert.match(listeners, /listen 443 quic;/); + assert.match(listeners, /listen \[::\]:443 quic;/); + assert.match(headers, /add_header Alt-Svc \$npm_http3_alt_svc always;/); + assert.match(headers, /if \(\$scheme = https\)/); + assert.match(headers, /h3=":8443"; ma=86400/); + assert.match(socketOwner, /listen 443 quic reuseport default_server;/); +}); + +test("keeps Alt-Svc out of HTTP-only and opted-out proxy hosts", async () => { + const httpOnly = await render("_http3_headers.conf", { + certificate: null, + http3_support: true, + public_https_port: 443, + }); + const optedOut = await render("_http3_headers.conf", { + certificate: { provider: "other" }, + http3_support: false, + public_https_port: 443, + }); + + assert.doesNotMatch(httpOnly, /Alt-Svc/); + assert.doesNotMatch(optedOut, /Alt-Svc/); +}); + +test("adds Alt-Svc inside the asset-cache location", async () => { + const assets = await render("_assets.conf", { + caching_enabled: true, + certificate: { provider: "other" }, + http3_support: true, + public_https_port: 8443, + }); + + assert.match(assets, /location ~\* \^\.\*\\\.\(css\|js/); + assert.match(assets, /add_header Alt-Svc \$npm_http3_alt_svc always;/); + assert.match(assets, /include conf\.d\/include\/assets-common\.conf;/); +}); diff --git a/docker/Dockerfile b/docker/Dockerfile index 01b478b0e1..b4c94084e3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -31,11 +31,13 @@ RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +RUN nginx -V 2>&1 | grep -q -- --with-http_v3_module + # s6 overlay COPY docker/scripts/install-s6 /tmp/install-s6 RUN /tmp/install-s6 "${TARGETPLATFORM}" && rm -f /tmp/install-s6 -EXPOSE 80 81 443 +EXPOSE 80/tcp 81/tcp 443/tcp 443/udp COPY backend /app COPY frontend/dist /app/frontend diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index 56875fd849..d7a0c65511 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -18,6 +18,8 @@ RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +RUN nginx -V 2>&1 | grep -q -- --with-http_v3_module + # Task WORKDIR /usr RUN curl -sL 'https://taskfile.dev/install.sh' | sh @@ -38,5 +40,5 @@ RUN ln -s NginxProxyManager.crt 1d0e3f10.0 && update-ca-certificates WORKDIR /root -EXPOSE 80 81 443 +EXPOSE 80/tcp 81/tcp 443/tcp 443/udp ENTRYPOINT [ "/init" ] diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index 1bb3c7450b..1ac02316e1 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -27,6 +27,7 @@ services: - "80/tcp" - "81/tcp" - "443/tcp" + - "443/udp" - "1500/tcp" - "1501/tcp" - "1502/tcp" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d6b07ec012..71ab12fccc 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -9,7 +9,8 @@ services: ports: - 3080:80 - 3081:81 - - 3443:443 + - 3443:443/tcp + - 3443:443/udp networks: nginx_proxy_manager: aliases: @@ -21,6 +22,7 @@ services: PUID: 1000 PGID: 1000 FORCE_COLOR: 1 + NPM_PUBLIC_HTTPS_PORT: 3443 # specifically for dev: DEBUG: "true" DEVELOPMENT: "true" diff --git a/docker/rootfs/etc/nginx/conf.d/include/assets-common.conf b/docker/rootfs/etc/nginx/conf.d/include/assets-common.conf new file mode 100644 index 0000000000..ab9489d607 --- /dev/null +++ b/docker/rootfs/etc/nginx/conf.d/include/assets-common.conf @@ -0,0 +1,24 @@ +if_modified_since off; + +# use the public cache +proxy_cache public-cache; +proxy_cache_key $host$request_uri; + +# ignore these headers for media +proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires; + +# strip this header to avoid If-Modified-Since requests +proxy_hide_header Last-Modified; +proxy_hide_header Cache-Control; +proxy_hide_header Vary; + +proxy_cache_bypass 0; +proxy_no_cache 0; + +proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_404; +proxy_connect_timeout 5s; +proxy_read_timeout 45s; + +access_log off; + +include conf.d/include/proxy.conf; diff --git a/docker/rootfs/etc/nginx/conf.d/include/assets.conf b/docker/rootfs/etc/nginx/conf.d/include/assets.conf index 5a90beb8ae..22efa08fe7 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/assets.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/assets.conf @@ -1,31 +1,9 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|js\.map)$ { - if_modified_since off; - - # use the public cache - proxy_cache public-cache; - proxy_cache_key $host$request_uri; - - # ignore these headers for media - proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires; - # cache 200s and also 404s (not ideal but there are a few 404 images for some reason) proxy_cache_valid any 30m; proxy_cache_valid 404 1m; - # strip this header to avoid If-Modified-Since requests - proxy_hide_header Last-Modified; - proxy_hide_header Cache-Control; - proxy_hide_header Vary; - - proxy_cache_bypass 0; - proxy_no_cache 0; - - proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_404; - proxy_connect_timeout 5s; - proxy_read_timeout 45s; - expires @30m; - access_log off; - include conf.d/include/proxy.conf; + include conf.d/include/assets-common.conf; } diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index bdba3b3055..d50c52e3ea 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -87,6 +87,7 @@ http { # Files generated by NPM include /etc/nginx/conf.d/*.conf; include /data/nginx/default_host/*.conf; + include /data/nginx/http3/*.conf; include /data/nginx/proxy_host/*.conf; include /data/nginx/redirection_host/*.conf; include /data/nginx/dead_host/*.conf; diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh index 2f59ef41ac..1181f2d7e2 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh @@ -22,6 +22,7 @@ mkdir -p \ /data/access \ /data/nginx/default_host \ /data/nginx/default_www \ + /data/nginx/http3 \ /data/nginx/proxy_host \ /data/nginx/redirection_host \ /data/nginx/stream \ diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index a93a1a4d09..2696b3372a 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -107,7 +107,8 @@ services: # Public HTTP Port: - '80:80' # Public HTTPS Port: - - '443:443' + - '443:443/tcp' + - '443:443/udp' # Admin Web Port: - '81:81' environment: @@ -249,6 +250,30 @@ On startup, we generate a resolvers directive for Nginx unless this is defined: In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file and then the `/etc/resolv.conf`. +## HTTP/3 / QUIC + +HTTP/3 uses UDP in addition to the normal HTTPS TCP listener. Publish the same public port for both protocols and allow UDP port 443 through your firewall and router: + +```yml + ports: + - '443:443/tcp' + - '443:443/udp' +``` + +If HTTPS is published on a non-standard public port, set that port so the generated `Alt-Svc` header advertises the correct endpoint: + +```yml + environment: + NPM_PUBLIC_HTTPS_PORT: '8443' + ports: + - '8443:443/tcp' + - '8443:443/udp' +``` + +An enabled UDP stream on port 443 and HTTP/3 cannot be used at the same time. Nginx Proxy Manager rejects the second setting instead of generating a conflicting Nginx configuration. + +When managed HTTP/3 is enabled, do not add manual `listen ... quic` directives in a Proxy Host's Advanced configuration or in custom includes such as `root.conf`, `http.conf`, or `server_proxy.conf`. The managed listener owns the shared QUIC socket. Nginx Proxy Manager rejects detected per-host conflicts; QUIC listeners injected through custom files are unsupported and can make the Nginx configuration invalid. Completely custom raw `location` blocks are outside managed header inheritance; add the appropriate `Alt-Svc` header there yourself if required. + ## Changing the Admin UI port from 81 to something else diff --git a/docs/src/guide/index.md b/docs/src/guide/index.md index 6e2d1feb63..48ac4f6946 100644 --- a/docs/src/guide/index.md +++ b/docs/src/guide/index.md @@ -48,7 +48,7 @@ so that the barrier for entry here is low. I won't go in to too much detail here but here are the basics for someone new to this self-hosted world. 1. Your home router will have a Port Forwarding section somewhere. Log in and find it -2. Add port forwarding for port 80 and 443 to the server hosting this project +2. Add port forwarding for TCP ports 80 and 443, plus UDP port 443 for HTTP/3, to the server hosting this project 3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS or [Amazon Route53](https://github.com/jc21/route53-ddns) 4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services @@ -71,7 +71,8 @@ services: ports: - '80:80' - '81:81' - - '443:443' + - '443:443/tcp' + - '443:443/udp' volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt diff --git a/docs/src/setup/index.md b/docs/src/setup/index.md index 844a80dfdc..42ed54e5e9 100644 --- a/docs/src/setup/index.md +++ b/docs/src/setup/index.md @@ -17,7 +17,8 @@ services: ports: # These ports are in format : - '80:80' # Public HTTP Port - - '443:443' # Public HTTPS Port + - '443:443/tcp' # Public HTTPS Port + - '443:443/udp' # Public HTTP/3 QUIC Port - '81:81' # Admin Web Port # Add any other Stream port you want to expose # - '21:21' # FTP @@ -60,7 +61,8 @@ services: ports: # These ports are in format : - '80:80' # Public HTTP Port - - '443:443' # Public HTTPS Port + - '443:443/tcp' # Public HTTPS Port + - '443:443/udp' # Public HTTP/3 QUIC Port - '81:81' # Admin Web Port # Add any other Stream port you want to expose # - '21:21' # FTP @@ -123,7 +125,8 @@ services: ports: # These ports are in format : - '80:80' # Public HTTP Port - - '443:443' # Public HTTPS Port + - '443:443/tcp' # Public HTTPS Port + - '443:443/udp' # Public HTTP/3 QUIC Port - '81:81' # Admin Web Port # Add any other Stream port you want to expose # - '21:21' # FTP diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..08a795c455 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -123,6 +123,7 @@ export interface ProxyHost { meta: Record; allowWebsocketUpgrade: boolean; http2Support: boolean; + http3Support: boolean; enabled: boolean; locations?: ProxyLocation[]; hstsEnabled: boolean; diff --git a/frontend/src/components/Form/SSLOptionsFields.tsx b/frontend/src/components/Form/SSLOptionsFields.tsx index ebfe38a65d..7f4506fcbd 100644 --- a/frontend/src/components/Form/SSLOptionsFields.tsx +++ b/frontend/src/components/Form/SSLOptionsFields.tsx @@ -22,7 +22,7 @@ export function SSLOptionsFields({ const newCertificate = v?.certificateId === "new"; const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0); - const { sslForced, http2Support, hstsEnabled, hstsSubdomains, trustForwardedProto, meta } = v; + const { sslForced, http2Support, http3Support, hstsEnabled, hstsSubdomains, trustForwardedProto, meta } = v; const { dnsChallenge } = meta || {}; if (forceDNSForNew && newCertificate && !dnsChallenge) { @@ -44,11 +44,12 @@ export function SSLOptionsFields({ const getHttpOptions = () => (
-
+
{({ field }: any) => ( -
-
+
+
+
{({ field }: any) => ( -
+ {forProxyHost ? ( +
+ + {({ field }: any) => ( + + )} + +
+ ) : null}
+ {forProxyHost ? ( + + + + ) : null}
{({ field }: any) => ( -