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 80cb0c2904..0e6969b979 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,77 +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, false, 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(() => { - internalNginx.renameConfigAsError(host_type, host); - }) - .then(() => { - return internalNginx.deleteConfig(host_type, host, true); - }); - }); - }) - .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; }, /** @@ -109,13 +107,25 @@ const internalNginx = { /** * @returns {Promise} */ - reload: () => { - return internalNginx.test().then(() => { + 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 @@ -155,9 +165,12 @@ const internalNginx = { { certificate_id: host.certificate_id }, { ssl_forced: host.ssl_forced }, { caching_enabled: host.caching_enabled }, + { asset_cache_ttl: host.asset_cache_ttl }, { 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 }, @@ -190,6 +203,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(); @@ -241,9 +258,6 @@ const internalNginx = { locationsPromise = Promise.resolve(); } - // Set the IPv6 setting for the host - host.ipv6 = internalNginx.ipv6Enabled(); - locationsPromise.then(() => { renderEngine .parseAndRender(template, host) @@ -435,6 +449,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..d98af1db3b 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -6,12 +6,20 @@ 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"]; }; +const cleanHttp3Data = (data) => { + if (!data.certificate_id) { + data.http3_support = false; + } + return data; +}; + const internalProxyHost = { /** * @param {Access} access @@ -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,65 @@ const internalProxyHost = { } if (createCertificate) { - return internalCertificate - .createQuickCertificate(access, { + 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 +302,23 @@ const internalProxyHost = { delete: (access, data) => { return access .can("proxy_hosts:delete", data.id) - .then(() => { - return internalProxyHost.get(access, { id: data.id }); - }) - .then((row) => { + .then(() => internalNginx.withConfigLock(async () => { + const row = await internalProxyHost.get(access, { id: data.id }); 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()), - }); - }); - }) + 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,13 +334,11 @@ const internalProxyHost = { enable: (access, data) => { return access .can("proxy_hosts:update", data.id) - .then(() => { - return internalProxyHost.get(access, { + .then(() => internalNginx.withConfigLock(async () => { + const row = await internalProxyHost.get(access, { id: data.id, expand: ["certificate", "owner", "access_list"], }); - }) - .then((row) => { if (!row?.id) { throw new errs.ItemNotFoundError(data.id); } @@ -336,26 +348,19 @@ const internalProxyHost = { 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()), - }); - }); - }) + 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,10 +376,8 @@ const internalProxyHost = { disable: (access, data) => { return access .can("proxy_hosts:update", data.id) - .then(() => { - return internalProxyHost.get(access, { id: data.id }); - }) - .then((row) => { + .then(() => internalNginx.withConfigLock(async () => { + const row = await internalProxyHost.get(access, { id: data.id }); if (!row?.id) { throw new errs.ItemNotFoundError(data.id); } @@ -384,28 +387,17 @@ const internalProxyHost = { 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()), - }); - }); - }) + 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..b2f02b5e27 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,23 @@ const internalStream = { delete: (access, data) => { return access .can("streams:delete", data.id) - .then(() => { - return internalStream.get(access, { id: data.id }); - }) - .then((row) => { + .then(() => internalNginx.withConfigLock(async () => { + const row = await internalStream.get(access, { id: data.id }); 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()), - }); - }); - }) + 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,13 +258,11 @@ const internalStream = { enable: (access, data) => { return access .can("streams:update", data.id) - .then(() => { - return internalStream.get(access, { + .then(() => internalNginx.withConfigLock(async () => { + const row = await internalStream.get(access, { id: data.id, expand: ["certificate", "owner"], }); - }) - .then((row) => { if (!row?.id) { throw new errs.ItemNotFoundError(data.id); } @@ -283,26 +272,19 @@ const internalStream = { 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()), - }); - }); - }) + 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,10 +300,8 @@ const internalStream = { disable: (access, data) => { return access .can("streams:update", data.id) - .then(() => { - return internalStream.get(access, { id: data.id }); - }) - .then((row) => { + .then(() => internalNginx.withConfigLock(async () => { + const row = await internalStream.get(access, { id: data.id }); if (!row?.id) { throw new errs.ItemNotFoundError(data.id); } @@ -331,28 +311,17 @@ const internalStream = { 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()), - }); - }); - }) + 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/20260825180000_proxy_host_performance.js b/backend/migrations/20260825180000_proxy_host_performance.js new file mode 100644 index 0000000000..d9a168ece5 --- /dev/null +++ b/backend/migrations/20260825180000_proxy_host_performance.js @@ -0,0 +1,43 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "proxy_host_performance"; + +/** + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable("proxy_host", (table) => { + table.tinyint("gzip_enabled").notNullable().defaultTo(1); + table.integer("gzip_comp_level").notNullable().unsigned().defaultTo(1); + table.json("gzip_types").nullable(); + table.integer("asset_cache_ttl").notNullable().unsigned().defaultTo(1800); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +/** + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .alterTable("proxy_host", (table) => { + table.dropColumn("gzip_enabled"); + table.dropColumn("gzip_comp_level"); + table.dropColumn("gzip_types"); + table.dropColumn("asset_cache_ttl"); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +export { up, down }; diff --git a/backend/migrations/20260825180100_proxy_host_http3.js b/backend/migrations/20260825180100_proxy_host_http3.js new file mode 100644 index 0000000000..4d8394c40e --- /dev/null +++ b/backend/migrations/20260825180100_proxy_host_http3.js @@ -0,0 +1,59 @@ +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..dda1bbc893 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -15,9 +15,11 @@ const boolFields = [ "is_deleted", "ssl_forced", "caching_enabled", + "gzip_enabled", "block_exploits", "allow_websocket_upgrade", "http2_support", + "http3_support", "enabled", "hsts_enabled", "hsts_subdomains", @@ -39,6 +41,10 @@ class ProxyHost extends Model { this.meta = {}; } + if (!Array.isArray(this.gzip_types)) { + this.gzip_types = []; + } + this.domain_names.sort(); } @@ -53,7 +59,11 @@ class ProxyHost extends Model { $parseDatabaseJson(json) { const thisJson = super.$parseDatabaseJson(json); - return convertIntFieldsToBool(thisJson, boolFields); + const parsedJson = convertIntFieldsToBool(thisJson, boolFields); + if (!Array.isArray(parsedJson.gzip_types)) { + parsedJson.gzip_types = []; + } + return parsedJson; } $formatDatabaseJson(json) { @@ -70,7 +80,7 @@ class ProxyHost extends Model { } static get jsonAttributes() { - return ["domain_names", "meta", "locations"]; + return ["domain_names", "meta", "locations", "gzip_types"]; } static get defaultAllowGraph() { 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..c7f941cd2a 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", @@ -128,6 +133,52 @@ "type": "boolean", "example": true }, + "asset_cache_ttl": { + "description": "Asset cache lifetime in seconds", + "type": "integer", + "minimum": 1, + "maximum": 31536000, + "example": 1800 + }, + "gzip_enabled": { + "description": "Should responses be compressed with gzip", + "type": "boolean", + "example": true + }, + "gzip_comp_level": { + "description": "Gzip compression level", + "type": "integer", + "minimum": 1, + "maximum": 9, + "example": 1 + }, + "gzip_types": { + "description": "Additional MIME types to compress; text/html is always compressed by Nginx", + "type": "array", + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "application/atom+xml", + "application/javascript", + "application/json", + "application/ld+json", + "application/manifest+json", + "application/rss+xml", + "application/wasm", + "application/xhtml+xml", + "application/xml", + "font/otf", + "font/ttf", + "image/svg+xml", + "text/css", + "text/plain", + "text/xml" + ] + }, + "example": ["application/javascript", "application/json", "text/css"] + }, "email": { "description": "Email address", "type": "string", diff --git a/backend/schema/components/certificate-object.json b/backend/schema/components/certificate-object.json index 80cd92befe..8008b7acec 100644 --- a/backend/schema/components/certificate-object.json +++ b/backend/schema/components/certificate-object.json @@ -16,6 +16,12 @@ "owner_user_id": { "$ref": "../common.json#/properties/user_id" }, + "is_deleted": { + "type": "boolean", + "description": "Whether the certificate has been soft-deleted", + "readOnly": true, + "example": false + }, "provider": { "$ref": "../common.json#/properties/ssl_provider" }, diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..6881058bbf 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -13,11 +13,16 @@ "certificate_id", "ssl_forced", "caching_enabled", + "asset_cache_ttl", + "gzip_enabled", + "gzip_comp_level", + "gzip_types", "block_exploits", "advanced_config", "meta", "allow_websocket_upgrade", "http2_support", + "http3_support", "forward_scheme", "enabled", "locations", @@ -65,6 +70,18 @@ "caching_enabled": { "$ref": "../common.json#/properties/caching_enabled" }, + "asset_cache_ttl": { + "$ref": "../common.json#/properties/asset_cache_ttl" + }, + "gzip_enabled": { + "$ref": "../common.json#/properties/gzip_enabled" + }, + "gzip_comp_level": { + "$ref": "../common.json#/properties/gzip_comp_level" + }, + "gzip_types": { + "$ref": "../common.json#/properties/gzip_types" + }, "block_exploits": { "$ref": "../common.json#/properties/block_exploits" }, @@ -87,6 +104,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..26acab9178 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -46,6 +46,10 @@ "certificate_id": 1, "ssl_forced": false, "caching_enabled": false, + "asset_cache_ttl": 1800, + "gzip_enabled": true, + "gzip_comp_level": 1, + "gzip_types": [], "block_exploits": false, "advanced_config": "", "meta": { @@ -54,6 +58,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..faae23af64 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -43,6 +43,10 @@ "certificate_id": 0, "ssl_forced": false, "caching_enabled": false, + "asset_cache_ttl": 1800, + "gzip_enabled": true, + "gzip_comp_level": 1, + "gzip_types": [], "block_exploits": false, "advanced_config": "", "meta": { @@ -51,6 +55,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..d1b0e6cfff 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -62,12 +62,27 @@ "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" }, "caching_enabled": { "$ref": "../../../../components/proxy-host-object.json#/properties/caching_enabled" }, + "asset_cache_ttl": { + "$ref": "../../../../components/proxy-host-object.json#/properties/asset_cache_ttl" + }, + "gzip_enabled": { + "$ref": "../../../../components/proxy-host-object.json#/properties/gzip_enabled" + }, + "gzip_comp_level": { + "$ref": "../../../../components/proxy-host-object.json#/properties/gzip_comp_level" + }, + "gzip_types": { + "$ref": "../../../../components/proxy-host-object.json#/properties/gzip_types" + }, "allow_websocket_upgrade": { "$ref": "../../../../components/proxy-host-object.json#/properties/allow_websocket_upgrade" }, @@ -112,6 +127,10 @@ "certificate_id": 0, "ssl_forced": false, "caching_enabled": false, + "asset_cache_ttl": 1800, + "gzip_enabled": true, + "gzip_comp_level": 1, + "gzip_types": [], "block_exploits": false, "advanced_config": "", "meta": { @@ -120,6 +139,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..0e6700bd2d 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -54,12 +54,27 @@ "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" }, "caching_enabled": { "$ref": "../../../components/proxy-host-object.json#/properties/caching_enabled" }, + "asset_cache_ttl": { + "$ref": "../../../components/proxy-host-object.json#/properties/asset_cache_ttl" + }, + "gzip_enabled": { + "$ref": "../../../components/proxy-host-object.json#/properties/gzip_enabled" + }, + "gzip_comp_level": { + "$ref": "../../../components/proxy-host-object.json#/properties/gzip_comp_level" + }, + "gzip_types": { + "$ref": "../../../components/proxy-host-object.json#/properties/gzip_types" + }, "allow_websocket_upgrade": { "$ref": "../../../components/proxy-host-object.json#/properties/allow_websocket_upgrade" }, @@ -112,11 +127,16 @@ "certificate_id": 0, "ssl_forced": false, "caching_enabled": false, + "asset_cache_ttl": 1800, + "gzip_enabled": true, + "gzip_comp_level": 1, + "gzip_types": [], "block_exploits": false, "advanced_config": "", "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..421dacdf82 100644 --- a/backend/templates/_assets.conf +++ b/backend/templates/_assets.conf @@ -1,4 +1,12 @@ {% if caching_enabled == 1 or caching_enabled == true -%} # Asset Caching - include conf.d/include/assets.conf; -{% endif %} \ No newline at end of file + location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|js\.map)$ { + proxy_cache_valid any {{ asset_cache_ttl }}s; + proxy_cache_valid 404 1m; + expires {{ asset_cache_ttl }}s; + +{% include "_http3_headers.conf" %} + + include conf.d/include/assets-common.conf; + } +{% endif %} diff --git a/backend/templates/_gzip.conf b/backend/templates/_gzip.conf new file mode 100644 index 0000000000..b5c2fac980 --- /dev/null +++ b/backend/templates/_gzip.conf @@ -0,0 +1,13 @@ +{% if gzip_enabled == 1 or gzip_enabled == true -%} + # Response Compression + gzip on; + gzip_comp_level {{ gzip_comp_level }}; + gzip_vary on; + gzip_proxied any; +{% if gzip_types != empty -%} + gzip_types {{ gzip_types | join: " " }}; +{% endif %} +{% else -%} + # Response Compression + gzip off; +{% endif %} 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..785abb5304 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -11,10 +11,18 @@ server { {% include "_listen.conf" %} {% include "_certificates.conf" %} +{% include "_gzip.conf" %} {% include "_assets.conf" %} {% 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 +43,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/nginx-lifecycle.test.js b/backend/test/nginx-lifecycle.test.js new file mode 100644 index 0000000000..970763d1cd --- /dev/null +++ b/backend/test/nginx-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 Nginx configuration 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 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/proxy-host-templates.test.js b/backend/test/proxy-host-templates.test.js new file mode 100644 index 0000000000..61ec979b61 --- /dev/null +++ b/backend/test/proxy-host-templates.test.js @@ -0,0 +1,90 @@ +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 managed gzip settings", async () => { + const config = await render("_gzip.conf", { + gzip_enabled: true, + gzip_comp_level: 7, + gzip_types: ["application/json", "text/css"], + }); + + assert.match(config, /gzip on;/); + assert.match(config, /gzip_comp_level 7;/); + assert.match(config, /gzip_types application\/json text\/css;/); +}); + +test("can disable gzip for one proxy host", async () => { + const config = await render("_gzip.conf", { + gzip_enabled: false, + gzip_comp_level: 1, + gzip_types: [], + }); + + assert.doesNotMatch(config, /gzip on;/); + assert.match(config, /gzip off;/); + assert.doesNotMatch(config, /gzip_types/); +}); + +test("renders a numeric asset cache lifetime", async () => { + const config = await render("_assets.conf", { + asset_cache_ttl: 21600, + caching_enabled: true, + certificate: null, + http3_support: false, + }); + + assert.match(config, /proxy_cache_valid any 21600s;/); + assert.match(config, /expires 21600s;/); + assert.match(config, /include conf\.d\/include\/assets-common\.conf;/); +}); + +test("uses the proxy host cache lifetime inside custom locations", async () => { + const config = await render("_location.conf", { + access_list_id: 0, + advanced_config: "", + allow_websocket_upgrade: false, + asset_cache_ttl: 3600, + block_exploits: false, + caching_enabled: true, + certificate: null, + forward_host: "127.0.0.1", + forward_path: "", + forward_port: 80, + forward_scheme: "http", + hsts_enabled: false, + http3_support: false, + path: "/assets", + ssl_forced: false, + }); + + assert.match(config, /location \/assets \{/); + assert.match(config, /proxy_cache_valid any 3600s;/); +}); + +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;/); +}); 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..428e88dbe7 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -118,11 +118,16 @@ export interface ProxyHost { certificateId: number; sslForced: boolean; cachingEnabled: boolean; + assetCacheTtl: number; + gzipEnabled: boolean; + gzipCompLevel: number; + gzipTypes: string[]; blockExploits: boolean; advancedConfig: string; meta: Record; allowWebsocketUpgrade: boolean; http2Support: boolean; + http3Support: boolean; enabled: boolean; locations?: ProxyLocation[]; hstsEnabled: boolean; diff --git a/frontend/src/components/Form/ProxyPerformanceOptionsFields.test.tsx b/frontend/src/components/Form/ProxyPerformanceOptionsFields.test.tsx new file mode 100644 index 0000000000..022024cf6e --- /dev/null +++ b/frontend/src/components/Form/ProxyPerformanceOptionsFields.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { Form, Formik } from "formik"; +import { describe, expect, it } from "vitest"; +import { ProxyPerformanceOptionsFields } from "./ProxyPerformanceOptionsFields"; + +const renderFields = (overrides = {}) => + render( + undefined} + > +
+ + +
, + ); + +describe("ProxyPerformanceOptionsFields", () => { + it("shows the cache lifetime only while asset caching is enabled", async () => { + renderFields(); + + expect(document.getElementById("assetCacheTtl")).toBeNull(); + fireEvent.click(document.getElementById("cachingEnabled") as HTMLElement); + + await waitFor(() => { + expect(document.getElementById("assetCacheTtl")).not.toBeNull(); + }); + + fireEvent.click(document.getElementById("cachingEnabled") as HTMLElement); + await waitFor(() => { + expect(document.getElementById("assetCacheTtl")).toBeNull(); + }); + }); + + it("shows gzip level and MIME types only while gzip is enabled", async () => { + renderFields(); + + expect(document.getElementById("gzipCompLevel")).toBeNull(); + expect(document.getElementById("gzipTypes")).toBeNull(); + fireEvent.click(document.getElementById("gzipEnabled") as HTMLElement); + + await waitFor(() => { + expect(document.getElementById("gzipCompLevel")).not.toBeNull(); + expect(document.getElementById("gzipTypes")).not.toBeNull(); + }); + + fireEvent.click(document.getElementById("gzipEnabled") as HTMLElement); + await waitFor(() => { + expect(document.getElementById("gzipCompLevel")).toBeNull(); + expect(document.getElementById("gzipTypes")).toBeNull(); + }); + }); + + it("aligns the gzip controls and renders their help across the full row", () => { + renderFields({ gzipEnabled: true }); + + const controlsRow = document.getElementById("gzipCompLevel")?.closest(".row"); + const help = document.querySelector(".form-hint"); + + expect(controlsRow?.classList.contains("align-items-end")).toBe(true); + expect(help?.previousElementSibling).toBe(controlsRow); + expect(help?.closest(".col-md-9")).toBeNull(); + }); +}); diff --git a/frontend/src/components/Form/ProxyPerformanceOptionsFields.tsx b/frontend/src/components/Form/ProxyPerformanceOptionsFields.tsx new file mode 100644 index 0000000000..85deb00392 --- /dev/null +++ b/frontend/src/components/Form/ProxyPerformanceOptionsFields.tsx @@ -0,0 +1,179 @@ +import cn from "classnames"; +import { Field, useFormikContext } from "formik"; +import type { ActionMeta, MultiValue } from "react-select"; +import Select from "react-select"; +import { T } from "src/locale"; +import { validateNumber } from "src/modules/Validations"; + +type SelectOption = { + label: string; + value: string; +}; + +type FormValues = { + assetCacheTtl: number; + cachingEnabled: boolean; + gzipCompLevel: number; + gzipEnabled: boolean; + gzipTypes: string[]; +}; + +const gzipTypeOptions: SelectOption[] = [ + "application/atom+xml", + "application/javascript", + "application/json", + "application/ld+json", + "application/manifest+json", + "application/rss+xml", + "application/wasm", + "application/xhtml+xml", + "application/xml", + "font/otf", + "font/ttf", + "image/svg+xml", + "text/css", + "text/plain", + "text/xml", +].map((value) => ({ label: value, value })); + +const compressionLevels = Array.from({ length: 9 }, (_, index) => index + 1); + +interface Props { + color?: string; +} + +export function ProxyPerformanceOptionsFields({ color = "bg-cyan" }: Props) { + const { values, setFieldValue } = useFormikContext(); + const { assetCacheTtl, cachingEnabled, gzipCompLevel, gzipEnabled, gzipTypes } = values; + + const handleGzipTypesChange = ( + selected: MultiValue, + _actionMeta: ActionMeta, + ) => { + setFieldValue( + "gzipTypes", + selected.map((option) => option.value), + ); + }; + + const toggleRow = (name: "cachingEnabled" | "gzipEnabled", label: string, enabled: boolean) => ( +
+ +
+ ); + + return ( + <> + {toggleRow("cachingEnabled", "host.flags.cache-assets", cachingEnabled)} + {cachingEnabled ? ( +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.assetCacheTtl && form.touched.assetCacheTtl ? ( +
{form.errors.assetCacheTtl}
+ ) : null} + + + +
+ )} +
+
+ ) : null} + + {toggleRow("gzipEnabled", "host.flags.gzip", gzipEnabled)} + {gzipEnabled ? ( +
+
+
+ + {({ field, form }: any) => ( +
+ + +
+ )} +
+
+
+ + {({ field }: any) => ( +
+ +
-
+
+
+
{({ field }: any) => ( -
+ {forProxyHost ? ( +
+ + {({ field }: any) => ( + + )} + +
+ ) : null}
+ {forProxyHost ? ( + + + + ) : null}
{({ field }: any) => ( -