diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 3a2ae9e8f9..d44ec466d4 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@v11
with:
stale-issue-label: 'stale'
stale-pr-label: 'stale'
diff --git a/.version b/.version
index 3b1fc7950f..7524906967 100644
--- a/.version
+++ b/.version
@@ -1 +1 @@
-2.15.1
+2.16.0
diff --git a/README.md b/README.md
index 5cd9be1c96..0ee51c3508 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-
+
diff --git a/SECURITY.md b/SECURITY.md
index 66e4bb1f61..a1d30620c7 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -7,8 +7,8 @@ Older versions are not actively maintained.
| Version | Supported |
| ------- | --------- |
-| 2.15.x (latest) | :white_check_mark: |
-| < 2.15.0 | :x: |
+| 2.16.x (latest) | :white_check_mark: |
+| < 2.16.0 | :x: |
Docker images: `jc21/nginx-proxy-manager:latest`, `jc21/nginx-proxy-manager:2`
diff --git a/backend/biome.json b/backend/biome.json
index a0164fd99c..5d1794b5a9 100644
--- a/backend/biome.json
+++ b/backend/biome.json
@@ -1,91 +1,74 @@
{
- "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
- "vcs": {
- "enabled": true,
- "clientKind": "git",
- "useIgnoreFile": true
- },
- "files": {
- "ignoreUnknown": false,
- "includes": [
- "**/*.ts",
- "**/*.tsx",
- "**/*.js",
- "**/*.jsx",
- "!**/dist/**/*"
- ]
- },
- "formatter": {
- "enabled": true,
- "indentStyle": "tab",
- "indentWidth": 4,
- "lineWidth": 120,
- "formatWithErrors": true
- },
- "assist": {
- "actions": {
- "source": {
- "organizeImports": {
- "level": "on",
- "options": {
- "groups": [
- ":BUN:",
- ":NODE:",
- [
- "npm:*",
- "npm:*/**"
- ],
- ":PACKAGE_WITH_PROTOCOL:",
- ":URL:",
- ":PACKAGE:",
- [
- "/src/*",
- "/src/**"
- ],
- [
- "/**"
- ],
- [
- "#*",
- "#*/**"
- ],
- ":PATH:"
- ]
- }
- }
- }
- }
- },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": true,
- "correctness": {
- "useUniqueElementIds": "off"
- },
- "suspicious": {
- "noExplicitAny": "off"
- },
- "performance": {
- "noDelete": "off"
- },
- "nursery": "off",
- "a11y": {
- "useSemanticElements": "off",
- "useValidAnchor": "off"
- },
- "style": {
- "noParameterAssign": "error",
- "useAsConstAssertion": "error",
- "useDefaultParameterLast": "error",
- "useEnumInitializers": "error",
- "useSelfClosingElements": "error",
- "useSingleVarDeclarator": "error",
- "noUnusedTemplateLiteral": "error",
- "useNumberNamespace": "error",
- "noInferrableTypes": "error",
- "noUselessElse": "error"
- }
- }
- }
+ "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": false,
+ "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "tab",
+ "indentWidth": 4,
+ "lineWidth": 120,
+ "formatWithErrors": true
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": {
+ "level": "on",
+ "options": {
+ "groups": [
+ ":BUN:",
+ ":NODE:",
+ ["npm:*", "npm:*/**"],
+ ":PACKAGE_WITH_PROTOCOL:",
+ ":URL:",
+ ":PACKAGE:",
+ ["/src/*", "/src/**"],
+ ["/**"],
+ ["#*", "#*/**"],
+ ":PATH:"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "correctness": {
+ "useUniqueElementIds": "off"
+ },
+ "suspicious": {
+ "noExplicitAny": "off"
+ },
+ "performance": {
+ "noDelete": "off"
+ },
+ "nursery": "off",
+ "a11y": {
+ "useSemanticElements": "off",
+ "useValidAnchor": "off"
+ },
+ "style": {
+ "noParameterAssign": "error",
+ "useAsConstAssertion": "error",
+ "useDefaultParameterLast": "error",
+ "useEnumInitializers": "error",
+ "useSelfClosingElements": "error",
+ "useSingleVarDeclarator": "error",
+ "noUnusedTemplateLiteral": "error",
+ "useNumberNamespace": "error",
+ "noInferrableTypes": "error",
+ "noUselessElse": "error"
+ }
+ }
+ }
}
diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json
index 220819404e..4467a27afe 100644
--- a/backend/certbot/dns-plugins.json
+++ b/backend/certbot/dns-plugins.json
@@ -32,12 +32,12 @@
"version": ">=0.1.0"
},
"azure": {
- "credentials": "# This plugin supported API authentication using either Service Principals or utilizing a Managed Identity assigned to the virtual machine.\n# Regardless which authentication method used, the identity will need the “DNS Zone Contributor” role assigned to it.\n# As multiple Azure DNS Zones in multiple resource groups can exist, the config file needs a mapping of zone to resource group ID. Multiple zones -> ID mappings can be listed by using the key dns_azure_zoneX where X is a unique number. At least 1 zone mapping is required.\n\n# Using a service principal (option 1)\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = E-xqXU83Y-jzTI6xe9fs2YC~mck3ZzUih9\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n\n# Using used assigned MSI (option 2)\n# dns_azure_msi_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\n\n# Using system assigned MSI (option 3)\n# dns_azure_msi_system_assigned = true\n\n# Zones (at least one always required)\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/dns1\ndns_azure_zone2 = example.org:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2",
- "dependencies": "azure-mgmt-dns==8.2.0",
+ "credentials": "# Docs: https://cloudchristoph.github.io/certbot-dns-azure-modern/\n# Service principal with the \"DNS Zone Contributor\" role on the DNS zone\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = example-client-secret-not-real\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n# One line per DNS zone, format ZONE_NAME:RESOURCE_GROUP_ID\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/rg-dns",
+ "dependencies": "",
"full_plugin_name": "dns-azure",
"name": "Azure",
- "package_name": "certbot-dns-azure",
- "version": "~=2.6.1"
+ "package_name": "certbot-dns-azure-modern",
+ "version": "~=2.8.0"
},
"baidu": {
"credentials": "dns_baidu_access_key = 12345678\ndns_baidu_secret_key = 1234567890abcdef1234567890abcdef",
@@ -231,6 +231,14 @@
"package_name": "certbot-plugin-edgedns",
"version": "~=0.1.0"
},
+ "edgeone": {
+ "credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY",
+ "dependencies": "",
+ "full_plugin_name": "dns-edgeone",
+ "name": "Tencent Cloud EdgeOne",
+ "package_name": "certbot-dns-edgeone",
+ "version": ">=0.1.0"
+ },
"eurodns": {
"credentials": "dns_eurodns_applicationId = myuser\ndns_eurodns_apiKey = mysecretpassword\ndns_eurodns_endpoint = https://rest-api.eurodns.com/dns-zones/",
"dependencies": "",
@@ -629,7 +637,7 @@
"full_plugin_name": "dns-tencentcloud",
"name": "Tencent Cloud",
"package_name": "certbot-dns-tencentcloud",
- "version": "~=2.0.2"
+ "version": "~=2.1.1"
},
"timeweb": {
"credentials": "dns_timeweb_api_key = XXXXXXXXXXXXXXXXXXX",
@@ -637,7 +645,7 @@
"full_plugin_name": "dns-timeweb",
"name": "Timeweb Cloud",
"package_name": "certbot-dns-timeweb",
- "version": "~=1.0.1"
+ "version": "~=2.0.0"
},
"transip": {
"credentials": "dns_transip_username = my_username\ndns_transip_key_file = /etc/letsencrypt/transip-rsa.key",
@@ -661,7 +669,7 @@
"full_plugin_name": "dns-websupport",
"name": "Websupport.sk",
"package_name": "certbot-dns-websupport",
- "version": "~=2.0.1"
+ "version": "~=5.0.0"
},
"wedos": {
"credentials": "dns_wedos_user = \ndns_wedos_auth = ",
@@ -686,5 +694,13 @@
"name": "RcodeZero",
"package_name": "certbot-dns-rcode0",
"version": "~=0.0.0.2"
+ },
+ "lws": {
+ "credentials": "dns_lws_login = 123456\ndns_lws_api_key = YOUR_API_KEY",
+ "dependencies": "",
+ "full_plugin_name": "dns-lws",
+ "name": "LWS",
+ "package_name": "certbot-dns-lws",
+ "version": "~=1.0.0"
}
}
diff --git a/backend/db.js b/backend/db.js
index bf540f8a22..5b18d3a9a4 100644
--- a/backend/db.js
+++ b/backend/db.js
@@ -1,5 +1,5 @@
import knex from "knex";
-import {configGet, configHas} from "./lib/config.js";
+import { configGet, configHas } from "./lib/config.js";
let instance = null;
@@ -23,8 +23,8 @@ const generateDbConfig = () => {
user: cfg.user,
password: cfg.password,
database: cfg.name,
- port: cfg.port,
- ...(cfg.ssl ? { ssl: cfg.ssl } : {})
+ port: cfg.port,
+ ...(cfg.ssl ? { ssl: cfg.ssl } : {}),
},
migrations: {
tableName: "migrations",
@@ -37,6 +37,6 @@ const getInstance = () => {
instance = knex(generateDbConfig());
}
return instance;
-}
+};
export default getInstance;
diff --git a/backend/internal/2fa.js b/backend/internal/2fa.js
index 43307e02c3..db1c3385ea 100644
--- a/backend/internal/2fa.js
+++ b/backend/internal/2fa.js
@@ -161,12 +161,12 @@ const internal2fa = {
}
const result = await verify({
- token: code,
- secret: auth.meta.totp_secret,
- guardrails: createGuardrails({
- MIN_SECRET_BYTES: 10,
- }),
- });
+ token: code,
+ secret: auth.meta.totp_secret,
+ guardrails: createGuardrails({
+ MIN_SECRET_BYTES: 10,
+ }),
+ });
if (!result.valid) {
throw new errs.AuthError("Invalid verification code");
@@ -288,11 +288,7 @@ const internal2fa = {
},
getUserPasswordAuth: async (userId) => {
- const auth = await authModel
- .query()
- .where("user_id", userId)
- .andWhere("type", "password")
- .first();
+ const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first();
if (!auth) {
throw new errs.ItemNotFoundError("Auth not found");
diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js
index 88bf9df523..1fdd65b6ab 100644
--- a/backend/internal/access-list.js
+++ b/backend/internal/access-list.js
@@ -2,7 +2,9 @@ import fs from "node:fs";
import batchflow from "batchflow";
import _ from "lodash";
import errs from "../lib/error.js";
+import { isMysql, isPostgres } from "../lib/config.js";
import utils from "../lib/utils.js";
+import db from "../db.js";
import { access as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import accessListAuthModel from "../models/access_list_auth.js";
@@ -15,6 +17,38 @@ const omissions = () => {
return ["is_deleted"];
};
+/**
+ * Find proxy hosts that reference an access list in their locations JSON.
+ *
+ * @param {Integer} accessListId
+ * @returns {Promise}
+ */
+const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
+ let result;
+ if (isMysql()) {
+ const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
+ result = await db().raw(
+ "SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)",
+ [searchObj, "$"],
+ );
+ } else if (isPostgres()) {
+ result = await db().raw(
+ "SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb",
+ [JSON.stringify([{ access_list_id: accessListId }])],
+ );
+ } else {
+ result = await db().raw(
+ "SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?",
+ [`%"access_list_id":${accessListId}%`],
+ );
+ }
+ // knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
+ if (!Array.isArray(result)) {
+ return result?.rows || [];
+ }
+ return (Array.isArray(result[0]) ? result[0] : result) || [];
+};
+
const internalAccessList = {
/**
* @param {Access} access
@@ -66,7 +100,7 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
},
- true // skip masking
+ true, // skip masking
);
// Audit log
@@ -180,13 +214,31 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
},
- true // skip masking
+ true, // skip masking
);
- await internalAccessList.build(freshRow)
+ await internalAccessList.build(freshRow);
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
}
+
+ // Also regenerate configs for proxy hosts that reference this access list in their locations
+ const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
+ if (locationHostRows?.length) {
+ const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
+ // Exclude hosts already regenerated above
+ return !freshRow.proxy_hosts?.find((h) => h.id === id);
+ });
+ if (locationHostIds.length) {
+ const locationHosts = await proxyHostModel.query()
+ .where("is_deleted", 0)
+ .whereIn("id", locationHostIds)
+ .allowGraph(proxyHostModel.defaultAllowGraph)
+ .withGraphFetched("[owner, certificate, access_list.[clients,items]]");
+ await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
+ }
+ }
+
await internalNginx.reload();
return internalAccessList.maskItems(freshRow);
},
@@ -202,17 +254,13 @@ const internalAccessList = {
*/
get: async (access, data, skipMasking) => {
const thisData = data || {};
- const accessData = await access.can("access_lists:get", thisData.id)
+ const accessData = await access.can("access_lists:get", thisData.id);
const query = accessListModel
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
- this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
- "proxy_host.is_deleted",
- "=",
- 0,
- );
+ this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.andWhere("access_list.id", thisData.id)
@@ -267,28 +315,41 @@ const internalAccessList = {
// 4. audit log
// 1. update row to be deleted
- await accessListModel
- .query()
- .where("id", row.id)
- .patch({
- is_deleted: 1,
- });
+ await accessListModel.query().where("id", row.id).patch({
+ is_deleted: 1,
+ });
// 2. update any proxy hosts that were using it (ignoring permissions)
- if (row.proxy_hosts) {
- await proxyHostModel
- .query()
- .where("access_list_id", "=", row.id)
- .patch({ access_list_id: 0 });
+ const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
+ if (affectedHostIds.size) {
+ await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
+ }
- // 3. reconfigure those hosts, then reload nginx
- // set the access_list_id to zero for these items
- row.proxy_hosts.map((_val, idx) => {
- row.proxy_hosts[idx].access_list_id = 0;
- return true;
- });
+ // Also clear it from any proxy host locations using it, these will then inherit the host's access list
+ const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
+ for (const { id: hostId } of locationHostRows) {
+ const host = await proxyHostModel.query().where("id", hostId).first();
+ if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
+ const updatedLocations = host.locations.map((loc) => {
+ if (loc.access_list_id === row.id) {
+ return { ...loc, access_list_id: 0 };
+ }
+ return loc;
+ });
+ await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
+ affectedHostIds.add(hostId);
+ }
+ }
- await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
+ // 3. reconfigure those hosts from fresh rows, then reload nginx
+ if (affectedHostIds.size) {
+ const affectedHosts = await proxyHostModel
+ .query()
+ .where("is_deleted", 0)
+ .whereIn("id", [...affectedHostIds])
+ .allowGraph(proxyHostModel.defaultAllowGraph)
+ .withGraphFetched("[owner, certificate, access_list.[clients,items]]");
+ await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
}
await internalNginx.reload();
@@ -325,11 +386,7 @@ const internalAccessList = {
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
- this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
- "proxy_host.is_deleted",
- "=",
- 0,
- );
+ this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.groupBy("access_list.id")
@@ -371,10 +428,7 @@ const internalAccessList = {
* @returns {Promise}
*/
getCount: async (userId, visibility) => {
- const query = accessListModel
- .query()
- .count("id as count")
- .where("is_deleted", 0);
+ const query = accessListModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") {
query.andWhere("owner_user_id", userId);
@@ -436,20 +490,24 @@ const internalAccessList = {
}
// 2. create empty access file
- fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
+ fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
// 3. generate password for each user
if (list.items.length) {
await new Promise((resolve, reject) => {
- batchflow(list.items).sequential()
+ batchflow(list.items)
+ .sequential()
.each((_i, item, next) => {
if (item.password?.length) {
logger.info(`Adding: ${item.username}`);
- utils.execFile('openssl', ['passwd', '-apr1', item.password])
+ utils
+ .execFile("openssl", ["passwd", "-apr1", item.password])
.then((res) => {
try {
- fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'});
+ fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
+ encoding: "utf8",
+ });
} catch (err) {
reject(err);
}
@@ -471,7 +529,7 @@ const internalAccessList = {
});
});
}
- }
-}
+ },
+};
export default internalAccessList;
diff --git a/backend/internal/audit-log.js b/backend/internal/audit-log.js
index 02700dc5da..5155028c87 100644
--- a/backend/internal/audit-log.js
+++ b/backend/internal/audit-log.js
@@ -3,7 +3,6 @@ import { castJsonIfNeed } from "../lib/helpers.js";
import auditLogModel from "../models/audit-log.js";
const internalAuditLog = {
-
/**
* All logs
*
@@ -46,11 +45,7 @@ const internalAuditLog = {
get: async (access, data) => {
await access.can("auditlog:list");
- const query = auditLogModel
- .query()
- .andWhere("id", data.id)
- .allowGraph("[user]")
- .first();
+ const query = auditLogModel.query().andWhere("id", data.id).allowGraph("[user]").first();
if (typeof data.expand !== "undefined" && data.expand !== null) {
query.withGraphFetched(`[${data.expand.join(", ")}]`);
diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js
index 6498422c61..963d2bb6a4 100644
--- a/backend/internal/certificate.js
+++ b/backend/internal/certificate.js
@@ -881,10 +881,20 @@ const internalCertificate = {
const result = await utils.execFile(certbotCommand, args, adds.opts);
logger.info(result);
return result;
- } catch (err) {
- // Don't fail if file does not exist, so no need for action in the callback
+ } finally {
+ // Remove the credentials file whether certbot succeeded or failed.
+ //
+ // This cleanup used to sit in a catch block, so it only ran when issuance FAILED.
+ // A certificate that issued successfully left its DNS provider API credentials in
+ // /etc/letsencrypt/credentials for the entire life of that certificate. Nothing
+ // reads the file between certbot runs, so there is no reason to keep it:
+ // renewLetsEncryptSslWithDnsChallenge() writes it again immediately before each
+ // renewal.
+ //
+ // unlink is fire-and-forget with an empty callback. If the file is already gone
+ // that is the end state we wanted anyway, and a missing file must never turn a
+ // successful issuance into a failure.
fs.unlink(credentialsLocation, () => {});
- throw err;
}
},
@@ -981,6 +991,43 @@ const internalCertificate = {
`Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
);
+ // certbot reads the DNS credentials back from the path recorded in the renewal config
+ // it wrote at issuance time, for example:
+ //
+ // authenticator = dns-cloudflare
+ // dns_cloudflare_credentials = /etc/letsencrypt/credentials/credentials-27
+ //
+ // so the file has to be present for the duration of this run. Write it here and remove
+ // it again below rather than leaving it on disk between renewals.
+ //
+ // Leaving it is an avoidable exposure. Anything running as root - a compromised
+ // process, a script, malware - can read the token and use it to issue valid Let's
+ // Encrypt certificates for the domain. Those certificates are genuinely trusted, so
+ // traffic presented with them passes TLS inspection, IDS/IPS and DLP that would
+ // otherwise flag it, and an exfiltration path built on them looks like ordinary
+ // HTTPS. The exposure window should be one certbot run, not the life of the
+ // certificate.
+ //
+ // The value is not on the certificate object we were handed: renew() sources that from
+ // internalCertificate.get(), which pipes the row through utils.omitRow(omissions()) so
+ // meta.dns_provider_credentials can never travel out over the API. Read the row from
+ // the model directly to get at it.
+ const row = await certificateModel.query().where("id", certificate.id).first();
+ const credentials = row?.meta?.dns_provider_credentials;
+ const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
+
+ if (credentials) {
+ fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true });
+ fs.writeFileSync(credentialsLocation, credentials, { mode: 0o600 });
+ } else {
+ // Nothing stored to write. A certificate issued under the previous behaviour may
+ // still have its file on disk; leave it be and let certbot decide. Throwing here
+ // would break a renewal that would otherwise have succeeded.
+ logger.warn(
+ `No stored DNS credentials for Cert #${certificate.id}; relying on any existing ${credentialsLocation}`,
+ );
+ }
+
const args = [
"renew",
"--force-renewal",
@@ -1008,9 +1055,19 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
- const result = await utils.execFile(certbotCommand, args, adds.opts);
- logger.info(result);
- return result;
+ try {
+ const result = await utils.execFile(certbotCommand, args, adds.opts);
+ logger.info(result);
+ return result;
+ } finally {
+ // Only clean up a file we put there ourselves. If `credentials` came back empty we
+ // wrote nothing, and an older file left on disk by the previous behaviour is the
+ // only thing keeping that certificate renewable - deleting it would break the next
+ // run for no gain.
+ if (credentials) {
+ fs.unlink(credentialsLocation, () => {});
+ }
+ }
},
/**
diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js
index ee27e53cc5..6523e62892 100644
--- a/backend/internal/dead-host.js
+++ b/backend/internal/dead-host.js
@@ -54,9 +54,7 @@ const internalDeadHost = {
thisData.advanced_config = "";
}
- const row = await deadHostModel.query()
- .insertAndFetch(thisData)
- .then(utils.omitRow(omissions()));
+ const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
// Add to audit log
await internalAuditLog.add(access, {
@@ -153,12 +151,8 @@ const internalDeadHost = {
thisData = internalHost.cleanSslHstsData(thisData, row);
-
// do the row update
- await deadHostModel
- .query()
- .where({id: data.id})
- .patch(data);
+ await deadHostModel.query().where({ id: data.id }).patch(data);
// Add to audit log
await internalAuditLog.add(access, {
@@ -168,15 +162,18 @@ const internalDeadHost = {
meta: thisData,
});
- const thisRow = await internalDeadHost
- .get(access, {
- id: thisData.id,
- expand: ["owner", "certificate"],
- });
+ const thisRow = await internalDeadHost.get(access, {
+ id: thisData.id,
+ expand: ["owner", "certificate"],
+ });
// Configure nginx
- const newMeta = await internalNginx.configure(deadHostModel, "dead_host", row);
- row.meta = newMeta;
+ if (!thisRow.enabled) {
+ // No need to add nginx config if host is disabled
+ return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
+ }
+ const newMeta = await internalNginx.configure(deadHostModel, "dead_host", thisRow);
+ thisRow.meta = newMeta;
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
},
@@ -224,18 +221,15 @@ const internalDeadHost = {
* @returns {Promise}
*/
delete: async (access, data) => {
- await access.can("dead_hosts:delete", data.id)
+ await access.can("dead_hosts:delete", data.id);
const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
- await deadHostModel
- .query()
- .where("id", row.id)
- .patch({
- is_deleted: 1,
- });
+ await deadHostModel.query().where("id", row.id).patch({
+ is_deleted: 1,
+ });
// Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row);
@@ -259,7 +253,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
enable: async (access, data) => {
- await access.can("dead_hosts:update", data.id)
+ await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, {
id: data.id,
expand: ["certificate", "owner"],
@@ -273,12 +267,9 @@ const internalDeadHost = {
row.enabled = 1;
- await deadHostModel
- .query()
- .where("id", row.id)
- .patch({
- enabled: 1,
- });
+ await deadHostModel.query().where("id", row.id).patch({
+ enabled: 1,
+ });
// Configure nginx
await internalNginx.configure(deadHostModel, "dead_host", row);
@@ -301,7 +292,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
disable: async (access, data) => {
- await access.can("dead_hosts:update", data.id)
+ await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
@@ -312,12 +303,9 @@ const internalDeadHost = {
row.enabled = 0;
- await deadHostModel
- .query()
- .where("id", row.id)
- .patch({
- enabled: 0,
- });
+ await deadHostModel.query().where("id", row.id).patch({
+ enabled: 0,
+ });
// Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row);
@@ -342,7 +330,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
getAll: async (access, expand, searchQuery) => {
- const accessData = await access.can("dead_hosts:list")
+ const accessData = await access.can("dead_hosts:list");
const query = deadHostModel
.query()
.where("is_deleted", 0)
diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js
new file mode 100644
index 0000000000..2b1b8e9675
--- /dev/null
+++ b/backend/internal/log-viewer.js
@@ -0,0 +1,229 @@
+import fs from "node:fs";
+import errs from "../lib/error.js";
+import internalDeadHost from "./dead-host.js";
+import internalProxyHost from "./proxy-host.js";
+import internalRedirectionHost from "./redirection-host.js";
+import internalStream from "./stream.js";
+
+const SYSTEM_LOG_FILE = "/data/logs/backend.log";
+const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log";
+
+// Matches the access_log/error_log paths written by the nginx templates
+// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf).
+// This is a fixed, server-side lookup table - a host log path is always derived
+// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied
+// path or filename, so there is no path-traversal surface here.
+const HOST_FILE_PREFIX = {
+ proxy: "proxy-host",
+ redirection: "redirection-host",
+ dead: "dead-host",
+ stream: "stream",
+};
+
+const DEFAULT_LINES = 200;
+const MAX_LINES = 1000;
+const CHUNK_SIZE = 64 * 1024;
+// Never scan further back than this, regardless of how many lines were requested,
+// so a huge or pathological log file can't turn a single request into unbounded I/O.
+const MAX_SCAN_BYTES = 5 * 1024 * 1024;
+
+/**
+ * Reads at most `maxLines` lines from the end of a file, without loading the
+ * whole file into memory. Reads backwards in fixed-size chunks until enough
+ * newlines have been seen, the start of the file is reached, or the hard
+ * MAX_SCAN_BYTES ceiling is hit.
+ *
+ * @param {String} filePath
+ * @param {Number} maxLines
+ * @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>}
+ */
+const readLastLines = async (filePath, maxLines) => {
+ let handle;
+ try {
+ handle = await fs.promises.open(filePath, "r");
+ const stat = await handle.stat();
+ const { size } = stat;
+
+ if (size === 0) {
+ return { lines: [], size: 0, truncated: false };
+ }
+
+ let position = size;
+ let scanned = 0;
+ let newlineCount = 0;
+ const chunks = [];
+
+ while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) {
+ const readSize = Math.min(CHUNK_SIZE, position);
+ position -= readSize;
+ const buffer = Buffer.alloc(readSize);
+ await handle.read(buffer, 0, readSize, position);
+ scanned += readSize;
+ for (let i = buffer.length - 1; i >= 0; i--) {
+ if (buffer[i] === 0x0a) newlineCount++;
+ }
+ chunks.unshift(buffer);
+ }
+
+ const truncated = position > 0 && scanned >= MAX_SCAN_BYTES;
+
+ // If we didn't start reading from byte 0, the first line in our buffer is only
+ // partial *unless* it happens that `position` landed exactly on a line boundary
+ // (the byte right before it is a newline) - check that one byte to avoid
+ // silently dropping a perfectly valid line.
+ let startsOnLineBoundary = position === 0;
+ if (position > 0) {
+ const boundaryByte = Buffer.alloc(1);
+ await handle.read(boundaryByte, 0, 1, position - 1);
+ startsOnLineBoundary = boundaryByte[0] === 0x0a;
+ }
+
+ const text = Buffer.concat(chunks).toString("utf8");
+ const allLines = text.split("\n");
+
+ if (!startsOnLineBoundary && allLines.length > 0) {
+ allLines.shift();
+ }
+ // Drop the trailing empty element caused by a final trailing newline.
+ if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
+ allLines.pop();
+ }
+
+ return { lines: allLines.slice(-maxLines), size, truncated };
+ } finally {
+ if (handle) {
+ await handle.close();
+ }
+ }
+};
+
+/**
+ * Resolves a validated {type, host_type, host_id, channel} selection to the
+ * fixed, absolute path of the log file on disk. Throws if the combination
+ * isn't a recognised source.
+ *
+ * `channel` ("access" | "error") picks which of the two log files nginx writes
+ * per host - it's deliberately not named "stream" to avoid confusion with the
+ * "stream" host_type (TCP/UDP stream hosts).
+ *
+ * @param {Object} data
+ * @returns {String}
+ */
+const resolveFilePath = (data) => {
+ switch (data.type) {
+ case "system":
+ return SYSTEM_LOG_FILE;
+ case "letsencrypt":
+ return LETSENCRYPT_LOG_FILE;
+ case "host": {
+ const prefix = HOST_FILE_PREFIX[data.host_type];
+ if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) {
+ throw new errs.ValidationError("Invalid host log source");
+ }
+ return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`;
+ }
+ default:
+ throw new errs.ItemNotFoundError(data.type);
+ }
+};
+
+/**
+ * @param {Array} rows
+ * @returns {Array}
+ */
+const toHostOptions = (rows) =>
+ rows.map((row) => ({
+ id: row.id,
+ label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`,
+ }));
+
+const internalLogViewer = {
+ /**
+ * Lists the log sources available for the log viewer: the system (backend)
+ * log, the Let's Encrypt (certbot) log, and one entry per host the caller
+ * can see, for each host type.
+ *
+ * @param {Access} access
+ * @returns {Promise}
+ */
+ listSources: async (access) => {
+ await access.can("logs:list");
+
+ const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([
+ internalProxyHost.getAll(access),
+ internalRedirectionHost.getAll(access),
+ internalDeadHost.getAll(access),
+ internalStream.getAll(access),
+ ]);
+
+ return {
+ system: { label: "System" },
+ letsencrypt: { label: "Let's Encrypt" },
+ hosts: {
+ proxy: toHostOptions(proxyHosts),
+ redirection: toHostOptions(redirectionHosts),
+ dead: toHostOptions(deadHosts),
+ stream: streams.map((row) => ({
+ id: row.id,
+ label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`,
+ })),
+ },
+ };
+ },
+
+ /**
+ * Returns the last N lines of the requested log source, optionally
+ * filtered by level and/or a plain-text search term.
+ *
+ * @param {Access} access
+ * @param {Object} data
+ * @param {String} data.type "system" | "letsencrypt" | "host"
+ * @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream"
+ * @param {Number} [data.host_id]
+ * @param {String} [data.channel] "access" | "error"
+ * @param {Number} [data.lines]
+ * @param {String} [data.level]
+ * @param {String} [data.search]
+ * @returns {Promise}
+ */
+ tail: async (access, data) => {
+ await access.can("logs:list");
+
+ const filePath = resolveFilePath(data);
+ const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES);
+
+ let result;
+ try {
+ result = await readLastLines(filePath, lines);
+ } catch (err) {
+ if (err.code === "ENOENT") {
+ return { lines: [], size: 0, truncated: false, exists: false };
+ }
+ throw err;
+ }
+
+ let outputLines = result.lines;
+
+ // Only the system log is written in our own "LEVEL [scope]" format - level
+ // filtering on nginx/certbot lines would just match nothing and look like an
+ // empty log, so the filter is a no-op for any other source.
+ if (data.type === "system" && data.level) {
+ const needle = ` ${data.level.toUpperCase().padEnd(7)} `;
+ outputLines = outputLines.filter((line) => line.includes(needle));
+ }
+
+ if (data.search) {
+ const needle = data.search.toLowerCase();
+ outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle));
+ }
+
+ return {
+ lines: outputLines,
+ size: result.size,
+ truncated: result.truncated,
+ exists: true,
+ };
+ },
+};
+
+export default internalLogViewer;
diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js
index fe84607f96..a50e0780dc 100644
--- a/backend/internal/nginx.js
+++ b/backend/internal/nginx.js
@@ -1,10 +1,12 @@
import fs from "node:fs";
+import net from "node:net";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import _ from "lodash";
import errs from "../lib/error.js";
import utils from "../lib/utils.js";
import { debug, nginx as logger } from "../logger.js";
+import accessListModel from "../models/access_list.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -34,7 +36,7 @@ const internalNginx = {
// 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);
+ return internalNginx.deleteConfig(host_type, host, true);
})
.then(() => {
return internalNginx.generateConfig(host_type, host);
@@ -83,10 +85,12 @@ const internalNginx = {
meta: combined_meta,
})
.then(() => {
- internalNginx.renameConfigAsError(host_type, host);
+ // Keep the failed config as a .err file for inspection
+ return internalNginx.renameConfigAsError(host_type, host);
})
.then(() => {
- return internalNginx.deleteConfig(host_type, host, true);
+ // The rename removed the live config already, don't touch the .err file
+ return internalNginx.deleteConfig(host_type, host, false);
});
});
})
@@ -165,6 +169,24 @@ const internalNginx = {
host.locations[i],
);
+ // A location with its own access list overrides the host's,
+ // otherwise it inherits the host's access list
+ let locationAccessList = null;
+ if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
+ locationAccessList = await accessListModel
+ .query()
+ .where("is_deleted", 0)
+ .andWhere("id", locationCopy.access_list_id)
+ .withGraphFetched("[clients,items]")
+ .first();
+ }
+ if (locationAccessList) {
+ locationCopy.access_list = locationAccessList;
+ } else {
+ locationCopy.access_list_id = host.access_list_id;
+ locationCopy.access_list = host.access_list;
+ }
+
if (locationCopy.forward_host.indexOf("/") > -1) {
const splitted = locationCopy.forward_host.split("/");
@@ -176,7 +198,9 @@ const internalNginx = {
}
};
- locationRendering().then(() => resolve(renderedLocations));
+ locationRendering()
+ .then(() => resolve(renderedLocations))
+ .catch(reject);
});
},
@@ -217,10 +241,21 @@ const internalNginx = {
}
// For redirection hosts, if the scheme is not http or https, set it to $scheme
- if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
+ if (
+ nice_host_type === "redirection_host" &&
+ ["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
+ ) {
host.forward_scheme = "$scheme";
}
+ // A stream forwarding to an IPv6 literal must have the address wrapped in
+ // square brackets before nginx appends ":". Without the brackets nginx
+ // reads the trailing ":" as part of the address and rejects the upstream
+ // ("invalid port in upstream"), so the stream saves but never activates (#5740).
+ if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) {
+ host.forwarding_host = `[${host.forwarding_host}]`;
+ }
+
if (host.locations) {
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
origLocations = [].concat(host.locations);
@@ -257,6 +292,9 @@ const internalNginx = {
debug(logger, `Could not write ${filename}:`, err.message);
reject(new errs.ConfigurationError(err.message));
});
+ }).catch((err) => {
+ debug(logger, `Could not render locations for ${filename}:`, err.message);
+ reject(new errs.ConfigurationError(err.message));
});
});
},
@@ -375,8 +413,8 @@ const internalNginx = {
const config_file_err = `${config_file}.err`;
return new Promise((resolve /*, reject*/) => {
- fs.unlink(config_file, () => {
- // ignore result, continue
+ fs.unlink(config_file_err, () => {
+ // ignore result, a previous .err file may not exist
fs.rename(config_file, config_file_err, () => {
// also ignore result, as this is a debugging informative file anyway
resolve();
diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js
index 2c159d48ad..eac0c148aa 100644
--- a/backend/internal/proxy-host.js
+++ b/backend/internal/proxy-host.js
@@ -83,28 +83,28 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list.[clients,items]"],
});
})
- .then((row) => {
- // Configure nginx
- return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
+ .then(async (row) => {
+ // Configure nginx
+ return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
+ return row;
+ });
+ })
+ .then((row) => {
+ // Audit log
+ thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
+
+ // Add to audit log
+ return internalAuditLog
+ .add(access, {
+ action: "created",
+ object_type: "proxy-host",
+ object_id: row.id,
+ meta: thisData,
+ })
+ .then(() => {
return row;
});
- })
- .then((row) => {
- // Audit log
- thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
-
- // Add to audit log
- return internalAuditLog
- .add(access, {
- action: "created",
- object_type: "proxy-host",
- object_id: row.id,
- meta: thisData,
- })
- .then(() => {
- return row;
- });
- });
+ });
},
/**
@@ -202,24 +202,24 @@ const internalProxyHost = {
});
});
})
- .then(() => {
- return 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());
- });
+ .then(() => {
+ return internalProxyHost
+ .get(access, {
+ id: thisData.id,
+ expand: ["owner", "certificate", "access_list.[clients,items]"],
+ })
+ .then(async (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());
});
- });
+ });
+ });
},
/**
@@ -326,39 +326,36 @@ const internalProxyHost = {
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");
- }
+ .then(async (row) => {
+ if (!row?.id) {
+ throw new errs.ItemNotFoundError(data.id);
+ }
+ if (row.enabled) {
+ throw new errs.ValidationError("Host is already enabled");
+ }
+
+ row.enabled = 1;
+
+ await proxyHostModel
+ .query()
+ .where("id", row.id)
+ .patch({
+ enabled: 1,
+ });
- row.enabled = 1;
+ // Configure nginx
+ await internalNginx.configure(proxyHostModel, "proxy_host", row);
- 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(() => {
- return true;
+ // Add to audit log
+ await internalAuditLog.add(access, {
+ action: "enabled",
+ object_type: "proxy-host",
+ object_id: row.id,
+ meta: _.omit(row, omissions()),
});
+
+ return true;
+ });
},
/**
diff --git a/backend/internal/token.js b/backend/internal/token.js
index 126283e2dd..67255b2134 100644
--- a/backend/internal/token.js
+++ b/backend/internal/token.js
@@ -38,11 +38,7 @@ export default {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
- const auth = await authModel
- .query()
- .where("user_id", "=", user.id)
- .where("type", "=", "password")
- .first();
+ const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first();
if (!auth) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
@@ -50,10 +46,7 @@ export default {
const valid = await auth.verifyPassword(data.secret);
if (!valid) {
- throw new errs.AuthError(
- ERROR_MESSAGE_INVALID_AUTH,
- ERROR_MESSAGE_INVALID_AUTH_I18N,
- );
+ throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) {
@@ -171,7 +164,7 @@ export default {
}
// Check scope
- if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
+ if (tokenData.scope?.[0] !== "2fa-challenge") {
throw new errs.AuthError("Invalid challenge token");
}
@@ -183,10 +176,7 @@ export default {
// Verify 2FA code
const valid = await twoFactor.verifyForLogin(userId, code);
if (!valid) {
- throw new errs.AuthError(
- ERROR_MESSAGE_INVALID_2FA,
- ERROR_MESSAGE_INVALID_2FA_I18N,
- );
+ throw new errs.AuthError(ERROR_MESSAGE_INVALID_2FA, ERROR_MESSAGE_INVALID_2FA_I18N);
}
// Create full token
diff --git a/backend/internal/user.js b/backend/internal/user.js
index 56a5ea8598..8dd86f461a 100644
--- a/backend/internal/user.js
+++ b/backend/internal/user.js
@@ -257,11 +257,9 @@ const internalUser = {
},
deleteAll: async () => {
- await userModel
- .query()
- .patch({
- is_deleted: 1,
- });
+ await userModel.query().patch({
+ is_deleted: 1,
+ });
},
/**
@@ -390,11 +388,21 @@ const internalUser = {
.andWhere("type", data.type)
.first()
.then((existing_auth) => {
+ // Stamped here rather than read off modified_on, because it is compared against a
+ // token's `iat` and the two only line up when the same clock writes both. The
+ // database clock is a different one: with the app on one timezone and the database
+ // on another, its timestamps come back hours away from where Node thinks it is.
+ const password_changed_at = Math.floor(Date.now() / 1000);
+
if (existing_auth) {
// patch
+ const meta = existing_auth.meta || {};
+ meta.password_changed_at = password_changed_at;
+
return authModel.query().where("user_id", user.id).andWhere("type", data.type).patch({
type: data.type, // This is required for the model to encrypt on save
secret: data.secret,
+ meta,
});
}
// insert
@@ -402,7 +410,7 @@ const internalUser = {
user_id: user.id,
type: data.type,
secret: data.secret,
- meta: {},
+ meta: { password_changed_at },
});
})
.then(() => {
diff --git a/backend/knexfile.js b/backend/knexfile.js
index 607552f6fc..0e7e5e5afb 100644
--- a/backend/knexfile.js
+++ b/backend/knexfile.js
@@ -1,19 +1,19 @@
module.exports = {
development: {
- client: 'mysql2',
+ client: "mysql2",
migrations: {
- tableName: 'migrations',
- stub: 'lib/migrate_template.js',
- directory: 'migrations'
- }
+ tableName: "migrations",
+ stub: "lib/migrate_template.js",
+ directory: "migrations",
+ },
},
production: {
- client: 'mysql2',
+ client: "mysql2",
migrations: {
- tableName: 'migrations',
- stub: 'lib/migrate_template.js',
- directory: 'migrations'
- }
- }
+ tableName: "migrations",
+ stub: "lib/migrate_template.js",
+ directory: "migrations",
+ },
+ },
};
diff --git a/backend/lib/access.js b/backend/lib/access.js
index a4dec5c4dd..d119a4f925 100644
--- a/backend/lib/access.js
+++ b/backend/lib/access.js
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import { access as logger } from "../logger.js";
+import authModel from "../models/auth.js";
import proxyHostModel from "../models/proxy_host.js";
import TokenModel from "../models/token.js";
import userModel from "../models/user.js";
@@ -80,6 +81,22 @@ export default function (tokenString) {
if (!ok) {
throw new errs.AuthError("Invalid token scope for User");
}
+
+ // A token issued before the password was last changed is no longer valid: taking an account
+ // back from whoever has the old password has to end the sessions that password opened.
+ const auth = await authModel
+ .query()
+ .where("user_id", "=", user.id)
+ .where("type", "=", "password")
+ .first();
+
+ // Both sides come from the same clock and in the same unit, whole seconds since
+ // the epoch: `setPassword` stamps the marker and `jsonwebtoken` stamps `iat`.
+ const changedAt = auth?.meta?.password_changed_at;
+ if (changedAt && typeof tokenData.iat === "number" && tokenData.iat < changedAt) {
+ throw new errs.TokenRevokedError("Token was issued before the password was changed");
+ }
+
initialised = true;
userRoles = user.roles;
permissions = user.permissions;
@@ -119,10 +136,7 @@ export default function (tokenString) {
// Proxy Hosts
case "proxy_hosts": {
- const query = proxyHostModel
- .query()
- .select("id")
- .andWhere("is_deleted", 0);
+ const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
@@ -271,7 +285,12 @@ export default function (tokenString) {
err.permission = permission;
err.permission_data = data;
logger.error(permission, data, err.message);
- throw errs.PermissionError("Permission Denied", err);
+ // A revoked token is not a permission problem, and the client can tell: the frontend
+ // clears the session on a 401 and on nothing else.
+ if (err instanceof errs.TokenRevokedError) {
+ throw err;
+ }
+ throw new errs.PermissionError("Permission Denied", err);
}
},
};
diff --git a/backend/lib/access/logs-list.json b/backend/lib/access/logs-list.json
new file mode 100644
index 0000000000..aeadc94ba9
--- /dev/null
+++ b/backend/lib/access/logs-list.json
@@ -0,0 +1,7 @@
+{
+ "anyOf": [
+ {
+ "$ref": "roles#/definitions/admin"
+ }
+ ]
+}
diff --git a/backend/lib/config.js b/backend/lib/config.js
index a491c190e8..59cb6598de 100644
--- a/backend/lib/config.js
+++ b/backend/lib/config.js
@@ -2,13 +2,13 @@ import fs from "node:fs";
import NodeRSA from "node-rsa";
import { global as logger } from "../logger.js";
-const keysFile = '/data/keys.json';
-const mysqlEngine = 'mysql2';
-const postgresEngine = 'pg';
-const sqliteClientName = 'better-sqlite3';
+const keysFile = "/data/keys.json";
+const mysqlEngine = "mysql2";
+const postgresEngine = "pg";
+const sqliteClientName = "better-sqlite3";
// Not used for new setups anymore but may exist in legacy setups
-const legacySqliteClientName = 'sqlite3';
+const legacySqliteClientName = "sqlite3";
let instance = null;
@@ -40,14 +40,20 @@ const configure = () => {
}
}
- const toBool = (v) => /^(1|true|yes|on)$/i.test((v || '').trim());
+ const toBool = (v) => /^(1|true|yes|on)$/i.test((v || "").trim());
- const envMysqlHost = process.env.DB_MYSQL_HOST || null;
- const envMysqlUser = process.env.DB_MYSQL_USER || null;
- const envMysqlName = process.env.DB_MYSQL_NAME || null;
- const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
- const envMysqlSSLRejectUnauthorized = process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined ? true : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
- const envMysqlSSLVerifyIdentity = process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined ? true : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
+ const envMysqlHost = process.env.DB_MYSQL_HOST || null;
+ const envMysqlUser = process.env.DB_MYSQL_USER || null;
+ const envMysqlName = process.env.DB_MYSQL_NAME || null;
+ const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
+ const envMysqlSSLRejectUnauthorized =
+ process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined
+ ? true
+ : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
+ const envMysqlSSLVerifyIdentity =
+ process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined
+ ? true
+ : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
if (envMysqlHost && envMysqlUser && envMysqlName) {
// we have enough mysql creds to go with mysql
logger.info("Using MySQL configuration");
@@ -58,8 +64,10 @@ const configure = () => {
port: process.env.DB_MYSQL_PORT || 3306,
user: envMysqlUser,
password: process.env.DB_MYSQL_PASSWORD,
- name: envMysqlName,
- ssl: envMysqlSSL ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } : false,
+ name: envMysqlName,
+ ssl: envMysqlSSL
+ ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity }
+ : false,
},
keys: getKeys(),
};
@@ -137,7 +145,7 @@ const generateKeys = () => {
// Write keys config
try {
- fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2));
+ fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2), { mode: 0o600 });
} catch (err) {
logger.error(`Could not write JWT key pair to config file: ${keysFile}: ${err.message}`);
process.exit(1);
@@ -222,7 +230,7 @@ const isDebugMode = () => !!process.env.DEBUG;
*
* @returns {boolean}
*/
-const isCI = () => process.env.CI === 'true' && process.env.DEBUG === 'true';
+const isCI = () => process.env.CI === "true" && process.env.DEBUG === "true";
/**
* Returns a public key
@@ -259,4 +267,16 @@ const useLetsencryptServer = () => {
return null;
};
-export { isCI, configHas, configGet, isSqlite, isMysql, isPostgres, isDebugMode, getPrivateKey, getPublicKey, useLetsencryptStaging, useLetsencryptServer };
+export {
+ isCI,
+ configHas,
+ configGet,
+ isSqlite,
+ isMysql,
+ isPostgres,
+ isDebugMode,
+ getPrivateKey,
+ getPublicKey,
+ useLetsencryptStaging,
+ useLetsencryptServer,
+};
diff --git a/backend/lib/error.js b/backend/lib/error.js
index d7dbf0c965..060988904d 100644
--- a/backend/lib/error.js
+++ b/backend/lib/error.js
@@ -22,6 +22,15 @@ const errs = {
this.status = 404;
},
+ TokenRevokedError: function (message, previous) {
+ Error.captureStackTrace(this, this.constructor);
+ this.name = this.constructor.name;
+ this.previous = previous;
+ this.message = message;
+ this.public = true;
+ this.status = 401;
+ },
+
AuthError: function (message, messageI18n, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
diff --git a/backend/lib/express/pagination.js b/backend/lib/express/pagination.js
index 188df2779d..457c954d2d 100644
--- a/backend/lib/express/pagination.js
+++ b/backend/lib/express/pagination.js
@@ -1,4 +1,4 @@
-import _ from "lodash";
+import _ from "lodash";
export default (default_sort, default_offset, default_limit, max_limit) => {
/**
diff --git a/backend/lib/express/user-id-from-me.js b/backend/lib/express/user-id-from-me.js
index 9c29ba2721..5035d8fdd9 100644
--- a/backend/lib/express/user-id-from-me.js
+++ b/backend/lib/express/user-id-from-me.js
@@ -1,6 +1,6 @@
export default (req, res, next) => {
- if (req.params.user_id === 'me' && res.locals.access) {
- req.params.user_id = res.locals.access.token.get('attrs').id;
+ if (req.params.user_id === "me" && res.locals.access) {
+ req.params.user_id = res.locals.access.token.get("attrs").id;
} else {
req.params.user_id = Number.parseInt(req.params.user_id, 10);
}
diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js
index 6c738d5097..0597ef635b 100644
--- a/backend/lib/validator/api.js
+++ b/backend/lib/validator/api.js
@@ -1,3 +1,4 @@
+import net from "node:net";
import Ajv from "ajv/dist/2020.js";
import errs from "../error.js";
@@ -9,6 +10,8 @@ const ajv = new Ajv({
coerceTypes: true,
});
+ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
+
/**
* @param {Object} schema
* @param {Object} payload
@@ -24,21 +27,17 @@ const apiValidator = async (schema, payload /*, description*/) => {
throw new errs.ValidationError("Payload is undefined");
}
-
const validate = ajv.compile(schema);
const valid = validate(payload);
-
if (valid && !validate.errors) {
return payload;
}
-
-
const message = ajv.errorsText(validate.errors);
const err = new errs.ValidationError(message);
- err.debug = {validationErrors: validate.errors, payload};
+ err.debug = { validationErrors: validate.errors, payload };
throw err;
};
diff --git a/backend/lib/validator/index.js b/backend/lib/validator/index.js
index 5f2586fd67..767353f61f 100644
--- a/backend/lib/validator/index.js
+++ b/backend/lib/validator/index.js
@@ -1,4 +1,5 @@
-import Ajv from 'ajv/dist/2020.js';
+import net from "node:net";
+import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import commonDefinitions from "../../schema/common.json" with { type: "json" };
import errs from "../error.js";
@@ -14,6 +15,8 @@ const ajv = new Ajv({
schemas: [commonDefinitions],
});
+ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
+
/**
*
* @param {Object} schema
diff --git a/backend/logger.js b/backend/logger.js
index 2b60dbff7b..cd845d5fbb 100644
--- a/backend/logger.js
+++ b/backend/logger.js
@@ -1,3 +1,5 @@
+import fs from "node:fs";
+import path from "node:path";
import signale from "signale";
import { isDebugMode } from "./lib/config.js";
@@ -5,17 +7,115 @@ const opts = {
logLevel: "info",
};
-const global = new signale.Signale({ scope: "Global ", ...opts });
-const migrate = new signale.Signale({ scope: "Migrate ", ...opts });
-const express = new signale.Signale({ scope: "Express ", ...opts });
-const access = new signale.Signale({ scope: "Access ", ...opts });
-const nginx = new signale.Signale({ scope: "Nginx ", ...opts });
-const ssl = new signale.Signale({ scope: "SSL ", ...opts });
-const certbot = new signale.Signale({ scope: "Certbot ", ...opts });
-const importer = new signale.Signale({ scope: "Importer ", ...opts });
-const setup = new signale.Signale({ scope: "Setup ", ...opts });
-const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts });
-const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts });
+// Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc).
+// Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...)
+// are left console-only since they carry no diagnostic value worth persisting.
+const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"];
+
+const LOG_FILE = "/data/logs/backend.log";
+// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk
+const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
+
+let fileStream = null;
+let lastOpenAttempt = 0;
+// If the file can't be opened (eg. running outside the standard Docker image, or in
+// CI without /data), don't retry on every single log call - but do retry periodically
+// so a transient issue (disk full, permissions fixed later) recovers without a restart.
+const REOPEN_COOLDOWN_MS = 30 * 1000;
+
+/**
+ * Lazily opens the backend log file for appending. If the directory isn't writable,
+ * file logging is silently disabled and console logging continues unaffected.
+ *
+ * @returns {import('node:fs').WriteStream|null}
+ */
+const getFileStream = () => {
+ if (fileStream) {
+ return fileStream;
+ }
+
+ const now = Date.now();
+ if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) {
+ return null;
+ }
+ lastOpenAttempt = now;
+
+ try {
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
+ const stream = fs.createWriteStream(LOG_FILE, { flags: "a" });
+ stream.on("error", () => {
+ fileStream = null;
+ });
+ fileStream = stream;
+ } catch (_err) {
+ fileStream = null;
+ }
+ return fileStream;
+};
+
+/**
+ * Formats and appends a single log line to the backend log file.
+ * Never throws - a failure here must never take down the application.
+ *
+ * @param {String} level
+ * @param {String} scope
+ * @param {Array} args
+ */
+const writeToFile = (level, scope, args) => {
+ const stream = getFileStream();
+ if (!stream) {
+ return;
+ }
+
+ const message = args
+ .map((arg) => {
+ if (typeof arg === "string") return arg;
+ if (arg instanceof Error) return arg.stack || arg.message;
+ try {
+ return JSON.stringify(arg);
+ } catch (_err) {
+ return String(arg);
+ }
+ })
+ .join(" ")
+ .replace(ANSI_PATTERN, "");
+
+ const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`;
+ stream.write(line);
+};
+
+/**
+ * Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also
+ * appended to the backend log file, in addition to its normal console output.
+ *
+ * @param {Signale} instance
+ * @param {String} scope
+ * @returns {Signale}
+ */
+const withFileSink = (instance, scope) => {
+ for (const method of PERSISTED_METHODS) {
+ const original = instance[method].bind(instance);
+ instance[method] = (...args) => {
+ writeToFile(method, scope, args);
+ return original(...args);
+ };
+ }
+ return instance;
+};
+
+const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope);
+
+const global = createLogger("Global ");
+const migrate = createLogger("Migrate ");
+const express = createLogger("Express ");
+const access = createLogger("Access ");
+const nginx = createLogger("Nginx ");
+const ssl = createLogger("SSL ");
+const certbot = createLogger("Certbot ");
+const importer = createLogger("Importer ");
+const setup = createLogger("Setup ");
+const ipRanges = createLogger("IP Ranges");
+const remoteVersion = createLogger("Remote Version");
const debug = (logger, ...args) => {
if (isDebugMode()) {
diff --git a/backend/migrations/20190227065017_settings.js b/backend/migrations/20190227065017_settings.js
index a6cbe2c99a..471d0b8577 100644
--- a/backend/migrations/20190227065017_settings.js
+++ b/backend/migrations/20190227065017_settings.js
@@ -13,13 +13,14 @@ const migrateName = "settings";
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
- return knex.schema.createTable('setting', (table) => {
- table.string('id').notNull().primary();
- table.string('name', 100).notNull();
- table.string('description', 255).notNull();
- table.string('value', 255).notNull();
- table.json('meta').notNull();
- })
+ return knex.schema
+ .createTable("setting", (table) => {
+ table.string("id").notNull().primary();
+ table.string("name", 100).notNull();
+ table.string("description", 255).notNull();
+ table.string("value", 255).notNull();
+ table.json("meta").notNull();
+ })
.then(() => {
logger.info(`[${migrateName}] setting Table created`);
});
diff --git a/backend/migrations/20251111090000_redirect_auto_scheme.js b/backend/migrations/20251111090000_redirect_auto_scheme.js
index 9f5f9d0642..a15f3e3f3c 100644
--- a/backend/migrations/20251111090000_redirect_auto_scheme.js
+++ b/backend/migrations/20251111090000_redirect_auto_scheme.js
@@ -17,9 +17,7 @@ const up = (knex) => {
.table("redirection_host", async (table) => {
// change the column default from $scheme to auto
await table.string("forward_scheme").notNull().defaultTo("auto").alter();
- await knex('redirection_host')
- .where('forward_scheme', '$scheme')
- .update({ forward_scheme: 'auto' });
+ await knex("redirection_host").where("forward_scheme", "$scheme").update({ forward_scheme: "auto" });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -38,9 +36,7 @@ const down = (knex) => {
return knex.schema
.table("redirection_host", async (table) => {
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
- await knex('redirection_host')
- .where('forward_scheme', 'auto')
- .update({ forward_scheme: '$scheme' });
+ await knex("redirection_host").where("forward_scheme", "auto").update({ forward_scheme: "$scheme" });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
diff --git a/backend/migrations/20260131163528_trust_forwarded_proto.js b/backend/migrations/20260131163528_trust_forwarded_proto.js
index c32c6fb697..24c50e8d4a 100644
--- a/backend/migrations/20260131163528_trust_forwarded_proto.js
+++ b/backend/migrations/20260131163528_trust_forwarded_proto.js
@@ -11,15 +11,15 @@ const migrateName = "trust_forwarded_proto";
* @returns {Promise}
*/
const up = (knex) => {
- logger.info(`[${migrateName}] Migrating Up...`);
+ logger.info(`[${migrateName}] Migrating Up...`);
- return knex.schema
- .alterTable('proxy_host', (table) => {
- table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0);
- })
- .then(() => {
- logger.info(`[${migrateName}] proxy_host Table altered`);
- });
+ return knex.schema
+ .alterTable("proxy_host", (table) => {
+ table.tinyint("trust_forwarded_proto").notNullable().defaultTo(0);
+ })
+ .then(() => {
+ logger.info(`[${migrateName}] proxy_host Table altered`);
+ });
};
/**
@@ -29,15 +29,15 @@ const up = (knex) => {
* @returns {Promise}
*/
const down = (knex) => {
- logger.info(`[${migrateName}] Migrating Down...`);
+ logger.info(`[${migrateName}] Migrating Down...`);
- return knex.schema
- .alterTable('proxy_host', (table) => {
- table.dropColumn('trust_forwarded_proto');
- })
- .then(() => {
- logger.info(`[${migrateName}] proxy_host Table altered`);
- });
+ return knex.schema
+ .alterTable("proxy_host", (table) => {
+ table.dropColumn("trust_forwarded_proto");
+ })
+ .then(() => {
+ logger.info(`[${migrateName}] proxy_host Table altered`);
+ });
};
-export { up, down };
\ No newline at end of file
+export { up, down };
diff --git a/backend/models/access_list.js b/backend/models/access_list.js
index 427d447d62..192f045b9b 100644
--- a/backend/models/access_list.js
+++ b/backend/models/access_list.js
@@ -31,6 +31,10 @@ class AccessList extends Model {
$parseDatabaseJson(json) {
const thisJson = super.$parseDatabaseJson(json);
+ // Postgres returns COUNT() as a string
+ if (typeof thisJson.proxy_host_count === "string") {
+ thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
+ }
return convertIntFieldsToBool(thisJson, boolFields);
}
diff --git a/backend/models/setting.js b/backend/models/setting.js
index 56f7dc5aac..1fbef374cc 100644
--- a/backend/models/setting.js
+++ b/backend/models/setting.js
@@ -7,23 +7,23 @@ import db from "../db.js";
Model.knex(db());
class Setting extends Model {
- $beforeInsert () {
+ $beforeInsert() {
// Default for meta
- if (typeof this.meta === 'undefined') {
+ if (typeof this.meta === "undefined") {
this.meta = {};
}
}
- static get name () {
- return 'Setting';
+ static get name() {
+ return "Setting";
}
- static get tableName () {
- return 'setting';
+ static get tableName() {
+ return "setting";
}
- static get jsonAttributes () {
- return ['meta'];
+ static get jsonAttributes() {
+ return ["meta"];
}
}
diff --git a/backend/models/user_permission.js b/backend/models/user_permission.js
index d878471780..cc6eac2cc7 100644
--- a/backend/models/user_permission.js
+++ b/backend/models/user_permission.js
@@ -8,21 +8,21 @@ import now from "./now_helper.js";
Model.knex(db());
class UserPermission extends Model {
- $beforeInsert () {
- this.created_on = now();
+ $beforeInsert() {
+ this.created_on = now();
this.modified_on = now();
}
- $beforeUpdate () {
+ $beforeUpdate() {
this.modified_on = now();
}
- static get name () {
- return 'UserPermission';
+ static get name() {
+ return "UserPermission";
}
- static get tableName () {
- return 'user_permission';
+ static get tableName() {
+ return "user_permission";
}
}
diff --git a/backend/package.json b/backend/package.json
index 80ae74b45e..b8e0ed9fc8 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -13,37 +13,37 @@
"regenerate-config": "node scripts/regenerate-config"
},
"dependencies": {
- "@apidevtools/json-schema-ref-parser": "^15.3.5",
+ "@apidevtools/json-schema-ref-parser": "^16.0.2",
"ajv": "^8.20.0",
"archiver": "^8.0.0",
"batchflow": "^0.4.0",
"bcrypt": "^6.0.0",
- "better-sqlite3": "^12.10.0",
- "body-parser": "^2.2.2",
+ "better-sqlite3": "^13.0.3",
+ "body-parser": "^2.3.0",
"chalk": "5.6.2",
- "compression": "^1.8.1",
+ "compression": "^1.8.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"gravatar": "^1.8.2",
"jsonwebtoken": "^9.0.3",
- "knex": "3.2.10",
- "liquidjs": "10.27.0",
+ "knex": "3.3.0",
+ "liquidjs": "10.29.0",
"lodash": "^4.18.1",
- "moment": "^2.30.1",
- "mysql2": "^3.22.3",
- "node-rsa": "^1.1.1",
+ "moment": "^2.31.0",
+ "mysql2": "^3.24.4",
+ "node-rsa": "^2.0.0",
"objection": "3.1.5",
- "otplib": "^13.4.0",
+ "otplib": "^13.5.0",
"path": "^0.12.7",
- "pg": "^8.21.0",
- "proxy-agent": "^8.0.1",
+ "pg": "^8.23.0",
+ "proxy-agent": "^8.0.2",
"signale": "1.4.0",
"sqlite3": "^6.0.1",
"temp-write": "^6.0.1"
},
"devDependencies": {
- "@apidevtools/swagger-parser": "^12.1.0",
- "@biomejs/biome": "^2.4.15",
+ "@apidevtools/swagger-parser": "^13.0.0",
+ "@biomejs/biome": "^2.5.10",
"nodemon": "^3.1.14"
},
"signale": {
diff --git a/backend/routes/audit-log.js b/backend/routes/audit-log.js
index c40b1628d8..54edef6e31 100644
--- a/backend/routes/audit-log.js
+++ b/backend/routes/audit-log.js
@@ -86,10 +86,7 @@ router
},
{
event_id: req.params.event_id,
- expand:
- typeof req.query.expand === "string"
- ? req.query.expand.split(",")
- : null,
+ expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
diff --git a/backend/routes/logs.js b/backend/routes/logs.js
new file mode 100644
index 0000000000..9088507b3a
--- /dev/null
+++ b/backend/routes/logs.js
@@ -0,0 +1,106 @@
+import express from "express";
+import internalLogViewer from "../internal/log-viewer.js";
+import jwtdecode from "../lib/express/jwt-decode.js";
+import validator from "../lib/validator/index.js";
+import { debug, express as logger } from "../logger.js";
+
+const router = express.Router({
+ caseSensitive: true,
+ strict: true,
+ mergeParams: true,
+});
+
+/**
+ * /api/logs/sources
+ */
+router
+ .route("/sources")
+ .options((_, res) => {
+ res.sendStatus(204);
+ })
+ .all(jwtdecode())
+
+ /**
+ * GET /api/logs/sources
+ *
+ * Lists the log sources available for the log viewer
+ */
+ .get(async (req, res, next) => {
+ try {
+ const data = await internalLogViewer.listSources(res.locals.access);
+ res.status(200).send(data);
+ } catch (err) {
+ debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
+ next(err);
+ }
+ });
+
+/**
+ * /api/logs/tail
+ */
+router
+ .route("/tail")
+ .options((_, res) => {
+ res.sendStatus(204);
+ })
+ .all(jwtdecode())
+
+ /**
+ * GET /api/logs/tail
+ *
+ * Retrieve the last N lines of a log source
+ */
+ .get(async (req, res, next) => {
+ try {
+ const data = await validator(
+ {
+ required: ["type"],
+ additionalProperties: false,
+ properties: {
+ type: {
+ type: "string",
+ enum: ["system", "letsencrypt", "host"],
+ },
+ host_type: {
+ anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }],
+ },
+ host_id: {
+ anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }],
+ },
+ channel: {
+ anyOf: [{ type: "null" }, { type: "string", enum: ["access", "error"] }],
+ },
+ lines: {
+ anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: 1000 }],
+ },
+ level: {
+ anyOf: [
+ { type: "null" },
+ { type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] },
+ ],
+ },
+ search: {
+ anyOf: [{ type: "null" }, { type: "string", minLength: 1, maxLength: 200 }],
+ },
+ },
+ },
+ {
+ type: req.query.type,
+ host_type: typeof req.query.host_type === "string" ? req.query.host_type : null,
+ host_id: typeof req.query.host_id !== "undefined" ? req.query.host_id : null,
+ channel: typeof req.query.channel === "string" ? req.query.channel : null,
+ lines: typeof req.query.lines !== "undefined" ? req.query.lines : null,
+ level: typeof req.query.level === "string" ? req.query.level : null,
+ search: typeof req.query.search === "string" ? req.query.search : null,
+ },
+ );
+
+ const result = await internalLogViewer.tail(res.locals.access, data);
+ res.status(200).send(result);
+ } catch (err) {
+ debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
+ next(err);
+ }
+ });
+
+export default router;
diff --git a/backend/routes/main.js b/backend/routes/main.js
index a308ea6179..2d719bb486 100644
--- a/backend/routes/main.js
+++ b/backend/routes/main.js
@@ -6,6 +6,7 @@ import pjson from "../package.json" with { type: "json" };
import { isSetup } from "../setup.js";
import auditLogRoutes from "./audit-log.js";
import ciRoutes from "./ci.js";
+import logsRoutes from "./logs.js";
import accessListsRoutes from "./nginx/access_lists.js";
import certificatesHostsRoutes from "./nginx/certificates.js";
import deadHostsRoutes from "./nginx/dead_hosts.js";
@@ -50,6 +51,7 @@ router.use("/schema", schemaRoutes);
router.use("/tokens", tokensRoutes);
router.use("/users", usersRoutes);
router.use("/audit-log", auditLogRoutes);
+router.use("/logs", logsRoutes);
router.use("/reports", reportsRoutes);
router.use("/settings", settingsRoutes);
router.use("/version", versionRoutes);
diff --git a/backend/routes/nginx/certificates.js b/backend/routes/nginx/certificates.js
index 99f429b446..dea8094230 100644
--- a/backend/routes/nginx/certificates.js
+++ b/backend/routes/nginx/certificates.js
@@ -44,18 +44,11 @@ router
},
},
{
- expand:
- typeof req.query.expand === "string"
- ? req.query.expand.split(",")
- : null,
+ expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
query: typeof req.query.query === "string" ? req.query.query : null,
},
);
- const rows = await internalCertificate.getAll(
- res.locals.access,
- data.expand,
- data.query,
- );
+ const rows = await internalCertificate.getAll(res.locals.access, data.expand, data.query);
res.status(200).send(rows);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -70,15 +63,9 @@ router
*/
.post(async (req, res, next) => {
try {
- const payload = await apiValidator(
- getValidationSchema("/nginx/certificates", "post"),
- req.body,
- );
+ const payload = await apiValidator(getValidationSchema("/nginx/certificates", "post"), req.body);
req.setTimeout(900000); // 15 minutes timeout
- const result = await internalCertificate.create(
- res.locals.access,
- payload,
- );
+ const result = await internalCertificate.create(res.locals.access, payload);
res.status(201).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -139,16 +126,10 @@ router
*/
.post(async (req, res, next) => {
try {
- const payload = await apiValidator(
- getValidationSchema("/nginx/certificates/test-http", "post"),
- req.body,
- );
+ const payload = await apiValidator(getValidationSchema("/nginx/certificates/test-http", "post"), req.body);
req.setTimeout(60000); // 1 minute timeout
- const result = await internalCertificate.testHttpsChallenge(
- res.locals.access,
- payload,
- );
+ const result = await internalCertificate.testHttpsChallenge(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -224,10 +205,7 @@ router
},
{
certificate_id: req.params.certificate_id,
- expand:
- typeof req.query.expand === "string"
- ? req.query.expand.split(",")
- : null,
+ expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
const row = await internalCertificate.get(res.locals.access, {
diff --git a/backend/routes/nginx/dead_hosts.js b/backend/routes/nginx/dead_hosts.js
index 31f7043635..a20df4f5fd 100644
--- a/backend/routes/nginx/dead_hosts.js
+++ b/backend/routes/nginx/dead_hosts.js
@@ -194,9 +194,11 @@ router
/**
* POST /api/nginx/dead-hosts/123/disable
*/
- .post((req, res, next) => {
+ .post(async (req, res, next) => {
try {
- const result = internalDeadHost.disable(res.locals.access, { id: Number.parseInt(req.params.host_id, 10) });
+ const result = await internalDeadHost.disable(res.locals.access, {
+ id: Number.parseInt(req.params.host_id, 10),
+ });
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
diff --git a/backend/routes/nginx/proxy_hosts.js b/backend/routes/nginx/proxy_hosts.js
index 7045a195cc..ef62cf835a 100644
--- a/backend/routes/nginx/proxy_hosts.js
+++ b/backend/routes/nginx/proxy_hosts.js
@@ -1,4 +1,5 @@
import express from "express";
+import fs from "node:fs";
import internalProxyHost from "../../internal/proxy-host.js";
import jwtdecode from "../../lib/express/jwt-decode.js";
import apiValidator from "../../lib/validator/api.js";
@@ -206,4 +207,70 @@ router
}
});
+/**
+ * Proxy-host logs
+ *
+ * /api/nginx/proxy-hosts/123/logs
+ */
+router
+ .route("/:host_id/logs")
+ .options((_, res) => {
+ res.sendStatus(204);
+ })
+ .all(jwtdecode())
+
+ /**
+ * GET /api/nginx/proxy-hosts/123/logs
+ *
+ * Retrieve logs for a specific proxy-host
+ */
+ .get(async (req, res, next) => {
+ try {
+ const data = await validator(
+ {
+ required: ["host_id"],
+ additionalProperties: false,
+ properties: {
+ host_id: {
+ $ref: "common#/properties/id",
+ },
+ type: {
+ type: "string",
+ enum: ["access", "error"],
+ },
+ },
+ },
+ {
+ host_id: req.params.host_id,
+ type: req.query.type || "access",
+ },
+ );
+
+ const hostId = Number.parseInt(data.host_id, 10);
+ const logType = data.type === "error" ? "error" : "access";
+ const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`;
+
+ // Check access permission
+ await res.locals.access.can("proxy_hosts:get", hostId);
+
+ let logs = "";
+ if (fs.existsSync(logFile)) {
+ const content = fs.readFileSync(logFile, { encoding: "utf8" });
+ const lines = content.split("\n");
+ // Return last 1000 lines to avoid huge payloads
+ const maxLines = 1000;
+ if (lines.length > maxLines) {
+ logs = lines.slice(-maxLines).join("\n");
+ } else {
+ logs = content;
+ }
+ }
+
+ res.status(200).send({ logs });
+ } catch (err) {
+ debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
+ next(err);
+ }
+ });
+
export default router;
diff --git a/backend/routes/users.js b/backend/routes/users.js
index 7c3da8c1d1..3f972223be 100644
--- a/backend/routes/users.js
+++ b/backend/routes/users.js
@@ -48,18 +48,11 @@ router
},
},
{
- expand:
- typeof req.query.expand === "string"
- ? req.query.expand.split(",")
- : null,
+ expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
query: typeof req.query.query === "string" ? req.query.query : null,
},
);
- const users = await internalUser.getAll(
- res.locals.access,
- data.expand,
- data.query,
- );
+ const users = await internalUser.getAll(res.locals.access, data.expand, data.query);
res.status(200).send(users);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -95,10 +88,7 @@ router
}
}
- const payload = await apiValidator(
- getValidationSchema("/users", "post"),
- body,
- );
+ const payload = await apiValidator(getValidationSchema("/users", "post"), body);
const user = await internalUser.create(res.locals.access, payload);
res.status(201).send(user);
} catch (err) {
@@ -169,20 +159,14 @@ router
},
{
user_id: req.params.user_id,
- expand:
- typeof req.query.expand === "string"
- ? req.query.expand.split(",")
- : null,
+ expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
const user = await internalUser.get(res.locals.access, {
id: data.user_id,
expand: data.expand,
- omit: internalUser.getUserOmisionsByAccess(
- res.locals.access,
- data.user_id,
- ),
+ omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id),
});
res.status(200).send(user);
} catch (err) {
@@ -198,10 +182,7 @@ router
*/
.put(async (req, res, next) => {
try {
- const payload = await apiValidator(
- getValidationSchema("/users/{userID}", "put"),
- req.body,
- );
+ const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.update(res.locals.access, payload);
res.status(200).send(result);
@@ -248,10 +229,7 @@ router
*/
.put(async (req, res, next) => {
try {
- const payload = await apiValidator(
- getValidationSchema("/users/{userID}/auth", "put"),
- req.body,
- );
+ const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.setPassword(res.locals.access, payload);
res.status(200).send(result);
@@ -281,15 +259,9 @@ router
*/
.put(async (req, res, next) => {
try {
- const payload = await apiValidator(
- getValidationSchema("/users/{userID}/permissions", "put"),
- req.body,
- );
+ const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
payload.id = req.params.user_id;
- const result = await internalUser.setPermissions(
- res.locals.access,
- payload,
- );
+ const result = await internalUser.setPermissions(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -408,10 +380,7 @@ router
*/
.post(async (req, res, next) => {
try {
- const { code } = await apiValidator(
- getValidationSchema("/users/{userID}/2fa/enable", "post"),
- req.body,
- );
+ const { code } = await apiValidator(getValidationSchema("/users/{userID}/2fa/enable", "post"), req.body);
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
res.status(200).send(result);
} catch (err) {
diff --git a/backend/schema/components/log-sources-object.json b/backend/schema/components/log-sources-object.json
new file mode 100644
index 0000000000..e077f1a0f9
--- /dev/null
+++ b/backend/schema/components/log-sources-object.json
@@ -0,0 +1,69 @@
+{
+ "type": "object",
+ "description": "Available log sources for the log viewer",
+ "required": ["system", "letsencrypt", "hosts"],
+ "additionalProperties": false,
+ "properties": {
+ "system": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string",
+ "example": "System"
+ }
+ }
+ },
+ "letsencrypt": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string",
+ "example": "Let's Encrypt"
+ }
+ }
+ },
+ "hosts": {
+ "type": "object",
+ "required": ["proxy", "redirection", "dead", "stream"],
+ "additionalProperties": false,
+ "properties": {
+ "proxy": {
+ "$ref": "#/$defs/host-option-list"
+ },
+ "redirection": {
+ "$ref": "#/$defs/host-option-list"
+ },
+ "dead": {
+ "$ref": "#/$defs/host-option-list"
+ },
+ "stream": {
+ "$ref": "#/$defs/host-option-list"
+ }
+ },
+ "example": {
+ "proxy": [{ "id": 1, "label": "example.com" }],
+ "redirection": [],
+ "dead": [],
+ "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
+ }
+ }
+ },
+ "$defs": {
+ "host-option-list": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "label"],
+ "properties": {
+ "id": {
+ "$ref": "../common.json#/properties/id"
+ },
+ "label": {
+ "type": "string",
+ "example": "example.com"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/schema/components/log-tail-object.json b/backend/schema/components/log-tail-object.json
new file mode 100644
index 0000000000..a897f08fcf
--- /dev/null
+++ b/backend/schema/components/log-tail-object.json
@@ -0,0 +1,30 @@
+{
+ "type": "object",
+ "description": "The last N lines of a log source",
+ "required": ["lines", "size", "truncated", "exists"],
+ "additionalProperties": false,
+ "properties": {
+ "lines": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."]
+ },
+ "size": {
+ "type": "integer",
+ "description": "Size in bytes of the underlying log file",
+ "example": 4096
+ },
+ "truncated": {
+ "type": "boolean",
+ "description": "True when the file is larger than the maximum amount of data scanned per request",
+ "example": false
+ },
+ "exists": {
+ "type": "boolean",
+ "description": "False when the underlying log file does not exist yet",
+ "example": true
+ }
+ }
+}
diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json
index 3ac6462136..90e6e23bb2 100644
--- a/backend/schema/components/proxy-host-object.json
+++ b/backend/schema/components/proxy-host-object.json
@@ -124,6 +124,9 @@
},
"advanced_config": {
"type": "string"
+ },
+ "access_list_id": {
+ "$ref": "../common.json#/properties/access_list_id"
}
}
},
@@ -132,7 +135,8 @@
"path": "/app",
"forward_scheme": "http",
"forward_host": "example.com",
- "forward_port": 80
+ "forward_port": 80,
+ "access_list_id": 0
}
]
},
diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json
index 602073ceca..3a1cd52101 100644
--- a/backend/schema/components/stream-object.json
+++ b/backend/schema/components/stream-object.json
@@ -44,7 +44,7 @@
},
{
"type": "string",
- "format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
+ "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
},
{
"type": "string",
diff --git a/backend/schema/paths/logs/sources/get.json b/backend/schema/paths/logs/sources/get.json
new file mode 100644
index 0000000000..2556986999
--- /dev/null
+++ b/backend/schema/paths/logs/sources/get.json
@@ -0,0 +1,36 @@
+{
+ "operationId": "getLogSources",
+ "summary": "Get available log sources",
+ "tags": ["logs"],
+ "security": [
+ {
+ "bearerAuth": ["admin"]
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "200 response",
+ "content": {
+ "application/json": {
+ "examples": {
+ "default": {
+ "value": {
+ "system": { "label": "System" },
+ "letsencrypt": { "label": "Let's Encrypt" },
+ "hosts": {
+ "proxy": [{ "id": 1, "label": "example.com" }],
+ "redirection": [],
+ "dead": [],
+ "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
+ }
+ }
+ }
+ },
+ "schema": {
+ "$ref": "../../../components/log-sources-object.json"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/schema/paths/logs/tail/get.json b/backend/schema/paths/logs/tail/get.json
new file mode 100644
index 0000000000..2e99ede574
--- /dev/null
+++ b/backend/schema/paths/logs/tail/get.json
@@ -0,0 +1,100 @@
+{
+ "operationId": "getLogTail",
+ "summary": "Get the last N lines of a log source",
+ "tags": ["logs"],
+ "security": [
+ {
+ "bearerAuth": ["admin"]
+ }
+ ],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "type",
+ "required": true,
+ "description": "Which log source to read",
+ "schema": {
+ "type": "string",
+ "enum": ["system", "letsencrypt", "host"]
+ }
+ },
+ {
+ "in": "query",
+ "name": "host_type",
+ "description": "Required when type=host",
+ "schema": {
+ "type": "string",
+ "enum": ["proxy", "redirection", "dead", "stream"]
+ }
+ },
+ {
+ "in": "query",
+ "name": "host_id",
+ "description": "Required when type=host",
+ "schema": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "in": "query",
+ "name": "channel",
+ "description": "Required when type=host - which log file to read (not to be confused with host_type=stream)",
+ "schema": {
+ "type": "string",
+ "enum": ["access", "error"]
+ }
+ },
+ {
+ "in": "query",
+ "name": "lines",
+ "description": "Number of lines to return from the end of the file",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 200
+ }
+ },
+ {
+ "in": "query",
+ "name": "level",
+ "description": "Only applicable to type=system",
+ "schema": {
+ "type": "string",
+ "enum": ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]
+ }
+ },
+ {
+ "in": "query",
+ "name": "search",
+ "description": "Case-insensitive plain-text search",
+ "schema": {
+ "type": "string",
+ "maxLength": 200
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "200 response",
+ "content": {
+ "application/json": {
+ "examples": {
+ "default": {
+ "value": {
+ "lines": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."],
+ "size": 4096,
+ "truncated": false,
+ "exists": true
+ }
+ }
+ },
+ "schema": {
+ "$ref": "../../../components/log-tail-object.json"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json
index 4222f19ddd..fd0dc33f51 100644
--- a/backend/schema/swagger.json
+++ b/backend/schema/swagger.json
@@ -24,6 +24,10 @@
"name": "audit-log",
"description": "Endpoints related to Audit Logs"
},
+ {
+ "name": "logs",
+ "description": "Endpoints for viewing system, Let's Encrypt and per-host logs"
+ },
{
"name": "access-lists",
"description": "Endpoints related to Access Lists"
@@ -81,6 +85,16 @@
"$ref": "./paths/audit-log/id/get.json"
}
},
+ "/logs/sources": {
+ "get": {
+ "$ref": "./paths/logs/sources/get.json"
+ }
+ },
+ "/logs/tail": {
+ "get": {
+ "$ref": "./paths/logs/tail/get.json"
+ }
+ },
"/nginx/access-lists": {
"get": {
"$ref": "./paths/nginx/access-lists/get.json"
diff --git a/backend/scripts/regenerate-config b/backend/scripts/regenerate-config
index 00f8411310..1965777220 100755
--- a/backend/scripts/regenerate-config
+++ b/backend/scripts/regenerate-config
@@ -46,7 +46,7 @@ const logIt = (msg, type = "info") => logger[type](
// Let's do it.
-const processItems = async (model, type) => {
+const processItems = async (model, type, hostType) => {
const rows = await model
.query()
.where("is_deleted", 0)
@@ -60,17 +60,17 @@ const processItems = async (model, type) => {
for (const row of rows) {
if (!DRY_RUN) {
logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
- await internalNginx.configure(proxyHostModel, "proxy_host", row);
+ await internalNginx.configure(model, hostType, row);
} else {
logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
}
}
};
-await processItems(proxyHostModel, "Proxy Host");
-await processItems(redirectionHostModel, "Redirection Host");
-await processItems(deadHostModel, "404 Host");
-await processItems(streamModel, "Stream");
+await processItems(proxyHostModel, "Proxy Host", "proxy_host");
+await processItems(redirectionHostModel, "Redirection Host", "redirection_host");
+await processItems(deadHostModel, "404 Host", "dead_host");
+await processItems(streamModel, "Stream", "stream");
logIt("Completed", "success");
process.exit(0);
diff --git a/backend/setup.js b/backend/setup.js
index 84f42793ea..9e8841e15b 100644
--- a/backend/setup.js
+++ b/backend/setup.js
@@ -6,11 +6,12 @@ import certificateModel from "./models/certificate.js";
import settingModel from "./models/setting.js";
import userModel from "./models/user.js";
import userPermissionModel from "./models/user_permission.js";
+import fs from "node:fs/promises";
export const isSetup = async () => {
const row = await userModel.query().select("id").where("is_deleted", 0).first();
return row?.id > 0;
-}
+};
/**
* Creates a default admin users if one doesn't already exist in the database
@@ -44,18 +45,14 @@ const setupDefaultUser = async () => {
roles: ["admin"],
};
- const user = await userModel
- .query()
- .insertAndFetch(data);
+ const user = await userModel.query().insertAndFetch(data);
- await authModel
- .query()
- .insert({
- user_id: user.id,
- type: "password",
- secret: initialAdminPassword,
- meta: {},
- });
+ await authModel.query().insert({
+ user_id: user.id,
+ type: "password",
+ secret: initialAdminPassword,
+ meta: {},
+ });
await userPermissionModel.query().insert({
user_id: user.id,
@@ -77,22 +74,16 @@ const setupDefaultUser = async () => {
* @returns {Promise}
*/
const setupDefaultSettings = async () => {
- const row = await settingModel
- .query()
- .select("id")
- .where({ id: "default-site" })
- .first();
+ const row = await settingModel.query().select("id").where({ id: "default-site" }).first();
if (!row?.id) {
- await settingModel
- .query()
- .insert({
- id: "default-site",
- name: "Default Site",
- description: "What to show when Nginx is hit with an unknown Host",
- value: "congratulations",
- meta: {},
- });
+ await settingModel.query().insert({
+ id: "default-site",
+ name: "Default Site",
+ description: "What to show when Nginx is hit with an unknown Host",
+ value: "congratulations",
+ meta: {},
+ });
logger.info("Default settings added");
}
};
@@ -103,14 +94,10 @@ const setupDefaultSettings = async () => {
* @returns {Promise}
*/
const setupCertbotPlugins = async () => {
- const certificates = await certificateModel
- .query()
- .where("is_deleted", 0)
- .andWhere("provider", "letsencrypt");
+ const certificates = await certificateModel.query().where("is_deleted", 0).andWhere("provider", "letsencrypt");
if (certificates?.length) {
const plugins = [];
- const promises = [];
certificates.map((certificate) => {
if (certificate.meta && certificate.meta.dns_challenge === true) {
@@ -118,24 +105,23 @@ const setupCertbotPlugins = async () => {
plugins.push(certificate.meta.dns_provider);
}
- // Make sure credentials file exists
- const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
- // Escape single quotes and backslashes
- if (typeof certificate.meta.dns_provider_credentials === "string") {
- const escapedCredentials = certificate.meta.dns_provider_credentials
- .replaceAll("'", "\\'")
- .replaceAll("\\", "\\\\");
- const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
- promises.push(utils.exec(credentials_cmd));
- }
+ // Deliberately does NOT write the DNS credentials file here any more.
+ //
+ // It used to, so that a later `certbot renew` would find the path recorded in its
+ // renewal config. The effect was that every backend restart rewrote a plaintext
+ // DNS provider API token for every DNS-01 certificate, and left it there.
+ //
+ // internalCertificate now writes that file immediately before it runs certbot and
+ // removes it again afterwards, so there is exactly one writer and the credential
+ // is on disk only for the length of a certbot run. Recreating the files at boot
+ // would put every one of them straight back.
}
return true;
});
await installPlugins(plugins);
- if (promises.length) {
- await Promise.all(promises);
+ if (plugins.length) {
logger.info(`Added Certbot plugins ${plugins.join(", ")}`);
}
}
diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf
index 3a10387b27..d365897ab7 100644
--- a/backend/templates/stream.conf
+++ b/backend/templates/stream.conf
@@ -5,8 +5,8 @@
{% if enabled %}
{% if tcp_forwarding == 1 or tcp_forwarding == true -%}
server {
- listen {{ incoming_port }} {%- if certificate %} ssl {%- endif %};
- {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %};
+ listen {{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
+ {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
{%- include "_certificates_stream.conf" %}
@@ -23,8 +23,8 @@ server {
{% if udp_forwarding == 1 or udp_forwarding == true -%}
server {
- listen {{ incoming_port }} udp;
- {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp;
+ listen {{ incoming_port }} udp reuseport;
+ {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp reuseport;
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
diff --git a/backend/yarn.lock b/backend/yarn.lock
index 427e834fa1..39efe544ad 100644
--- a/backend/yarn.lock
+++ b/backend/yarn.lock
@@ -2,20 +2,20 @@
# yarn lockfile v1
-"@apidevtools/json-schema-ref-parser@14.0.1":
- version "14.0.1"
- resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz#3bc445ed2eddf72bc2f9eb2e295c696bdc5be725"
- integrity sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==
+"@apidevtools/json-schema-ref-parser@15.3.6":
+ version "15.3.6"
+ resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.6.tgz#9e6360264d90b209eb09f9e4dc9a32316d86f4ea"
+ integrity sha512-oPFDSviRrreUmCZn09LCD4S9QQa6j7zfEwBM1pkGa/kXY0O4sXsEXn4emLP/Tt6tik2+BtgwKk2eldAeOWZ2Rw==
dependencies:
- "@types/json-schema" "^7.0.15"
- js-yaml "^4.1.0"
+ js-yaml "^4.2.0"
-"@apidevtools/json-schema-ref-parser@^15.3.5":
- version "15.3.5"
- resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.5.tgz#503726178d8d792eea7b195566272aaee6837e2f"
- integrity sha512-orNOYXw3hYXxxisXMldjzjBzqqTLBPbwOtHg7ovBPvfBHDue1qM9YJENZ3W2BQuS+7z4ThogMbEzEsov57Itkg==
+"@apidevtools/json-schema-ref-parser@^16.0.2":
+ version "16.0.2"
+ resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.2.tgz#21f55458a55d1b02bc62f19c731913d8e74976b3"
+ integrity sha512-oCwWUyRdMtt/hnp6ONPudFZzF7tSqiHy46lLx5VsnsLTacs8TSU558VPAiOueFJ8WddCH30PINWDA8ByxhGHmQ==
dependencies:
- js-yaml "^4.1.1"
+ js-yaml "^5.4.1"
+ undici "^8.10.2"
"@apidevtools/openapi-schemas@^2.1.0":
version "2.1.0"
@@ -27,71 +27,72 @@
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267"
integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==
-"@apidevtools/swagger-parser@^12.1.0":
- version "12.1.0"
- resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz#ef73e5f9e32c2becef6d95b90fb4481b0fec8fe4"
- integrity sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==
+"@apidevtools/swagger-parser@^13.0.0":
+ version "13.0.0"
+ resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.0.0.tgz#a8363c086cc2ae0005b270cfc241f353a741e49c"
+ integrity sha512-TcvnmBzJD7DBREkc/2qaxMqbKpttOaHhOFsYRYKdYAv6HGY+iNlWY2IUA209C2/NmvL9z8RBZhkrESqvGkjqmw==
dependencies:
- "@apidevtools/json-schema-ref-parser" "14.0.1"
+ "@apidevtools/json-schema-ref-parser" "15.3.6"
"@apidevtools/openapi-schemas" "^2.1.0"
"@apidevtools/swagger-methods" "^3.0.2"
+ "@types/json-schema" "^7.0.15"
ajv "^8.17.1"
ajv-draft-04 "^1.0.0"
call-me-maybe "^1.0.2"
-"@biomejs/biome@^2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.4.15.tgz#cb84ad6eb4235e7230b3c105a825e9bc03399944"
- integrity sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==
+"@biomejs/biome@^2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.5.10.tgz#c2a42fc6f9c7d9e12c1f77f63dfb6b5489c497b7"
+ integrity sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==
optionalDependencies:
- "@biomejs/cli-darwin-arm64" "2.4.15"
- "@biomejs/cli-darwin-x64" "2.4.15"
- "@biomejs/cli-linux-arm64" "2.4.15"
- "@biomejs/cli-linux-arm64-musl" "2.4.15"
- "@biomejs/cli-linux-x64" "2.4.15"
- "@biomejs/cli-linux-x64-musl" "2.4.15"
- "@biomejs/cli-win32-arm64" "2.4.15"
- "@biomejs/cli-win32-x64" "2.4.15"
-
-"@biomejs/cli-darwin-arm64@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz#3469daa56ac3ff4f16588a120df706381a96f65c"
- integrity sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==
-
-"@biomejs/cli-darwin-x64@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz#0697b81089409635da16682ac1e539165c262006"
- integrity sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==
-
-"@biomejs/cli-linux-arm64-musl@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz#c6af054e3732c361e9ad8c44070f909666b5616f"
- integrity sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==
-
-"@biomejs/cli-linux-arm64@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz#527cef60339649a442d51a9cd129ae9dfe9da926"
- integrity sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==
-
-"@biomejs/cli-linux-x64-musl@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz#b39292ad106c3d5a612bf3c61ba3119f66833013"
- integrity sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==
-
-"@biomejs/cli-linux-x64@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz#7360b7f81ff03ec6d9350bedc76b89f783b0945d"
- integrity sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==
-
-"@biomejs/cli-win32-arm64@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz#9542aac679174892a9379267e0c0048f8eee4d9f"
- integrity sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==
-
-"@biomejs/cli-win32-x64@2.4.15":
- version "2.4.15"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz#80288e4eea8f916fc5c876e9a486baadb8de537d"
- integrity sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==
+ "@biomejs/cli-darwin-arm64" "2.5.10"
+ "@biomejs/cli-darwin-x64" "2.5.10"
+ "@biomejs/cli-linux-arm64" "2.5.10"
+ "@biomejs/cli-linux-arm64-musl" "2.5.10"
+ "@biomejs/cli-linux-x64" "2.5.10"
+ "@biomejs/cli-linux-x64-musl" "2.5.10"
+ "@biomejs/cli-win32-arm64" "2.5.10"
+ "@biomejs/cli-win32-x64" "2.5.10"
+
+"@biomejs/cli-darwin-arm64@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz#f1453a7f2e63f228a34645df1c33962f22a6c2a8"
+ integrity sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==
+
+"@biomejs/cli-darwin-x64@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz#e21041e7bfab01cd453f609b940bf613713d0e24"
+ integrity sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==
+
+"@biomejs/cli-linux-arm64-musl@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz#57fc8aee25c08ca6c14526ccc44e282e7ba96eb6"
+ integrity sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==
+
+"@biomejs/cli-linux-arm64@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz#aef789a71e5aa86f5a0e1aff6cb4c1b757ec5be1"
+ integrity sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==
+
+"@biomejs/cli-linux-x64-musl@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz#1aebe9f928dc72dc3c112e60f1211a4d646990c4"
+ integrity sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==
+
+"@biomejs/cli-linux-x64@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz#a82b414abf5d9668fb035ee50a710bf78a7c5fbf"
+ integrity sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==
+
+"@biomejs/cli-win32-arm64@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz#db671cf67d7040a374e8cdda5aebe73c3f4ffba0"
+ integrity sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==
+
+"@biomejs/cli-win32-x64@2.5.10":
+ version "2.5.10"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz#3bdeaf5856f21aec6b6c50a7bca5464b9cda5623"
+ integrity sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==
"@isaacs/fs-minipass@^4.0.0":
version "4.0.1"
@@ -100,60 +101,60 @@
dependencies:
minipass "^7.0.4"
-"@noble/hashes@^2.0.1":
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e"
- integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==
+"@noble/hashes@^2.2.0":
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.3.0.tgz#505fd39c3134a37e67c8c4e6c6049a496154879c"
+ integrity sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==
-"@otplib/core@13.4.0":
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.4.0.tgz#14db803de5bd09f7c412eba86c7d193a09d57187"
- integrity sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==
+"@otplib/core@13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.5.0.tgz#629a7fef5b6394f34ff55213b936223b368796a4"
+ integrity sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==
-"@otplib/hotp@13.4.0":
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.4.0.tgz#e12a69cc54c000213e4bf2d92dc9741a6897d3a2"
- integrity sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==
+"@otplib/hotp@13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.5.0.tgz#2832cc6e52f3d037428f094177b4779babe19aa5"
+ integrity sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==
dependencies:
- "@otplib/core" "13.4.0"
- "@otplib/uri" "13.4.0"
+ "@otplib/core" "13.5.0"
+ "@otplib/uri" "13.5.0"
-"@otplib/plugin-base32-scure@13.4.0":
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz#04c3b31075bc733ed3fb54d3335a65813faed0fe"
- integrity sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==
+"@otplib/plugin-base32-scure@13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz#ba2f329aeea3ed4df3c4e21ce2e9c2e8c9aaed4f"
+ integrity sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==
dependencies:
- "@otplib/core" "13.4.0"
- "@scure/base" "^2.0.0"
+ "@otplib/core" "13.5.0"
+ "@scure/base" "^2.2.0"
-"@otplib/plugin-crypto-noble@13.4.0":
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz#048c6ad84fd718a2b66f108048aa2f7fd118e5d8"
- integrity sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==
+"@otplib/plugin-crypto-noble@13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz#820227beb159090a7448ce7245a4e615b5579f0e"
+ integrity sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==
dependencies:
- "@noble/hashes" "^2.0.1"
- "@otplib/core" "13.4.0"
+ "@noble/hashes" "^2.2.0"
+ "@otplib/core" "13.5.0"
-"@otplib/totp@13.4.0":
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.4.0.tgz#f43543435f9ea4c7798913ef8f975462f80fce63"
- integrity sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==
+"@otplib/totp@13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.5.0.tgz#48a60807457726ea34f1d1185f994120652e4468"
+ integrity sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==
dependencies:
- "@otplib/core" "13.4.0"
- "@otplib/hotp" "13.4.0"
- "@otplib/uri" "13.4.0"
+ "@otplib/core" "13.5.0"
+ "@otplib/hotp" "13.5.0"
+ "@otplib/uri" "13.5.0"
-"@otplib/uri@13.4.0":
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.4.0.tgz#23fa0b727bec026bb871afd42e1c3e5ff57bed1d"
- integrity sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==
+"@otplib/uri@13.5.0":
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.5.0.tgz#d2da470be24f872bb1c24717bcdd2e5c122e3ce3"
+ integrity sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==
dependencies:
- "@otplib/core" "13.4.0"
+ "@otplib/core" "13.5.0"
-"@scure/base@^2.0.0":
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98"
- integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==
+"@scure/base@^2.2.0":
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.3.0.tgz#fc7acb5b7084c53e4e2537d83fa72cddac7143fd"
+ integrity sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==
"@types/json-schema@^7.0.15":
version "7.0.15"
@@ -197,17 +198,7 @@ ajv-formats@^2.1.1:
dependencies:
ajv "^8.0.0"
-ajv@^8.0.0, ajv@^8.17.1:
- version "8.18.0"
- resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc"
- integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==
- dependencies:
- fast-deep-equal "^3.1.3"
- fast-uri "^3.0.1"
- json-schema-traverse "^1.0.0"
- require-from-string "^2.0.2"
-
-ajv@^8.20.0:
+ajv@^8.0.0, ajv@^8.17.1, ajv@^8.20.0:
version "8.20.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9"
integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==
@@ -264,13 +255,6 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
-asn1@^0.2.4:
- version "0.2.6"
- resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
- integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
- dependencies:
- safer-buffer "~2.1.0"
-
ast-types@^0.13.4:
version "0.13.4"
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782"
@@ -288,27 +272,59 @@ aws-ssl-profiles@^1.1.2:
resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641"
integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
-b4a@^1.6.4:
- version "1.8.0"
- resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.0.tgz#1ca3ba0edc9469aaabef5647e769a83d50180b1a"
- integrity sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==
+b4a@^1.6.4, b4a@^1.8.1:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4"
+ integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==
balanced-match@^4.0.2:
- version "4.0.3"
- resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.3.tgz#6337a2f23e0604a30481423432f99eac603599f9"
- integrity sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
+ integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
+
+bare-events@^2.5.4, bare-events@^2.7.0:
+ version "2.9.1"
+ resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.1.tgz#5c86616966343bcb03a1b3155feab253eadbf349"
+ integrity sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==
+
+bare-fs@^4.5.5:
+ version "4.8.0"
+ resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.0.tgz#8c0f916833ae11e6d9d347f9e0a7f46f7375bb8f"
+ integrity sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==
+ dependencies:
+ bare-events "^2.5.4"
+ bare-path "^3.0.0"
+ bare-stream "^2.6.4"
+ bare-url "^2.2.2"
+ fast-fifo "^1.3.2"
+
+bare-path@^3.0.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.1.tgz#d4a207c088609b4663a7556a46a97342398bf7e2"
+ integrity sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==
+
+bare-stream@^2.6.4:
+ version "2.13.3"
+ resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.3.tgz#f6186c7cbb4bbf53a4560f35e48b16373ba51ce6"
+ integrity sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==
+ dependencies:
+ b4a "^1.8.1"
+ streamx "^2.25.0"
+ teex "^1.0.1"
-bare-events@^2.7.0:
- version "2.8.2"
- resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f"
- integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==
+bare-url@^2.2.2:
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.2.tgz#e8dafaefbdb56891a0fbc2b0e1c051296d241cd6"
+ integrity sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==
+ dependencies:
+ bare-path "^3.0.0"
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
-basic-ftp@^5.2.0:
+basic-ftp@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.3.1.tgz#3148ee9af43c0522514a4f973fecb1d3cbb6d71e"
integrity sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==
@@ -326,13 +342,12 @@ bcrypt@^6.0.0:
node-addon-api "^8.3.0"
node-gyp-build "^4.8.4"
-better-sqlite3@^12.10.0:
- version "12.10.0"
- resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.0.tgz#bde622d14a18008583a53bc53501ae98f1a12221"
- integrity sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==
+better-sqlite3@^13.0.3:
+ version "13.0.3"
+ resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-13.0.3.tgz#b6ea0dc7fff7e28d04d9093e81051d38f7beabe2"
+ integrity sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==
dependencies:
- bindings "^1.5.0"
- prebuild-install "^7.1.1"
+ node-addon-api "^8.0.0"
binary-extensions@^2.0.0:
version "2.3.0"
@@ -360,25 +375,25 @@ blueimp-md5@^2.16.0:
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
-body-parser@^2.2.1, body-parser@^2.2.2:
- version "2.2.2"
- resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c"
- integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==
+body-parser@^2.2.1, body-parser@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437"
+ integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==
dependencies:
bytes "^3.1.2"
- content-type "^1.0.5"
+ content-type "^2.0.0"
debug "^4.4.3"
- http-errors "^2.0.0"
- iconv-lite "^0.7.0"
+ http-errors "^2.0.1"
+ iconv-lite "^0.7.2"
on-finished "^2.4.1"
- qs "^6.14.1"
- raw-body "^3.0.1"
- type-is "^2.0.1"
+ qs "^6.15.2"
+ raw-body "^3.0.2"
+ type-is "^2.1.0"
-brace-expansion@^5.0.5:
- version "5.0.6"
- resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285"
- integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==
+brace-expansion@^5.0.8:
+ version "5.0.9"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
+ integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
@@ -553,29 +568,35 @@ compressible@~2.0.18:
dependencies:
mime-db ">= 1.43.0 < 2"
-compression@^1.8.1:
- version "1.8.1"
- resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79"
- integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==
+compression@^1.8.2:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.2.tgz#781397127ae2da00c0c5f7c76bda5b2a9b32a49f"
+ integrity sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==
dependencies:
bytes "3.1.2"
compressible "~2.0.18"
debug "2.6.9"
+ destroy "1.2.0"
negotiator "~0.6.4"
on-headers "~1.1.0"
safe-buffer "5.2.1"
vary "~1.1.2"
content-disposition@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b"
- integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17"
+ integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==
content-type@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
+content-type@^2.0.0, content-type@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.1.0.tgz#d9389c43c0a8cf6a355db464d21e07092a40493a"
+ integrity sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==
+
cookie-signature@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793"
@@ -661,16 +682,16 @@ degenerator@7.0.1:
escodegen "^2.1.0"
esprima "^4.0.1"
-denque@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
- integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
-
depd@^2.0.0, depd@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+destroy@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+ integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
detect-libc@^2.0.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
@@ -742,9 +763,9 @@ es-errors@^1.3.0:
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
- integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
+ integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
dependencies:
es-errors "^1.3.0"
@@ -878,9 +899,9 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2:
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
fast-uri@^3.0.1:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
- integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
+ version "3.1.7"
+ resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a"
+ integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==
fdir@^6.5.0:
version "6.5.0"
@@ -999,12 +1020,12 @@ get-proto@^1.0.1:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
-get-uri@8.0.0:
- version "8.0.0"
- resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-8.0.0.tgz#3ac2e278c4bd8a717be70c8f8f8841e7d6aead93"
- integrity sha512-CqtZlMKvfJeY0Zxv8wazDwXmSKmnMnsmNy8j8+wudi8EyG/pMUB1NqHc+Tv1QaNtpYsK9nOYjb7r7Ufu32RPSw==
+get-uri@8.0.1:
+ version "8.0.1"
+ resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-8.0.1.tgz#772b1418bf034c2c43ddd6a41af24a4262e135ee"
+ integrity sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==
dependencies:
- basic-ftp "^5.2.0"
+ basic-ftp "^5.3.1"
data-uri-to-buffer "8.0.0"
debug "^4.3.4"
@@ -1055,10 +1076,10 @@ has-symbols@^1.1.0:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
-hasown@^2.0.2:
- version "2.0.2"
- resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
- integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
+hasown@^2.0.2, hasown@^2.0.3:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
+ integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
dependencies:
function-bind "^1.1.2"
@@ -1073,26 +1094,28 @@ http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1:
statuses "~2.0.2"
toidentifier "~1.0.1"
-http-proxy-agent@9.0.0:
- version "9.0.0"
- resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz#473fe9c2b8ffea87611f8c68dfb9872b835c91d1"
- integrity sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==
+http-proxy-agent@9.1.0:
+ version "9.1.0"
+ resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz#fd8b39dcb58ac8046139e5f4ea80de78e8beaf7b"
+ integrity sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==
dependencies:
agent-base "9.0.0"
debug "^4.3.4"
+ proxy-agent-negotiate "1.1.0"
-https-proxy-agent@9.0.0:
- version "9.0.0"
- resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz#89256adf9dc20926fe43ea1d3ca0feb9e23cc4bd"
- integrity sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==
+https-proxy-agent@9.1.0:
+ version "9.1.0"
+ resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz#a9f60f79bc1578c37b166dd7f58b6bcb5c57e44b"
+ integrity sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==
dependencies:
agent-base "9.0.0"
debug "^4.3.4"
+ proxy-agent-negotiate "1.1.0"
-iconv-lite@^0.7.0, iconv-lite@^0.7.2, iconv-lite@~0.7.0:
- version "0.7.2"
- resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e"
- integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==
+iconv-lite@^0.7.2, iconv-lite@^0.7.3, iconv-lite@~0.7.0:
+ version "0.7.3"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f"
+ integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
@@ -1126,10 +1149,10 @@ interpret@^2.2.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
-ip-address@^10.0.1:
- version "10.2.0"
- resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206"
- integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==
+ip-address@^10.1.1:
+ version "10.5.0"
+ resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.5.0.tgz#fcd6d7dfe9e68416b7cb71afe9bc960b3e38c13b"
+ integrity sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==
ipaddr.js@1.9.1:
version "1.9.1"
@@ -1149,11 +1172,11 @@ is-binary-path@~2.1.0:
binary-extensions "^2.0.0"
is-core-module@^2.16.1:
- version "2.16.1"
- resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
- integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
+ version "2.16.2"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082"
+ integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==
dependencies:
- hasown "^2.0.2"
+ hasown "^2.0.3"
is-extglob@^2.1.1:
version "2.1.1"
@@ -1202,10 +1225,17 @@ isexe@^4.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca"
integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==
-js-yaml@^4.1.0, js-yaml@^4.1.1:
- version "4.1.1"
- resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
- integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
+js-yaml@^4.2.0:
+ version "4.3.2"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
+ integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
+ dependencies:
+ argparse "^2.0.1"
+
+js-yaml@^5.4.1:
+ version "5.4.2"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.2.tgz#eebc7a57e0bbe1d6b6d9416a72e7463082c7da85"
+ integrity sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==
dependencies:
argparse "^2.0.1"
@@ -1252,10 +1282,10 @@ jws@^4.0.1:
jwa "^2.0.1"
safe-buffer "^5.0.1"
-knex@3.2.10:
- version "3.2.10"
- resolved "https://registry.yarnpkg.com/knex/-/knex-3.2.10.tgz#ca8f77a10851b1e18b26463a6dc995e563c3b165"
- integrity sha512-oypTHfrc9i72iyxaUQBKHOxhcr0xM65MPf6FpN02nimsftXwzXprIkLjfXdubvhbu4PMWLp023q8o8CYvHSuZw==
+knex@3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/knex/-/knex-3.3.0.tgz#451d24d5670939fc4ce54f2269163dbf2627d682"
+ integrity sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==
dependencies:
colorette "2.0.19"
commander "^10.0.0"
@@ -1269,7 +1299,7 @@ knex@3.2.10:
pg-connection-string "2.6.2"
rechoir "^0.8.0"
resolve-from "^5.0.0"
- tarn "^3.0.2"
+ tarn "^3.1.0"
tildify "2.0.0"
lazystream@^1.0.0:
@@ -1279,10 +1309,10 @@ lazystream@^1.0.0:
dependencies:
readable-stream "^2.0.5"
-liquidjs@10.27.0:
- version "10.27.0"
- resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.27.0.tgz#e31dc4c539e1a26aee46c847b4e60a6ede32564a"
- integrity sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==
+liquidjs@10.29.0:
+ version "10.29.0"
+ resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.29.0.tgz#ee061890ccf0da26be377aad4f02bd32da0140d8"
+ integrity sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==
dependencies:
commander "^10.0.0"
@@ -1372,9 +1402,9 @@ math-intrinsics@^1.1.0:
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
media-typer@^1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561"
- integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8"
+ integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==
merge-descriptors@^2.0.0:
version "2.0.0"
@@ -1399,11 +1429,11 @@ mimic-response@^3.1.0:
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
minimatch@^10.2.1, minimatch@^10.2.2:
- version "10.2.5"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1"
- integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
+ version "10.2.6"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
+ integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
- brace-expansion "^5.0.5"
+ brace-expansion "^5.0.8"
minimist@^1.2.0, minimist@^1.2.3:
version "1.2.8"
@@ -1427,10 +1457,10 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
-moment@^2.30.1:
- version "2.30.1"
- resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
- integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
+moment@^2.31.0:
+ version "2.31.0"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.31.0.tgz#6e19184e8005a9dfc61c9351263d4d6ea2ace7aa"
+ integrity sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
ms@2.0.0:
version "2.0.0"
@@ -1447,19 +1477,18 @@ ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
-mysql2@^3.22.3:
- version "3.22.3"
- resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.22.3.tgz#884a36a1171501ef752ba90057dbcde528f322e3"
- integrity sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==
+mysql2@^3.24.4:
+ version "3.24.4"
+ resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.4.tgz#7752de0cf10b1d2984103bdbc60f696c60756f31"
+ integrity sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==
dependencies:
aws-ssl-profiles "^1.1.2"
- denque "^2.1.0"
generate-function "^2.3.1"
- iconv-lite "^0.7.2"
+ iconv-lite "^0.7.3"
long "^5.3.2"
lru.min "^1.1.4"
named-placeholders "^1.1.6"
- sql-escaper "^1.3.3"
+ sql-escaper "^1.5.1"
named-placeholders@^1.1.6:
version "1.1.6"
@@ -1474,9 +1503,11 @@ napi-build-utils@^2.0.0:
integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==
negotiator@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a"
- integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.1.0.tgz#16e003d0db4ac24fd9df168edf871625deeae3df"
+ integrity sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==
+ dependencies:
+ content-type "^2.1.0"
negotiator@~0.6.4:
version "0.6.4"
@@ -1484,26 +1515,21 @@ negotiator@~0.6.4:
integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
netmask@^2.0.2:
- version "2.0.2"
- resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7"
- integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.1.1.tgz#80043d265b53aa521b3bd01e8fcdf353f9e1e81e"
+ integrity sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==
node-abi@^3.3.0:
- version "3.87.0"
- resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.87.0.tgz#423e28fea5c2f195fddd98acded9938c001ae6dd"
- integrity sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==
+ version "3.94.0"
+ resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.94.0.tgz#007181ed0d1b56ae9670ea6c084d2bf83538405f"
+ integrity sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==
dependencies:
semver "^7.3.5"
-node-addon-api@^8.0.0:
- version "8.7.0"
- resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.7.0.tgz#f64f8413456ecbe900221305a3f883c37666473f"
- integrity sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==
-
-node-addon-api@^8.3.0:
- version "8.5.0"
- resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.5.0.tgz#c91b2d7682fa457d2e1c388150f0dff9aafb8f3f"
- integrity sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==
+node-addon-api@^8.0.0, node-addon-api@^8.3.0:
+ version "8.9.2"
+ resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.9.2.tgz#db7ac94a13ffd9b55e6cb04584bd5ef8e1d74b18"
+ integrity sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==
node-gyp-build@^4.8.4:
version "4.8.4"
@@ -1511,9 +1537,9 @@ node-gyp-build@^4.8.4:
integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==
node-gyp@12.x:
- version "12.3.0"
- resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.3.0.tgz#a0e0d9364779451eaf4148b6f9a7366f98000b3f"
- integrity sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==
+ version "12.4.0"
+ resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.4.0.tgz#2d017b6ea1ca9294dbbee75be533728f49257024"
+ integrity sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==
dependencies:
env-paths "^2.2.0"
exponential-backoff "^3.1.1"
@@ -1526,12 +1552,12 @@ node-gyp@12.x:
undici "^6.25.0"
which "^6.0.0"
-node-rsa@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.1.1.tgz#efd9ad382097782f506153398496f79e4464434d"
- integrity sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==
+node-rsa@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-2.0.0.tgz#f55c99fb3ffef08b24bab414b7d2f24b30d12081"
+ integrity sha512-7yfzccmBd5rm7pALJf709tPwLL987WvdT7LC/QK5Oxa5S2aabdBPjxRDxhWKkbfCc9/FiC82lwSc+ulGZHH44g==
dependencies:
- asn1 "^0.2.4"
+ "@noble/hashes" "^2.2.0"
nodemon@^3.1.14:
version "3.1.14"
@@ -1561,7 +1587,7 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
-object-inspect@^1.13.3:
+object-inspect@^1.13.3, object-inspect@^1.13.4:
version "1.13.4"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
@@ -1594,17 +1620,17 @@ once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"
-otplib@^13.4.0:
- version "13.4.0"
- resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.4.0.tgz#5743391a7fe1900cb1ac364af0bbb407a72af76b"
- integrity sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==
+otplib@^13.5.0:
+ version "13.5.0"
+ resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.5.0.tgz#78b5d0d9edf83078d7fe37243bbcdd96d7a65d37"
+ integrity sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==
dependencies:
- "@otplib/core" "13.4.0"
- "@otplib/hotp" "13.4.0"
- "@otplib/plugin-base32-scure" "13.4.0"
- "@otplib/plugin-crypto-noble" "13.4.0"
- "@otplib/totp" "13.4.0"
- "@otplib/uri" "13.4.0"
+ "@otplib/core" "13.5.0"
+ "@otplib/hotp" "13.5.0"
+ "@otplib/plugin-base32-scure" "13.5.0"
+ "@otplib/plugin-crypto-noble" "13.5.0"
+ "@otplib/totp" "13.5.0"
+ "@otplib/uri" "13.5.0"
p-limit@^1.1.0:
version "1.3.0"
@@ -1644,19 +1670,19 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
-pac-proxy-agent@9.0.1:
- version "9.0.1"
- resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-9.0.1.tgz#9cee1f8b41b00c53418f10824a8a41b3e07587dd"
- integrity sha512-3ZOSpLboOlpW4yp8Cuv21KlTULRqyJ5Uuad3wXpSKFrxdNgcHEyoa22GRaZ2UlgCVuR6z+5BiavtYVvbajL/Yw==
+pac-proxy-agent@9.1.0:
+ version "9.1.0"
+ resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz#cafd7bb54905de0b6804283cd273eb22028c6abd"
+ integrity sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==
dependencies:
agent-base "9.0.0"
debug "^4.3.4"
- get-uri "8.0.0"
- http-proxy-agent "9.0.0"
- https-proxy-agent "9.0.0"
+ get-uri "8.0.1"
+ http-proxy-agent "9.1.0"
+ https-proxy-agent "9.1.0"
pac-resolver "9.0.1"
quickjs-wasi "^2.2.0"
- socks-proxy-agent "10.0.0"
+ socks-proxy-agent "10.1.0"
pac-resolver@9.0.1:
version "9.0.1"
@@ -1717,10 +1743,10 @@ pg-connection-string@2.6.2:
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475"
integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==
-pg-connection-string@^2.13.0:
- version "2.13.0"
- resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.13.0.tgz#8678113465a5af3cc977dcb51eadc847b27aa2de"
- integrity sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==
+pg-connection-string@^2.14.0:
+ version "2.14.0"
+ resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9"
+ integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==
pg-int8@1.0.1:
version "1.0.1"
@@ -1732,10 +1758,10 @@ pg-pool@^3.14.0:
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90"
integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
-pg-protocol@^1.14.0:
- version "1.14.0"
- resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.14.0.tgz#c1f045b74274b007078c687147141f785f59b8de"
- integrity sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==
+pg-protocol@^1.16.0:
+ version "1.16.0"
+ resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305"
+ integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==
pg-types@2.2.0:
version "2.2.0"
@@ -1748,14 +1774,14 @@ pg-types@2.2.0:
postgres-date "~1.0.4"
postgres-interval "^1.1.0"
-pg@^8.21.0:
- version "8.21.0"
- resolved "https://registry.yarnpkg.com/pg/-/pg-8.21.0.tgz#d7fa2118d960cec5cc7d2b24525f9850dd5932b0"
- integrity sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==
+pg@^8.23.0:
+ version "8.23.0"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.0.tgz#5c2026d32bd0cb4fbd9196bac1ecf5ae66607180"
+ integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==
dependencies:
- pg-connection-string "^2.13.0"
+ pg-connection-string "^2.14.0"
pg-pool "^3.14.0"
- pg-protocol "^1.14.0"
+ pg-protocol "^1.16.0"
pg-types "2.2.0"
pgpass "1.0.5"
optionalDependencies:
@@ -1774,9 +1800,9 @@ picomatch@^2.0.4, picomatch@^2.2.1:
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.4:
- version "4.0.4"
- resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
- integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
+ version "4.0.5"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
+ integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
pify@^3.0.0:
version "3.0.0"
@@ -1813,7 +1839,7 @@ postgres-interval@^1.1.0:
dependencies:
xtend "^4.0.0"
-prebuild-install@^7.1.1, prebuild-install@^7.1.3:
+prebuild-install@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec"
integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==
@@ -1854,19 +1880,24 @@ proxy-addr@^2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
-proxy-agent@^8.0.1:
- version "8.0.1"
- resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-8.0.1.tgz#4819c7b14637c6940424aeed840e9883a378a15d"
- integrity sha512-kccqGBqHZXR8onQhY/ganJjoO8QIKKRiFBhPOzbTZK16attzSZ/0XSmp9H7jrRxPKHjhGyx1q32lMPrJ3uLFgA==
+proxy-agent-negotiate@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz#de7d37aede9d71e74346125d6f484fac8092b615"
+ integrity sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==
+
+proxy-agent@^8.0.2:
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-8.0.2.tgz#70737a3b9cdc8bd7a282c9e6ff54a64f5252413c"
+ integrity sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==
dependencies:
agent-base "9.0.0"
debug "^4.3.4"
- http-proxy-agent "9.0.0"
- https-proxy-agent "9.0.0"
+ http-proxy-agent "9.1.0"
+ https-proxy-agent "9.1.0"
lru-cache "^7.14.1"
- pac-proxy-agent "9.0.1"
+ pac-proxy-agent "9.1.0"
proxy-from-env "^2.0.0"
- socks-proxy-agent "10.0.0"
+ socks-proxy-agent "10.1.0"
proxy-from-env@^2.0.0:
version "2.1.0"
@@ -1879,19 +1910,20 @@ pstree.remy@^1.1.8:
integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==
pump@^3.0.0:
- version "3.0.3"
- resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d"
- integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.4.tgz#1f313430527fa8b905622ebd22fe1444e757ab3c"
+ integrity sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
-qs@^6.14.0, qs@^6.14.1:
- version "6.15.2"
- resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
- integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
+qs@^6.14.0, qs@^6.15.2:
+ version "6.16.0"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd"
+ integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==
dependencies:
- side-channel "^1.1.0"
+ es-define-property "^1.0.1"
+ side-channel "^1.1.1"
querystring@0.2.0:
version "0.2.0"
@@ -1904,11 +1936,11 @@ quickjs-wasi@^2.2.0:
integrity sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==
range-parser@^1.2.1:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
- integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47"
+ integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==
-raw-body@^3.0.1:
+raw-body@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51"
integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==
@@ -2003,10 +2035,11 @@ resolve-from@^5.0.0:
integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
resolve@^1.20.0:
- version "1.22.11"
- resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
- integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
+ version "1.22.12"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f"
+ integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==
dependencies:
+ es-errors "^1.3.0"
is-core-module "^2.16.1"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
@@ -2032,15 +2065,15 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@~2.1.0:
+"safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
semver@^7.3.5, semver@^7.5.3, semver@^7.5.4:
- version "7.7.4"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
- integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
+ version "7.8.5"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
+ integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
send@^1.1.0, send@^1.2.0:
version "1.2.1"
@@ -2079,13 +2112,13 @@ setprototypeof@~1.2.0:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
-side-channel-list@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
- integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
+side-channel-list@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127"
+ integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==
dependencies:
es-errors "^1.3.0"
- object-inspect "^1.13.3"
+ object-inspect "^1.13.4"
side-channel-map@^1.0.1:
version "1.0.1"
@@ -2108,14 +2141,14 @@ side-channel-weakmap@^1.0.2:
object-inspect "^1.13.3"
side-channel-map "^1.0.1"
-side-channel@^1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
- integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
+side-channel@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab"
+ integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==
dependencies:
es-errors "^1.3.0"
- object-inspect "^1.13.3"
- side-channel-list "^1.0.0"
+ object-inspect "^1.13.4"
+ side-channel-list "^1.0.1"
side-channel-map "^1.0.1"
side-channel-weakmap "^1.0.2"
@@ -2154,21 +2187,21 @@ smart-buffer@^4.2.0:
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
-socks-proxy-agent@10.0.0:
- version "10.0.0"
- resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz#e94a497f890d790a67274c19837c5bbe3f182220"
- integrity sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==
+socks-proxy-agent@10.1.0:
+ version "10.1.0"
+ resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz#8eaf7d64ad8752ca43ab04a1a9567be9b7fc40b1"
+ integrity sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==
dependencies:
agent-base "9.0.0"
debug "^4.3.4"
socks "^2.8.3"
socks@^2.8.3:
- version "2.8.7"
- resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea"
- integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==
+ version "2.8.9"
+ resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752"
+ integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==
dependencies:
- ip-address "^10.0.1"
+ ip-address "^10.1.1"
smart-buffer "^4.2.0"
source-map@~0.6.1:
@@ -2181,10 +2214,10 @@ split2@^4.1.0:
resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
-sql-escaper@^1.3.3:
- version "1.3.3"
- resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.3.3.tgz#65faf89f048d26bb9a75566b82b5990ddf8a5b7f"
- integrity sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==
+sql-escaper@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.1.tgz#fbaee511294d1f7da92fb68ca1faee2255839c7b"
+ integrity sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==
sqlite3@^6.0.1:
version "6.0.1"
@@ -2208,10 +2241,10 @@ streamsearch@^1.1.0:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
-streamx@^2.15.0:
- version "2.23.0"
- resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.23.0.tgz#7d0f3d00d4a6c5de5728aecd6422b4008d66fd0b"
- integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==
+streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0:
+ version "2.28.0"
+ resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.0.tgz#035ab56057b7ed2211b51d532e6973f0f99fbf11"
+ integrity sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==
dependencies:
events-universal "^1.0.0"
fast-fifo "^1.3.2"
@@ -2270,9 +2303,9 @@ supports-preserve-symlinks-flag@^1.0.0:
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tar-fs@^2.0.0:
- version "2.1.4"
- resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
- integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.5.tgz#33e9c29413dce0c58ada7ff77db4e5a30afffe70"
+ integrity sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==
dependencies:
chownr "^1.1.1"
mkdirp-classic "^0.5.2"
@@ -2291,18 +2324,19 @@ tar-stream@^2.1.4:
readable-stream "^3.1.1"
tar-stream@^3.0.0:
- version "3.1.7"
- resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b"
- integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.0.tgz#0d0064d9b67ea3c9f5abde155e35faab0df37591"
+ integrity sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==
dependencies:
b4a "^1.6.4"
+ bare-fs "^4.5.5"
fast-fifo "^1.2.0"
streamx "^2.15.0"
tar@^7.5.10, tar@^7.5.4:
- version "7.5.15"
- resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.15.tgz#afe6d1316cddf614a566e3813e42fe01aed46fee"
- integrity sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==
+ version "7.5.22"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.22.tgz#a696f998136e71487dc3f869a85bba2c67971ba9"
+ integrity sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==
dependencies:
"@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0"
@@ -2310,10 +2344,17 @@ tar@^7.5.10, tar@^7.5.4:
minizlib "^3.1.0"
yallist "^5.0.0"
-tarn@^3.0.2:
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693"
- integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==
+tarn@^3.1.0:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.1.2.tgz#d2922d34a232c8b027605f3873a9c55318ce4f34"
+ integrity sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==
+
+teex@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/teex/-/teex-1.0.1.tgz#b8fa7245ef8e8effa8078281946c85ab780a0b12"
+ integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==
+ dependencies:
+ streamx "^2.12.5"
temp-dir@^3.0.0:
version "3.0.0"
@@ -2342,9 +2383,9 @@ tildify@2.0.0:
integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==
tinyglobby@^0.2.12:
- version "0.2.16"
- resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
- integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
+ version "0.2.17"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
+ integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.4"
@@ -2378,12 +2419,12 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"
-type-is@^2.0.1:
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97"
- integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==
+type-is@^2.0.1, type-is@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570"
+ integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==
dependencies:
- content-type "^1.0.5"
+ content-type "^2.0.0"
media-typer "^1.1.0"
mime-types "^3.0.0"
@@ -2393,9 +2434,14 @@ undefsafe@^2.0.5:
integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==
undici@^6.25.0:
- version "6.25.0"
- resolved "https://registry.yarnpkg.com/undici/-/undici-6.25.0.tgz#8c4efb8c998dc187fc1cfb5dde1ef19a211849fb"
- integrity sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==
+ version "6.28.0"
+ resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c"
+ integrity sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==
+
+undici@^8.10.2:
+ version "8.10.2"
+ resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7"
+ integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==
unpipe@~1.0.0:
version "1.0.0"
diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile
index 8593fd5ef6..56875fd849 100644
--- a/docker/dev/Dockerfile
+++ b/docker/dev/Dockerfile
@@ -25,7 +25,7 @@ RUN curl -sL 'https://taskfile.dev/install.sh' | sh
COPY rootfs /
COPY scripts/install-s6 /tmp/install-s6
-RUN rm -f /etc/nginx/conf.d/production.conf \
+RUN rm -f /etc/nginx/conf.d/production.conf.template \
&& chmod 644 /etc/logrotate.d/nginx-proxy-manager \
&& /tmp/install-s6 "${TARGETPLATFORM}" \
&& rm -f /tmp/install-s6 \
diff --git a/docker/dev/squid.conf b/docker/dev/squid.conf
index cdd749f582..b335f01af1 100644
--- a/docker/dev/squid.conf
+++ b/docker/dev/squid.conf
@@ -28,6 +28,7 @@ acl SSL_ports port 443
acl Safe_ports port 80 # http
acl Safe_ports port 81
acl Safe_ports port 443 # https
+acl Safe_ports port 8000 # for CI testing with custom admin port
#
# Recommended minimum Access Permission configuration:
diff --git a/docker/docker-compose.ci.postgres.yml b/docker/docker-compose.ci.postgres.yml
index b8c4244668..90245ed399 100644
--- a/docker/docker-compose.ci.postgres.yml
+++ b/docker/docker-compose.ci.postgres.yml
@@ -3,6 +3,7 @@ services:
cypress:
environment:
CYPRESS_stack: "postgres"
+ NPM_ADMIN_PORT: 8000
fullstack:
environment:
@@ -11,11 +12,14 @@ services:
DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass"
DB_POSTGRES_NAME: "npm"
+ NPM_ADMIN_PORT: 8000
depends_on:
- db-postgres
- authentik
- authentik-worker
- authentik-ldap
+ expose:
+ - "8000/tcp"
db-postgres:
image: postgres:17
diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml
index 6130f82e3c..be6f517d3d 100644
--- a/docker/docker-compose.ci.yml
+++ b/docker/docker-compose.ci.yml
@@ -38,6 +38,16 @@ services:
- website2.example.com
- website3.example.com
+ examplesite:
+ image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
+ build:
+ context: ../test/docker
+ dockerfile: Dockerfile.website
+ expose:
+ - "80/tcp"
+ networks:
+ - fulltest
+
stepca:
image: nginxproxymanager/testca
volumes:
@@ -105,6 +115,7 @@ services:
environment:
HTTP_PROXY: "squid:3128"
HTTPS_PROXY: "squid:3128"
+ NPM_ADMIN_PORT: 81
volumes:
- "cypress_logs:/test/results"
- "./dev/resolv.conf:/etc/resolv.conf:ro"
diff --git a/docker/rootfs/etc/logrotate.d/nginx-proxy-manager b/docker/rootfs/etc/logrotate.d/nginx-proxy-manager
index de9772971e..3e42324c77 100644
--- a/docker/rootfs/etc/logrotate.d/nginx-proxy-manager
+++ b/docker/rootfs/etc/logrotate.d/nginx-proxy-manager
@@ -25,3 +25,13 @@
kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true
endscript
}
+
+/data/logs/backend.log {
+ su npm npm
+ size 10M
+ rotate 5
+ missingok
+ notifempty
+ compress
+ copytruncate
+}
diff --git a/docker/rootfs/etc/nginx/conf.d/production.conf b/docker/rootfs/etc/nginx/conf.d/production.conf.template
similarity index 89%
rename from docker/rootfs/etc/nginx/conf.d/production.conf
rename to docker/rootfs/etc/nginx/conf.d/production.conf.template
index 877e51dda2..452609bcbc 100644
--- a/docker/rootfs/etc/nginx/conf.d/production.conf
+++ b/docker/rootfs/etc/nginx/conf.d/production.conf.template
@@ -1,7 +1,7 @@
# Admin Interface
server {
- listen 81 default;
- listen [::]:81 default;
+ listen {{NPM_ADMIN_PORT}} default;
+ listen [::]:{{NPM_ADMIN_PORT}} default;
server_name nginxproxymanager;
root /app/frontend;
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
index d2e62f3bbc..172290eae6 100755
--- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
@@ -17,6 +17,7 @@ fi
. /etc/s6-overlay/s6-rc.d/prepare/20-paths.sh
. /etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
. /etc/s6-overlay/s6-rc.d/prepare/40-dynamic.sh
+. /etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
. /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh
. /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
index fa94651897..4236c90a2e 100755
--- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
@@ -46,6 +46,12 @@ for loc in "${locations[@]}"; do
chownit "$loc"
done
+# Ensure the JWT key file is owned by the runtime user, even when the /data
+# directory ownership already matches PUID:PGID (chownit skips recursion then)
+if [ -f /data/keys.json ]; then
+ chown "$PUID:$PGID" /data/keys.json
+fi
+
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
log_info 'Skipping ownership change of certbot directories'
else
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
new file mode 100644
index 0000000000..3807c7d76c
--- /dev/null
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
@@ -0,0 +1,30 @@
+#!/command/with-contenv bash
+# shellcheck shell=bash
+
+# This command reads the `NPM_ADMIN_PORT` env var and will fall
+# back to 81 if this is not set or is not a number.
+
+set -e
+
+log_info 'Admin Port ...'
+
+NPM_ADMIN_PORT="${NPM_ADMIN_PORT:-81}"
+# ensure admin port is a number
+if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
+ echo "WARNING: NPM_ADMIN_PORT must be a number. Defaulting to 81" >&2
+ NPM_ADMIN_PORT=81
+fi
+
+PRODFILE="/etc/nginx/conf.d/production.conf"
+SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g"
+
+if is_mounted "$PRODFILE"; then
+ echo "WARNING: skipping ${PRODFILE} — mounted file" >&2
+elif [ -f "$PRODFILE.template" ]; then
+ if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then
+ # success
+ log_info "Generated ${PRODFILE} from template"
+ else
+ log_fatal "Failed to generate ${PRODFILE} from template"
+ fi
+fi
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
index 36387a98dc..e37d11cfa0 100755
--- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
@@ -8,10 +8,6 @@ set -e
log_info 'IPv6 ...'
-is_mounted() {
- awk -v p="$1" '$5 == p { found=1 } END { exit !found }' /proc/self/mountinfo
-}
-
process_folder () {
FILES=$(find "$1" -type f -name "*.conf")
SED_REGEX=
diff --git a/docker/rootfs/usr/bin/check-health b/docker/rootfs/usr/bin/check-health
index bcf5552b39..de23c903b6 100755
--- a/docker/rootfs/usr/bin/check-health
+++ b/docker/rootfs/usr/bin/check-health
@@ -1,6 +1,6 @@
#!/bin/bash
-OK=$(curl --silent http://127.0.0.1:81/api/ | jq --raw-output '.status')
+OK=$(curl --silent "http://127.0.0.1:${NPM_ADMIN_PORT:-81}/api/" | jq --raw-output '.status')
if [ "$OK" == "OK" ]; then
echo "OK"
diff --git a/docker/rootfs/usr/bin/common.sh b/docker/rootfs/usr/bin/common.sh
index 92bf19d9c6..32d4570444 100644
--- a/docker/rootfs/usr/bin/common.sh
+++ b/docker/rootfs/usr/bin/common.sh
@@ -70,3 +70,7 @@ is_true () {
echo '0'
fi
}
+
+is_mounted() {
+ awk -v p="$1" '$5 == p { found=1 } END { exit !found }' /proc/self/mountinfo
+}
diff --git a/docker/scripts/install-s6 b/docker/scripts/install-s6
index 5fcb4e09bd..55513fe1e7 100755
--- a/docker/scripts/install-s6
+++ b/docker/scripts/install-s6
@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
GREEN='\E[1;32m'
RESET='\E[0m'
-S6_OVERLAY_VERSION=3.2.1.0
+S6_OVERLAY_VERSION=3.2.3.0
TARGETPLATFORM=${1:-linux/amd64}
# Determine the correct binary file for the architecture given
diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md
index 3ab04ce25b..a93a1a4d09 100644
--- a/docs/src/advanced-config/index.md
+++ b/docs/src/advanced-config/index.md
@@ -248,3 +248,21 @@ 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`.
+
+
+## Changing the Admin UI port from 81 to something else
+
+First, add an env var to your docker compose file:
+```yml
+ environment:
+ NPM_ADMIN_PORT: '8000'
+```
+
+And you'll probably want to expose that port as well
+
+```yml
+ ports:
+ - '8000:8000'
+```
+
+Then you'll be able to access admin UI at `http://localhost:8000`
diff --git a/docs/yarn.lock b/docs/yarn.lock
index c756948271..f8c7568b23 100644
--- a/docs/yarn.lock
+++ b/docs/yarn.lock
@@ -952,10 +952,10 @@ mitt@^3.0.1:
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
-nanoid@^3.3.11:
- version "3.3.11"
- resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
- integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+nanoid@^3.3.16:
+ version "3.3.16"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
+ integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
oniguruma-to-es@^3.1.0:
version "3.1.1"
@@ -977,11 +977,11 @@ picocolors@^1.1.1:
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
postcss@^8.4.43, postcss@^8.5.6:
- version "8.5.14"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
- integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
+ version "8.5.25"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb"
+ integrity sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==
dependencies:
- nanoid "^3.3.11"
+ nanoid "^3.3.16"
picocolors "^1.1.1"
source-map-js "^1.2.1"
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a9b91bc1a0..50c1dc047e 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -1,7 +1,7 @@
src/locale/lang
# Logs
-logs
+/logs/
*.log
npm-debug.log*
yarn-debug.log*
diff --git a/frontend/biome.json b/frontend/biome.json
index 13f21e36ba..e84969d2b0 100644
--- a/frontend/biome.json
+++ b/frontend/biome.json
@@ -1,92 +1,75 @@
{
- "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
- "vcs": {
- "enabled": true,
- "clientKind": "git",
- "useIgnoreFile": true
- },
- "files": {
- "ignoreUnknown": false,
- "includes": [
- "**/*.ts",
- "**/*.tsx",
- "**/*.js",
- "**/*.jsx",
- "!**/dist/**/*"
- ]
- },
- "formatter": {
- "enabled": true,
- "indentStyle": "tab",
- "indentWidth": 4,
- "lineWidth": 120,
- "formatWithErrors": true
- },
- "assist": {
- "actions": {
- "source": {
- "organizeImports": {
- "level": "on",
- "options": {
- "groups": [
- ":BUN:",
- ":NODE:",
- [
- "npm:*",
- "npm:*/**"
- ],
- ":PACKAGE_WITH_PROTOCOL:",
- ":URL:",
- ":PACKAGE:",
- [
- "/src/*",
- "/src/**"
- ],
- [
- "/**"
- ],
- [
- "#*",
- "#*/**"
- ],
- ":PATH:"
- ]
- }
- }
- }
- }
- },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": true,
- "correctness": {
- "useUniqueElementIds": "off"
- },
- "suspicious": {
- "noExplicitAny": "off",
- "noArrayIndexKey": "off"
- },
- "performance": {
- "noDelete": "off"
- },
- "nursery": "off",
- "a11y": {
- "useSemanticElements": "off",
- "useValidAnchor": "off"
- },
- "style": {
- "noParameterAssign": "error",
- "useAsConstAssertion": "error",
- "useDefaultParameterLast": "error",
- "useEnumInitializers": "error",
- "useSelfClosingElements": "error",
- "useSingleVarDeclarator": "error",
- "noUnusedTemplateLiteral": "error",
- "useNumberNamespace": "error",
- "noInferrableTypes": "error",
- "noUselessElse": "error"
- }
- }
- }
+ "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": false,
+ "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "tab",
+ "indentWidth": 4,
+ "lineWidth": 120,
+ "formatWithErrors": true
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": {
+ "level": "on",
+ "options": {
+ "groups": [
+ ":BUN:",
+ ":NODE:",
+ ["npm:*", "npm:*/**"],
+ ":PACKAGE_WITH_PROTOCOL:",
+ ":URL:",
+ ":PACKAGE:",
+ ["/src/*", "/src/**"],
+ ["/**"],
+ ["#*", "#*/**"],
+ ":PATH:"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "correctness": {
+ "useUniqueElementIds": "off"
+ },
+ "suspicious": {
+ "noExplicitAny": "off",
+ "noArrayIndexKey": "off"
+ },
+ "performance": {
+ "noDelete": "off"
+ },
+ "nursery": "off",
+ "a11y": {
+ "useSemanticElements": "off",
+ "useValidAnchor": "off"
+ },
+ "style": {
+ "noParameterAssign": "error",
+ "useAsConstAssertion": "error",
+ "useDefaultParameterLast": "error",
+ "useEnumInitializers": "error",
+ "useSelfClosingElements": "error",
+ "useSingleVarDeclarator": "error",
+ "noUnusedTemplateLiteral": "error",
+ "useNumberNamespace": "error",
+ "noInferrableTypes": "error",
+ "noUselessElse": "error"
+ }
+ }
+ }
}
diff --git a/frontend/check-locales.cjs b/frontend/check-locales.cjs
old mode 100755
new mode 100644
index deb418a3ec..251b19c4b4
--- a/frontend/check-locales.cjs
+++ b/frontend/check-locales.cjs
@@ -29,6 +29,9 @@ const allLocales = [
["tr", "tr-TR"],
["hu", "hu-HU"],
["no", "no-NO"],
+ ["uk", "uk-UA"],
+ ["az", "az-AZ"],
+ ["fa", "fa-IR"],
];
const ignoreUnused = [/^.*$/];
diff --git a/frontend/package.json b/frontend/package.json
index 769a2a7702..e2cc7ee64b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,51 +16,52 @@
"test": "vitest"
},
"dependencies": {
- "@tabler/core": "^1.4.0",
- "@tabler/icons-react": "^3.44.0",
- "@tanstack/react-query": "^5.100.14",
- "@tanstack/react-table": "^8.21.3",
+ "@tabler/core": "^1.5.1",
+ "@tabler/icons-react": "^3.46.0",
+ "@tanstack/react-query": "^5.102.8",
+ "@tanstack/react-table": "^9.2.4",
"@uiw/react-textarea-code-editor": "^3.1.1",
"classnames": "^2.5.1",
- "country-flag-icons": "^1.6.17",
- "date-fns": "^4.3.0",
+ "country-flag-icons": "^1.6.20",
+ "date-fns": "^4.4.0",
"ez-modal-react": "^1.0.5",
"formik": "^2.4.9",
"generate-password-browser": "^1.1.0",
"humps": "^2.0.1",
- "query-string": "^9.3.1",
- "react": "^19.2.6",
+ "query-string": "^9.5.1",
+ "react": "^19.3.0",
"react-bootstrap": "^2.10.10",
- "react-dom": "^19.2.6",
- "react-intl": "^10.1.9",
+ "react-dom": "^19.3.0",
+ "react-intl": "^10.2.0",
"react-markdown": "^10.1.0",
- "react-router-dom": "^7.15.1",
+ "react-qr-code": "^2.2.0",
+ "react-router-dom": "^7.18.4",
"react-select": "^5.10.2",
"react-toastify": "^11.1.0",
- "rooks": "^9.8.0"
+ "rooks": "^9.9.0"
},
"devDependencies": {
- "@biomejs/biome": "^2.4.15",
- "@formatjs/cli": "^6.16.3",
- "@tanstack/react-query-devtools": "^5.100.14",
- "@testing-library/dom": "^10.4.1",
- "@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.2",
+ "@biomejs/biome": "^2.5.10",
+ "@formatjs/cli": "^6.16.30",
+ "@tanstack/react-query-devtools": "^5.103.1",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
"@types/country-flag-icons": "^1.2.2",
"@types/humps": "^2.0.6",
- "@types/node": "^25.9.1",
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
+ "@types/node": "^26.6.2",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
"@types/react-table": "^7.7.20",
- "@vitejs/plugin-react": "^6.0.2",
- "happy-dom": "^20.9.0",
- "postcss": "^8.5.15",
+ "@vitejs/plugin-react": "^6.1.1",
+ "happy-dom": "^20.14.5",
+ "postcss": "^8.5.28",
"postcss-simple-vars": "^7.0.1",
- "sass": "^1.100.0",
- "tmp": "^0.2.6",
- "typescript": "6.0.3",
- "vite": "^8.0.14",
- "vite-plugin-checker": "^0.14.1",
- "vitest": "^4.1.7"
+ "sass": "^1.104.1",
+ "tmp": "^0.2.7",
+ "typescript": "7.0.2",
+ "vite": "^8.3.0",
+ "vite-plugin-checker": "^0.14.5",
+ "vitest": "^4.1.11"
}
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b6f0bba70d..dfd0c7fe44 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,7 +4,7 @@ import EasyModal from "ez-modal-react";
import { RawIntlProvider } from "react-intl";
import { ToastContainer } from "react-toastify";
import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
-import { intl } from "src/locale";
+import { getLocale, intl, isRTLLocale } from "src/locale";
import Router from "src/Router.tsx";
// Create a client
@@ -26,7 +26,7 @@ function App() {
hideProgressBar={true}
newestOnTop={true}
closeOnClick={true}
- rtl={false}
+ rtl={isRTLLocale(getLocale())}
closeButton={false}
/>
diff --git a/frontend/src/Router.test.tsx b/frontend/src/Router.test.tsx
new file mode 100644
index 0000000000..bd61148073
--- /dev/null
+++ b/frontend/src/Router.test.tsx
@@ -0,0 +1,79 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import Router from "src/Router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { authState } = vi.hoisted(() => ({ authState: { authenticated: true } }));
+
+vi.mock("src/context", () => ({ useAuthState: () => authState }));
+vi.mock("src/hooks", () => ({
+ useHealth: () => ({ data: { status: "OK", setup: true }, isLoading: false, isError: false }),
+}));
+vi.mock("src/components", () => ({
+ Page: ({ children }: { children: ReactNode }) => children,
+ SiteContainer: ({ children }: { children: ReactNode }) => children,
+ SiteHeader: () => null,
+ SiteMenu: () => null,
+ SiteFooter: () => null,
+ LoadingPage: () => Loading
,
+ Unhealthy: () => Unhealthy
,
+ ErrorNotFound: () => Not found
,
+}));
+vi.mock("src/pages/Dashboard", () => ({ default: () => Dashboard
}));
+vi.mock("src/pages/Login", () => ({ default: () => Login
}));
+vi.mock("src/pages/Nginx/ProxyHosts", () => ({ default: () => Proxy hosts
}));
+
+describe("Router", () => {
+ beforeEach(() => {
+ authState.authenticated = true;
+ window.history.replaceState(null, "", "/");
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it.each(["/login", "/login/", "/login?next=/users#form"])(
+ "redirects an authenticated visit to %s to the dashboard",
+ async (path) => {
+ window.history.replaceState(null, "", path);
+ const replaceState = vi.spyOn(window.history, "replaceState");
+ render();
+
+ expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible();
+ expect(window.location.pathname).toBe("/");
+ expect(window.location.search).toBe("");
+ expect(window.location.hash).toBe("");
+ expect(replaceState).toHaveBeenCalledWith(expect.anything(), "", "/");
+ },
+ );
+
+ it("shows the login form when signed out and redirects after sign-in", async () => {
+ authState.authenticated = false;
+ window.history.replaceState(null, "", "/login");
+ const { rerender } = render();
+
+ expect(await screen.findByRole("heading", { name: "Login" })).toBeVisible();
+ expect(window.location.pathname).toBe("/login");
+
+ authState.authenticated = true;
+ rerender();
+
+ expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible();
+ await waitFor(() => expect(window.location.pathname).toBe("/"));
+ });
+
+ it.each([
+ ["/", "Dashboard"],
+ ["/nginx/proxy", "Proxy hosts"],
+ ["/unknown", "Not found"],
+ ])("preserves the existing route for %s", async (path, heading) => {
+ window.history.replaceState(null, "", path);
+ render();
+
+ expect(await screen.findByRole("heading", { name: heading })).toBeVisible();
+ expect(window.location.pathname).toBe(path);
+ });
+});
diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx
index 6aa8f0894f..dc7556509e 100644
--- a/frontend/src/Router.tsx
+++ b/frontend/src/Router.tsx
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
-import { BrowserRouter, Route, Routes } from "react-router-dom";
+import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import {
ErrorNotFound,
LoadingPage,
@@ -20,6 +20,7 @@ const Settings = lazy(() => import("src/pages/Settings"));
const Certificates = lazy(() => import("src/pages/Certificates"));
const Access = lazy(() => import("src/pages/Access"));
const AuditLog = lazy(() => import("src/pages/AuditLog"));
+const Logs = lazy(() => import("src/pages/Logs"));
const Users = lazy(() => import("src/pages/Users"));
const ProxyHosts = lazy(() => import("src/pages/Nginx/ProxyHosts"));
const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts"));
@@ -61,9 +62,11 @@ function Router() {
}>
} />
+ } />
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/api/backend/getLogSources.ts b/frontend/src/api/backend/getLogSources.ts
new file mode 100644
index 0000000000..4bec700024
--- /dev/null
+++ b/frontend/src/api/backend/getLogSources.ts
@@ -0,0 +1,8 @@
+import * as api from "./base";
+import type { LogSources } from "./models";
+
+export async function getLogSources(): Promise {
+ return await api.get({
+ url: "/logs/sources",
+ });
+}
diff --git a/frontend/src/api/backend/getLogTail.ts b/frontend/src/api/backend/getLogTail.ts
new file mode 100644
index 0000000000..899282b326
--- /dev/null
+++ b/frontend/src/api/backend/getLogTail.ts
@@ -0,0 +1,19 @@
+import * as api from "./base";
+import type { LogChannel, LogHostType, LogSourceType, LogTail } from "./models";
+
+export interface GetLogTailParams {
+ type: LogSourceType;
+ hostType?: LogHostType;
+ hostId?: number;
+ channel?: LogChannel;
+ lines?: number;
+ level?: string;
+ search?: string;
+}
+
+export async function getLogTail(params: GetLogTailParams): Promise {
+ return await api.get({
+ url: "/logs/tail",
+ params: { ...params },
+ });
+}
diff --git a/frontend/src/api/backend/getProxyHostLogs.ts b/frontend/src/api/backend/getProxyHostLogs.ts
new file mode 100644
index 0000000000..91944c9d72
--- /dev/null
+++ b/frontend/src/api/backend/getProxyHostLogs.ts
@@ -0,0 +1,8 @@
+import * as api from "./base";
+
+export async function getProxyHostLogs(id: number, type: "access" | "error" = "access"): Promise<{ logs: string }> {
+ return await api.get({
+ url: `/nginx/proxy-hosts/${id}/logs`,
+ params: { type },
+ });
+}
diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts
index 40cb4142fc..ff6e420684 100644
--- a/frontend/src/api/backend/index.ts
+++ b/frontend/src/api/backend/index.ts
@@ -26,7 +26,10 @@ export * from "./getDeadHost";
export * from "./getDeadHosts";
export * from "./getHealth";
export * from "./getHostsReport";
+export * from "./getLogSources";
+export * from "./getLogTail";
export * from "./getProxyHost";
+export * from "./getProxyHostLogs";
export * from "./getProxyHosts";
export * from "./getRedirectionHost";
export * from "./getRedirectionHosts";
diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts
index 2ae0b08348..96c1b8a488 100644
--- a/frontend/src/api/backend/models.ts
+++ b/frontend/src/api/backend/models.ts
@@ -44,6 +44,30 @@ export interface AuditLog {
user?: User;
}
+export type LogSourceType = "system" | "letsencrypt" | "host";
+export type LogHostType = "proxy" | "redirection" | "dead" | "stream";
+// Which of the two files nginx writes per host - named "channel", not "stream",
+// to avoid confusion with the "stream" LogHostType (TCP/UDP stream hosts).
+export type LogChannel = "access" | "error";
+
+export interface LogHostOption {
+ id: number;
+ label: string;
+}
+
+export interface LogSources {
+ system: { label: string };
+ letsencrypt: { label: string };
+ hosts: Record;
+}
+
+export interface LogTail {
+ lines: string[];
+ size: number;
+ truncated: boolean;
+ exists: boolean;
+}
+
export interface AccessList {
id?: number;
createdOn?: string;
@@ -103,6 +127,8 @@ export interface ProxyLocation {
forwardScheme: string;
forwardHost: string;
forwardPort: number;
+ accessListId?: number;
+ accessList?: AccessList;
}
export interface ProxyHost {
diff --git a/frontend/src/components/EmptyData.tsx b/frontend/src/components/EmptyData.tsx
index 46a9664c2f..104c8a3611 100644
--- a/frontend/src/components/EmptyData.tsx
+++ b/frontend/src/components/EmptyData.tsx
@@ -1,12 +1,13 @@
-import type { Table as ReactTable } from "@tanstack/react-table";
+import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import cn from "classnames";
import type { ReactNode } from "react";
import { Button, HasPermission } from "src/components";
+import type { Features } from "src/components/Table/features";
import { T } from "src/locale";
import { type ADMIN, MANAGE, type Permission, type Section } from "src/modules/Permissions";
-interface Props {
- tableInstance: ReactTable;
+interface Props {
+ tableInstance: ReactTable;
onNew?: () => void;
isFiltered?: boolean;
object: string;
@@ -16,7 +17,7 @@ interface Props {
permissionSection?: Section | typeof ADMIN;
permission?: Permission;
}
-function EmptyData({
+function EmptyData({
tableInstance,
onNew,
isFiltered,
@@ -26,7 +27,7 @@ function EmptyData({
customAddBtn,
permissionSection,
permission,
-}: Props) {
+}: Props) {
return (
diff --git a/frontend/src/components/Form/AccessClientFields.tsx b/frontend/src/components/Form/AccessClientFields.tsx
index 9dda8c3de0..bac4458409 100644
--- a/frontend/src/components/Form/AccessClientFields.tsx
+++ b/frontend/src/components/Form/AccessClientFields.tsx
@@ -65,8 +65,12 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value={client.directive}
onChange={(e) => handleChange(idx, "directive", e.target.value)}
>
-
-
+
+
))}
@@ -112,7 +113,9 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value="deny"
disabled
>
-
+
void;
+ // When set, the 0 option inherits the host's access list instead of being public
+ inheritHost?: boolean;
}
-export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) {
+export function AccessField({
+ name = "accessListId",
+ label = "access-list",
+ id = "accessListId",
+ onFormChange,
+ inheritHost = false,
+}: Props) {
const { locale } = useLocaleState();
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
const { setFieldValue } = useFormikContext();
const handleChange = (newValue: any, _actionMeta: ActionMeta) => {
setFieldValue(name, newValue?.value);
+ if (onFormChange) {
+ onFormChange(newValue?.value ?? 0);
+ }
};
const options: AccessOption[] =
@@ -56,13 +68,22 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
icon: ,
})) || [];
- // Public option
- options?.unshift({
- value: 0,
- label: intl.formatMessage({ id: "access-list.public" }),
- subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
- icon: ,
- });
+ // Public or inherit option
+ options?.unshift(
+ inheritHost
+ ? {
+ value: 0,
+ label: intl.formatMessage({ id: "access-list.inherit" }),
+ subLabel: intl.formatMessage({ id: "access-list.inherit.subtitle" }),
+ icon: ,
+ }
+ : {
+ value: 0,
+ label: intl.formatMessage({ id: "access-list.public" }),
+ subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
+ icon: ,
+ },
+ );
return (
diff --git a/frontend/src/components/Form/BasicAuthFields.tsx b/frontend/src/components/Form/BasicAuthFields.tsx
index c47d96da42..d8d33d1595 100644
--- a/frontend/src/components/Form/BasicAuthFields.tsx
+++ b/frontend/src/components/Form/BasicAuthFields.tsx
@@ -82,16 +82,13 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
/>
))}
diff --git a/frontend/src/components/Form/LocationsFields.module.css b/frontend/src/components/Form/LocationsFields.module.css
index 4b48ef3cb9..9551e0d4ef 100644
--- a/frontend/src/components/Form/LocationsFields.module.css
+++ b/frontend/src/components/Form/LocationsFields.module.css
@@ -1,3 +1,51 @@
+/* card-active points --tblr-card-border-color at --tblr-primary, which both the
+ card outline and the card header's bottom border are drawn from. Tabler's
+ stylesheet is loaded after this one, so the override needs !important. */
.locationCard {
- border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
+ --tblr-card-border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
+}
+
+.filter {
+ max-width: 20rem;
+}
+
+/* The header is a plain toggle rather than a button-styled control, so that a
+ list of collapsed locations reads as rows instead of a stack of buttons. */
+.toggle {
+ display: flex;
+ flex: 1 1 auto;
+ align-self: stretch;
+ align-items: center;
+ min-width: 0;
+ padding: 0;
+ color: inherit;
+ text-align: left;
+ background: transparent;
+ border: 0;
+}
+
+.toggle:focus-visible {
+ outline: 2px solid var(--tblr-primary);
+ outline-offset: -2px;
+}
+
+/* Keeps the marker on one line next to the delete button, and lets it drop out
+ of the way before the path does when the row runs out of room. */
+.marker {
+ display: flex;
+ flex: 0 1 auto;
+ align-items: center;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.path {
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.summary {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx
index 4240b1f986..a0f64c2393 100644
--- a/frontend/src/components/Form/LocationsFields.tsx
+++ b/frontend/src/components/Form/LocationsFields.tsx
@@ -1,20 +1,44 @@
-import { IconSettings } from "@tabler/icons-react";
+import {
+ IconChevronDown,
+ IconChevronRight,
+ IconPlus,
+ IconSearch,
+ IconSettings,
+ IconTrash,
+ IconX,
+} from "@tabler/icons-react";
import CodeEditor from "@uiw/react-textarea-code-editor";
import cn from "classnames";
import { useFormikContext } from "formik";
-import { useState } from "react";
+import { useRef, useState } from "react";
import type { ProxyLocation } from "src/api/backend";
import { intl, T } from "src/locale";
+import { AccessField } from "./AccessField";
import styles from "./LocationsFields.module.css";
+// Below this many locations the list is short enough to scan by eye, and the
+// filter would only take up space.
+const FILTER_THRESHOLD = 5;
+
+// Locations are identified by a client-side id rather than their array index,
+// so that expanded/advanced state stays with the right row when one is removed.
+interface Row {
+ id: number;
+ value: ProxyLocation;
+}
+
interface Props {
initialValues: ProxyLocation[];
name?: string;
}
export function LocationsFields({ initialValues, name = "locations" }: Props) {
- const [values, setValues] = useState(initialValues || []);
+ const [rows, setRows] = useState(() => (initialValues || []).map((value, id) => ({ id, value })));
const { setFieldValue } = useFormikContext();
+ const [expanded, setExpanded] = useState([]);
const [advVisible, setAdvVisible] = useState([]);
+ const [filter, setFilter] = useState("");
+ const nextId = useRef(rows.length);
+ const scrollToId = useRef(null);
const blankItem: ProxyLocation = {
path: "",
@@ -22,34 +46,71 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
forwardScheme: "http",
forwardHost: "",
forwardPort: 80,
+ accessListId: 0,
+ };
+
+ const toggleExpanded = (id: number) => {
+ setExpanded(expanded.includes(id) ? expanded.filter((i) => i !== id) : [...expanded, id]);
};
- const toggleAdvVisible = (idx: number) => {
- setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
+ const toggleAdvVisible = (id: number) => {
+ setAdvVisible(advVisible.includes(id) ? advVisible.filter((i) => i !== id) : [...advVisible, id]);
};
const handleAdd = () => {
- setValues([...values, blankItem]);
+ const id = nextId.current++;
+ setRows([...rows, { id, value: blankItem }]);
+ // A new location starts empty, so open it and make sure an active filter
+ // doesn't hide the row that was just added.
+ setExpanded([...expanded, id]);
+ setFilter("");
+ scrollToId.current = id;
};
- const handleRemove = (idx: number) => {
- const newValues = values.filter((_: ProxyLocation, i: number) => i !== idx);
- setValues(newValues);
- setFormField(newValues);
+ const handleRemove = (id: number) => {
+ const newRows = rows.filter((r: Row) => r.id !== id);
+ setRows(newRows);
+ setExpanded(expanded.filter((i) => i !== id));
+ setAdvVisible(advVisible.filter((i) => i !== id));
+ setFormField(newRows);
};
- const handleChange = (idx: number, field: string, fieldValue: string) => {
- const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
- setValues(newValues);
- setFormField(newValues);
+ const handleChange = (id: number, field: string, fieldValue: string) => {
+ const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, [field]: fieldValue } } : r));
+ setRows(newRows);
+ setFormField(newRows);
};
- const setFormField = (newValues: ProxyLocation[]) => {
- const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
+ const handleAccessListChange = (id: number, accessListId: number) => {
+ const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, accessListId } } : r));
+ setRows(newRows);
+ setFormField(newRows);
+ };
+
+ const setFormField = (newRows: Row[]) => {
+ const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
setFieldValue(name, filtered);
};
- if (values.length === 0) {
+ const forwardSummary = (item: ProxyLocation) => {
+ if (!item.forwardHost) {
+ return "";
+ }
+ return `${item.forwardScheme}://${item.forwardHost}${item.forwardPort ? `:${item.forwardPort}` : ""}`;
+ };
+
+ // Matches the path as well as the destination, so a location can be found by
+ // the host or port it forwards to and not just by its path.
+ const matchesFilter = (item: ProxyLocation, query: string) =>
+ [item.path, item.forwardScheme, item.forwardHost, item.forwardPort, forwardSummary(item)]
+ .join(" ")
+ .toLowerCase()
+ .includes(query);
+
+ const query = filter.trim().toLowerCase();
+ const visibleRows = query ? rows.filter((r: Row) => matchesFilter(r.value, query)) : rows;
+
+ if (rows.length === 0) {
return (
|