Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -66,7 +66,8 @@ services:
ports:
- '80:80'
- '81:81'
- '443:443'
- '443:443/tcp'
- '443:443/udp'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
248 changes: 248 additions & 0 deletions backend/internal/http3.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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<void>}
*/
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<string|null>}
*/
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<boolean>}
*/
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;
Loading