Merge pull request #5880 from NginxProxyManager/develop

v2.16.0
This commit is contained in:
jc21
2026-09-24 19:27:13 +10:00
committed by GitHub
180 changed files with 8461 additions and 2866 deletions
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -1 +1 @@
2.15.1
2.16.0
+1 -1
View File
@@ -1,7 +1,7 @@
<p align="center">
<img src="https://nginxproxymanager.com/github.png">
<br><br>
<img src="https://img.shields.io/badge/version-2.15.1-green.svg?style=for-the-badge">
<img src="https://img.shields.io/badge/version-2.16.0-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a>
+2 -2
View File
@@ -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`
+72 -89
View File
@@ -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"
}
}
}
}
+23 -7
View File
@@ -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 = <wedos_registration>\ndns_wedos_auth = <wapi_password>",
@@ -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"
}
}
+4 -4
View File
@@ -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;
+7 -11
View File
@@ -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");
+101 -43
View File
@@ -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<Array>}
*/
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
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 });
}
// 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);
}
}
// 3. reconfigure those hosts from fresh rows, then reload nginx
if (affectedHostIds.size) {
const affectedHosts = 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;
});
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
.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;
+1 -6
View File
@@ -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(", ")}]`);
+63 -6
View File
@@ -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, () => {});
}
}
},
/**
+25 -37
View File
@@ -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)
+229
View File
@@ -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;
+45 -7
View File
@@ -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 ":<port>". Without the brackets nginx
// reads the trailing ":<port>" 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();
+64 -67
View File
@@ -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;
row.enabled = 1;
return proxyHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
})
.then(() => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
});
})
.then(() => {
return true;
await proxyHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
});
// Configure nginx
await internalNginx.configure(proxyHostModel, "proxy_host", row);
// Add to audit log
await internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
return true;
});
},
/**
+4 -14
View File
@@ -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
+14 -6
View File
@@ -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(() => {
+11 -11
View File
@@ -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",
},
},
};
+24 -5
View File
@@ -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);
}
},
};
+7
View File
@@ -0,0 +1,7 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
+37 -17
View File
@@ -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,
};
+9
View File
@@ -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;
+1 -1
View File
@@ -1,4 +1,4 @@
import _ from "lodash";
import _ from "lodash";
export default (default_sort, default_offset, default_limit, max_limit) => {
/**
+2 -2
View File
@@ -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);
}
+4 -5
View File
@@ -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;
};
+4 -1
View File
@@ -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
+111 -11
View File
@@ -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()) {
@@ -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`);
});
@@ -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`);
@@ -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 };
export { up, down };
+4
View File
@@ -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);
}
+8 -8
View File
@@ -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"];
}
}
+7 -7
View File
@@ -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";
}
}
+14 -14
View File
@@ -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": {
+1 -4
View File
@@ -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,
},
);
+106
View File
@@ -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;
+2
View File
@@ -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);
+7 -29
View File
@@ -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, {
+4 -2
View File
@@ -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}`);
+67
View File
@@ -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;
+10 -41
View File
@@ -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) {
@@ -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"
}
}
}
}
}
}
@@ -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
}
}
}
@@ -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
}
]
},
+1 -1
View File
@@ -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",
@@ -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"
}
}
}
}
}
}
+100
View File
@@ -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"
}
}
}
}
}
}
+14
View File
@@ -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"
+6 -6
View File
@@ -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);
+29 -43
View File
@@ -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(", ")}`);
}
}
+4 -4
View File
@@ -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 }};
+417 -371
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 \
+1
View File
@@ -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:
+4
View File
@@ -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
+11
View File
@@ -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"
@@ -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
}
@@ -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;
@@ -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
@@ -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
@@ -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
@@ -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=
+1 -1
View File
@@ -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"
+4
View File
@@ -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
}
+1 -1
View File
@@ -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
+18
View File
@@ -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`
+8 -8
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
src/locale/lang
# Logs
logs
/logs/
*.log
npm-debug.log*
yarn-debug.log*
+73 -90
View File
@@ -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"
}
}
}
}
Executable → Regular
+3
View File
@@ -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 = [/^.*$/];
+31 -30
View File
@@ -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"
}
}
+2 -2
View File
@@ -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}
/>
</AuthProvider>
+79
View File
@@ -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: () => <div>Loading</div>,
Unhealthy: () => <div>Unhealthy</div>,
ErrorNotFound: () => <h1>Not found</h1>,
}));
vi.mock("src/pages/Dashboard", () => ({ default: () => <h1>Dashboard</h1> }));
vi.mock("src/pages/Login", () => ({ default: () => <h1>Login</h1> }));
vi.mock("src/pages/Nginx/ProxyHosts", () => ({ default: () => <h1>Proxy hosts</h1> }));
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(<Router />);
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(<Router />);
expect(await screen.findByRole("heading", { name: "Login" })).toBeVisible();
expect(window.location.pathname).toBe("/login");
authState.authenticated = true;
rerender(<Router />);
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(<Router />);
expect(await screen.findByRole("heading", { name: heading })).toBeVisible();
expect(window.location.pathname).toBe(path);
});
});
+4 -1
View File
@@ -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() {
<Suspense fallback={<LoadingPage noLogo />}>
<Routes>
<Route path="*" element={<ErrorNotFound />} />
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="/certificates" element={<Certificates />} />
<Route path="/access" element={<Access />} />
<Route path="/audit-log" element={<AuditLog />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
<Route path="/users" element={<Users />} />
<Route path="/nginx/proxy" element={<ProxyHosts />} />
@@ -0,0 +1,8 @@
import * as api from "./base";
import type { LogSources } from "./models";
export async function getLogSources(): Promise<LogSources> {
return await api.get({
url: "/logs/sources",
});
}
+19
View File
@@ -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<LogTail> {
return await api.get({
url: "/logs/tail",
params: { ...params },
});
}
@@ -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 },
});
}
+3
View File
@@ -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";
+26
View File
@@ -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<LogHostType, LogHostOption[]>;
}
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 {
+6 -5
View File
@@ -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<any>;
interface Props<TData extends RowData> {
tableInstance: ReactTable<Features, TData>;
onNew?: () => void;
isFiltered?: boolean;
object: string;
@@ -16,7 +17,7 @@ interface Props {
permissionSection?: Section | typeof ADMIN;
permission?: Permission;
}
function EmptyData({
function EmptyData<TData extends RowData>({
tableInstance,
onNew,
isFiltered,
@@ -26,7 +27,7 @@ function EmptyData({
customAddBtn,
permissionSection,
permission,
}: Props) {
}: Props<TData>) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
@@ -65,8 +65,12 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value={client.directive}
onChange={(e) => handleChange(idx, "directive", e.target.value)}
>
<option value="allow"><T id="action.allow" /></option>
<option value="deny"><T id="action.deny" /></option>
<option value="allow">
<T id="action.allow" />
</option>
<option value="deny">
<T id="action.deny" />
</option>
</select>
</span>
<input
@@ -81,16 +85,13 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
</div>
</div>
<div className="col-1">
<a
role="button"
<button
type="button"
className="btn btn-ghost btn-danger p-0"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
onClick={() => handleRemove(idx)}
>
<IconX size={16} />
</a>
</button>
</div>
</div>
))}
@@ -112,7 +113,9 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value="deny"
disabled
>
<option value="deny"><T id="action.deny" /></option>
<option value="deny">
<T id="action.deny" />
</option>
</select>
</span>
<input
+30 -9
View File
@@ -1,4 +1,4 @@
import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
import { IconArrowBackUp, IconLock, IconLockOpen2 } from "@tabler/icons-react";
import { Field, useFormikContext } from "formik";
import type { ReactNode } from "react";
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
@@ -31,14 +31,26 @@ interface Props {
id?: string;
name?: string;
label?: string;
onFormChange?: (value: number) => 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<AccessOption>) => {
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: <IconLock size={14} className="text-lime" />,
})) || [];
// Public option
options?.unshift({
value: 0,
label: intl.formatMessage({ id: "access-list.public" }),
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
icon: <IconLockOpen2 size={14} className="text-red" />,
});
// 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: <IconArrowBackUp size={14} className="text-secondary" />,
}
: {
value: 0,
label: intl.formatMessage({ id: "access-list.public" }),
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
icon: <IconLockOpen2 size={14} className="text-red" />,
},
);
return (
<Field name={name}>
@@ -82,16 +82,13 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
/>
</div>
<div className="col-1">
<a
role="button"
<button
type="button"
className="btn btn-ghost btn-danger p-0"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
onClick={() => handleRemove(idx)}
>
<IconX size={16} />
</a>
</button>
</div>
</div>
))}
@@ -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;
}
+291 -131
View File
@@ -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<ProxyLocation[]>(initialValues || []);
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
const { setFieldValue } = useFormikContext();
const [expanded, setExpanded] = useState<number[]>([]);
const [advVisible, setAdvVisible] = useState<number[]>([]);
const [filter, setFilter] = useState("");
const nextId = useRef(rows.length);
const scrollToId = useRef<number | null>(null);
const blankItem: ProxyLocation = {
path: "",
@@ -22,34 +46,71 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
forwardScheme: "http",
forwardHost: "",
forwardPort: 80,
accessListId: 0,
};
const toggleAdvVisible = (idx: number) => {
setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
const toggleExpanded = (id: number) => {
setExpanded(expanded.includes(id) ? expanded.filter((i) => i !== id) : [...expanded, id]);
};
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 (
<div className="text-center">
<button type="button" className="btn my-3" onClick={handleAdd}>
@@ -61,125 +122,224 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
return (
<>
{values.map((item: ProxyLocation, idx: number) => (
<div key={idx} className={cn("card", "card-active", "mb-3", styles.locationCard)}>
<div className="card-body">
<div className="row">
<div className="col-md-10">
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
type="text"
className="form-control"
placeholder="/path"
autoComplete="off"
value={item.path}
onChange={(e) => handleChange(idx, "path", e.target.value)}
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
onClick={() => toggleAdvVisible(idx)}
>
<IconSettings size={20} />
</button>
</div>
</div>
<div className="row">
<div className="col-md-3">
<div className="mb-3">
<label className="form-label" htmlFor="forwardScheme">
<T id="host.forward-scheme" />
</label>
<select
id="forwardScheme"
className="form-control"
value={item.forwardScheme}
onChange={(e) => handleChange(idx, "forwardScheme", e.target.value)}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label" htmlFor="forwardHost">
<T id="proxy-host.forward-host" />
</label>
<input
id="forwardHost"
type="text"
className="form-control"
required
placeholder="eg: 10.0.0.1/path/"
value={item.forwardHost}
onChange={(e) => handleChange(idx, "forwardHost", e.target.value)}
/>
</div>
</div>
<div className="col-md-3">
<div className="mb-3">
<label className="form-label" htmlFor="forwardPort">
<T id="host.forward-port" />
</label>
<input
id="forwardPort"
type="number"
min={1}
max={65535}
className="form-control"
required
placeholder="eg: 8081"
value={item.forwardPort}
onChange={(e) => handleChange(idx, "forwardPort", e.target.value)}
/>
</div>
</div>
</div>
{advVisible.includes(idx) && (
<div className="">
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={170}
indentWidth={2}
value={item.advancedConfig}
onChange={(e) => handleChange(idx, "advancedConfig", e.target.value)}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "170px",
}}
/>
</div>
)}
<div className="mt-1">
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
<div className="d-flex align-items-center mb-3">
{rows.length >= FILTER_THRESHOLD && (
<div className={cn("input-group", styles.filter)}>
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
autoComplete="off"
placeholder={intl.formatMessage({ id: "location.filter" })}
aria-label={intl.formatMessage({ id: "location.filter" })}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filter ? (
<button
type="button"
className="btn btn-icon"
title={intl.formatMessage({ id: "action.clear" })}
aria-label={intl.formatMessage({ id: "action.clear" })}
onClick={() => setFilter("")}
>
<T id="action.delete" />
</a>
</div>
<IconX size={16} />
</button>
) : null}
</div>
</div>
))}
<div>
<button type="button" className="btn btn-sm" onClick={handleAdd}>
)}
<button type="button" className="btn ms-auto" onClick={handleAdd}>
<IconPlus size={16} className="me-1" />
<T id="action.add-location" />
</button>
</div>
{visibleRows.length === 0 ? (
<div className="text-secondary text-center my-3">
<T id="empty-search" />
</div>
) : (
visibleRows.map((row: Row) => {
const item = row.value;
const isOpen = expanded.includes(row.id);
const bodyId = `location-body-${row.id}`;
return (
<div
key={row.id}
ref={(node) => {
if (node && scrollToId.current === row.id) {
scrollToId.current = null;
node.scrollIntoView({ block: "nearest" });
}
}}
className={cn("card", "card-active", "mb-2", styles.locationCard)}
>
<div className={cn("card-header", "p-2", !isOpen && "border-bottom-0")}>
<button
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-controls={bodyId}
onClick={() => toggleExpanded(row.id)}
>
{isOpen ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<span className={cn("ms-2", styles.path)}>{item.path}</span>
<span className={cn("ms-2", "text-secondary", styles.summary)}>
{forwardSummary(item)}
</span>
</button>
{item.advancedConfig ? (
// Deliberately the same icon as the advanced-config toggle in the
// body below, so the marker reads as "this row has that section
// filled in" rather than as a decoration of its own.
<span
className={cn("ms-2", "text-secondary", styles.marker)}
role="img"
title={intl.formatMessage({ id: "location.advanced-config" })}
aria-label={intl.formatMessage({ id: "location.advanced-config" })}
>
<IconSettings size={16} />
</span>
) : null}
<button
type="button"
className="btn btn-action ms-2"
title={intl.formatMessage({ id: "action.delete" })}
aria-label={intl.formatMessage({ id: "action.delete" })}
onClick={() => handleRemove(row.id)}
>
<IconTrash size={16} className="icon" />
</button>
</div>
{isOpen && (
<div className="card-body" id={bodyId}>
<div className="row">
<div className="col-md-10">
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
type="text"
className="form-control"
placeholder="/path"
autoComplete="off"
value={item.path}
onChange={(e) => handleChange(row.id, "path", e.target.value)}
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
aria-expanded={advVisible.includes(row.id)}
onClick={() => toggleAdvVisible(row.id)}
>
<IconSettings size={20} />
</button>
</div>
</div>
<div className="row">
<div className="col-md-3">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardScheme-${row.id}`}
>
<T id="host.forward-scheme" />
</label>
<select
id={`location-forwardScheme-${row.id}`}
className="form-control"
value={item.forwardScheme}
onChange={(e) =>
handleChange(row.id, "forwardScheme", e.target.value)
}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardHost-${row.id}`}
>
<T id="proxy-host.forward-host" />
</label>
<input
id={`location-forwardHost-${row.id}`}
type="text"
className="form-control"
required
placeholder="eg: 10.0.0.1/path/"
value={item.forwardHost}
onChange={(e) =>
handleChange(row.id, "forwardHost", e.target.value)
}
/>
</div>
</div>
<div className="col-md-3">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardPort-${row.id}`}
>
<T id="host.forward-port" />
</label>
<input
id={`location-forwardPort-${row.id}`}
type="number"
min={1}
max={65535}
className="form-control"
required
placeholder="eg: 8081"
value={item.forwardPort}
onChange={(e) =>
handleChange(row.id, "forwardPort", e.target.value)
}
/>
</div>
</div>
</div>
<AccessField
name={`locations[${row.id}].accessListId`}
label="access-list"
id={`locations-access-list-${row.id}`}
onFormChange={(value) => handleAccessListChange(row.id, value)}
inheritHost
/>
{advVisible.includes(row.id) && (
<div className="">
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={170}
indentWidth={2}
value={item.advancedConfig}
onChange={(e) => handleChange(row.id, "advancedConfig", e.target.value)}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "170px",
}}
/>
</div>
)}
</div>
)}
</div>
);
})
)}
</>
);
}
@@ -10,7 +10,13 @@ interface Props {
requireDomainNames?: boolean; // used for streams
color?: string;
}
export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
export function SSLOptionsFields({
forHttp = true,
forProxyHost = false,
forceDNSForNew,
requireDomainNames,
color = "bg-cyan",
}: Props) {
const { values, setFieldValue } = useFormikContext();
const v: any = values || {};
@@ -116,10 +122,12 @@ export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDN
</div>
);
const getHttpAdvancedOptions = () =>(
const getHttpAdvancedOptions = () => (
<div>
<details>
<summary className="mb-1"><T id="domains.advanced" /></summary>
<summary className="mb-1">
<T id="domains.advanced" />
</summary>
<div className="row">
<div className="col-12">
<Field name="trustForwardedProto">
+4 -1
View File
@@ -72,7 +72,10 @@ export function SiteHeader() {
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<div className="d-md-none">
{/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: This div is not interactive. */}
<div className="p-2 pb-1 pe-1 d-flex align-items-center" onClick={e => e.stopPropagation()}>
<div
className="p-2 pb-1 pe-1 d-flex align-items-center"
onClick={(e) => e.stopPropagation()}
>
<div className="ps-2 pe-1 me-auto">
<div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary text-nowrap">
+15 -8
View File
@@ -1,6 +1,7 @@
import {
IconBook,
IconDeviceDesktop,
IconFileText,
IconHome,
IconLock,
IconSettings,
@@ -95,6 +96,12 @@ const menuItems: MenuItem[] = [
label: "auditlogs",
permissionSection: ADMIN,
},
{
to: "/logs",
icon: IconFileText,
label: "logs",
permissionSection: ADMIN,
},
{
to: "/settings",
icon: IconSettings,
@@ -143,7 +150,6 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
className="nav-link dropdown-toggle"
href={item.to}
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
role="button"
>
@@ -176,13 +182,14 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
};
export function SiteMenu() {
const closeMenu = () => setTimeout(() => {
const navbarToggler = document.querySelector<HTMLElement>(".navbar-toggler");
const navbarMenu = document.querySelector("#navbar-menu");
if (navbarToggler && navbarMenu?.classList.contains("show")) {
navbarToggler.click();
}
}, 300);
const closeMenu = () =>
setTimeout(() => {
const navbarToggler = document.querySelector<HTMLElement>(".navbar-toggler");
const navbarMenu = document.querySelector("#navbar-menu");
if (navbarToggler && navbarMenu?.classList.contains("show")) {
navbarToggler.click();
}
}, 300);
return (
<header className="navbar-expand-md">
+5 -4
View File
@@ -1,9 +1,10 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import type { Features } from "./features";
interface Props {
tableInstance: ReactTable<any>;
interface Props<TData extends RowData> {
tableInstance: ReactTable<Features, TData>;
}
function EmptyRow({ tableInstance }: Props) {
function EmptyRow<TData extends RowData>({ tableInstance }: Props<TData>) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
+2 -2
View File
@@ -1,8 +1,8 @@
import { flexRender } from "@tanstack/react-table";
import { flexRender, type RowData } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
import { EmptyRow } from "./EmptyRow";
function TableBody<T>(props: TableLayoutProps<T>) {
function TableBody<T extends RowData>(props: TableLayoutProps<T>) {
const { tableInstance, extraStyles, emptyState } = props;
const rows = tableInstance.getRowModel().rows;
@@ -1,8 +1,8 @@
import { IconArrowsSort, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { flexRender } from "@tanstack/react-table";
import { flexRender, type RowData } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
function TableHeader<T>(props: TableLayoutProps<T>) {
function TableHeader<T extends RowData>(props: TableLayoutProps<T>) {
const { tableInstance } = props;
const headerGroups = tableInstance.getHeaderGroups();
@@ -61,4 +61,4 @@ const tableEventReducer = (state: any, { type, payload }: any) => {
}
};
export { tableEvents, tableEventReducer };
export { tableEventReducer, tableEvents };
@@ -1,15 +1,16 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import type { Features } from "./features";
import { TableBody } from "./TableBody";
import { TableHeader } from "./TableHeader";
interface TableLayoutProps<TFields> {
tableInstance: ReactTable<TFields>;
interface TableLayoutProps<TFields extends RowData> {
tableInstance: ReactTable<Features, TFields>;
emptyState?: React.ReactNode;
extraStyles?: {
row: (rowData: TFields) => any | undefined;
};
}
function TableLayout<TFields>(props: TableLayoutProps<TFields>) {
function TableLayout<TFields extends RowData>(props: TableLayoutProps<TFields>) {
const hasRows = props.tableInstance.getRowModel().rows.length > 0;
return (
<div className="table-responsive">
+35
View File
@@ -0,0 +1,35 @@
import {
columnVisibilityFeature,
createSortedRowModel,
metaHelper,
rowSortingFeature,
tableFeatures,
} from "@tanstack/react-table";
interface ColumnMeta {
className?: string;
}
interface TableMeta {
isFetching?: boolean;
}
/**
* Shared TanStack Table v9 feature registration for every table in the app.
* Sorting and column visibility are used (or their APIs are called
* unconditionally, e.g. `getVisibleFlatColumns`/`getVisibleCells`) by the
* shared TableLayout/TableHeader/TableBody/EmptyData components, so every
* table instance must register them even when a particular table doesn't
* wire up controlled sorting state itself.
*/
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
columnVisibilityFeature,
columnMeta: metaHelper<ColumnMeta>(),
tableMeta: metaHelper<TableMeta>(),
});
type Features = typeof features;
export { type ColumnMeta, type Features, features, type TableMeta };
+1
View File
@@ -1,4 +1,5 @@
export * from "./Formatter";
export * from "./features";
export * from "./TableHeader";
export * from "./TableHelpers";
export * from "./TableLayout";
+3
View File
@@ -10,7 +10,10 @@ export * from "./useDeadHosts";
export * from "./useDnsProviders";
export * from "./useHealth";
export * from "./useHostReport";
export * from "./useLogSources";
export * from "./useLogTail";
export * from "./useProxyHost";
export * from "./useProxyHostLogs";
export * from "./useProxyHosts";
export * from "./useRedirectionHost";
export * from "./useRedirectionHosts";
+15
View File
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { getLogSources, type LogSources } from "src/api/backend";
const fetchLogSources = () => getLogSources();
const useLogSources = (options = {}) => {
return useQuery<LogSources, Error>({
queryKey: ["log-sources"],
queryFn: fetchLogSources,
staleTime: 30 * 1000,
...options,
});
};
export { fetchLogSources, useLogSources };
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { type GetLogTailParams, getLogTail, type LogTail } from "src/api/backend";
const POLL_INTERVAL_MS = 5000;
interface UseLogTailOptions extends GetLogTailParams {
live?: boolean;
}
// Polls for new log lines while `live` is true. React Query only runs the interval
// while the tab is focused (refetchIntervalInBackground defaults to false), so an
// idle/backgrounded browser tab never generates load.
const useLogTail = ({ live = true, ...params }: UseLogTailOptions) => {
const enabled = params.type === "host" ? Boolean(params.hostType && params.hostId && params.channel) : true;
return useQuery<LogTail, Error>({
queryKey: ["log-tail", params],
queryFn: () => getLogTail(params),
enabled,
refetchInterval: live ? POLL_INTERVAL_MS : false,
placeholderData: (previousData) => previousData,
});
};
export { useLogTail };
+12
View File
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getProxyHostLogs } from "src/api/backend";
const useProxyHostLogs = (id: number, type: "access" | "error" = "access") => {
return useQuery<{ logs: string }, Error>({
queryKey: ["proxy-host-logs", id, type],
queryFn: () => getProxyHostLogs(id, type),
staleTime: 10_000,
});
};
export { useProxyHostLogs };
+102 -83
View File
@@ -21,124 +21,143 @@ import langZh from "./lang/zh.json";
import langTr from "./lang/tr.json";
import langHu from "./lang/hu.json";
import langNo from "./lang/no.json";
import langUk from "./lang/uk.json";
import langAz from "./lang/az.json";
import langFa from "./lang/fa.json";
import langList from "./lang/lang-list.json";
// first item of each array should be the language code,
// not the country code
// Remember when adding to this list, also update check-locales.js script
const localeOptions = [
["en", "en-US", langEn],
["de", "de-DE", langDe],
["es", "es-ES", langEs],
["et", "et-EE", langEt],
["pt", "pt-PT", langPt],
["fr", "fr-FR", langFr],
["ga", "ga-IE", langGa],
["ja", "ja-JP", langJa],
["it", "it-IT", langIt],
["nl", "nl-NL", langNl],
["pl", "pl-PL", langPl],
["ru", "ru-RU", langRu],
["sk", "sk-SK", langSk],
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
["tr", "tr-TR", langTr],
["hu", "hu-HU", langHu],
["no", "no-NO", langNo],
["en", "en-US", langEn],
["de", "de-DE", langDe],
["es", "es-ES", langEs],
["et", "et-EE", langEt],
["pt", "pt-PT", langPt],
["fr", "fr-FR", langFr],
["ga", "ga-IE", langGa],
["ja", "ja-JP", langJa],
["it", "it-IT", langIt],
["nl", "nl-NL", langNl],
["pl", "pl-PL", langPl],
["ru", "ru-RU", langRu],
["sk", "sk-SK", langSk],
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
["tr", "tr-TR", langTr],
["hu", "hu-HU", langHu],
["no", "no-NO", langNo],
["uk", "uk-UA", langUk],
["az", "az-AZ", langAz],
["fa", "fa-IR", langFa],
];
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
const thisLocale = (locale || "en").slice(0, 2);
const thisLocale = (locale || "en").slice(0, 2);
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
return Object.assign({}, langList, langEn);
}
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
return Object.assign({}, langList, langEn);
}
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
};
const getFlagCodeForLocale = (locale?: string) => {
const thisLocale = (locale || "en").slice(0, 2);
const thisLocale = (locale || "en").slice(0, 2);
// only add to this if your flag is different from the locale code
const specialCases: Record<string, string> = {
ja: "jp", // Japan
zh: "cn", // China
vi: "vn", // Vietnam
ko: "kr", // Korea
cs: "cz", // Czechia
ga: "ie", // Ireland (Irish)
};
// only add to this if your flag is different from the locale code
const specialCases: Record<string, string> = {
ja: "jp", // Japan
zh: "cn", // China
vi: "vn", // Vietnam
ko: "kr", // Korea
cs: "cz", // Czechia
ga: "ie", // Ireland (Irish)
et: "ee", // Estonia (ISO 3166-1). "et" as a country code would be Ethiopia.
uk: "ua", // Ukraine
fa: "ir", // Iran (Persian)
};
if (specialCases[thisLocale]) {
return specialCases[thisLocale].toUpperCase();
}
return thisLocale.toUpperCase();
if (specialCases[thisLocale]) {
return specialCases[thisLocale].toUpperCase();
}
return thisLocale.toUpperCase();
};
const rtlLocales = ["fa"];
const isRTLLocale = (locale?: string) => rtlLocales.includes((locale || "en").slice(0, 2));
const applyDocumentLocale = (locale: string): void => {
document.documentElement.lang = locale;
document.documentElement.dir = isRTLLocale(locale) ? "rtl" : "ltr";
};
const getLocale = (short = false) => {
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
};
const cache = createIntlCache();
const initialMessages = loadMessages(getLocale());
applyDocumentLocale(getLocale());
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
const changeLocale = (locale: string): void => {
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
document.documentElement.lang = locale;
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
applyDocumentLocale(locale);
};
// This is a translation component that wraps the translation in a span with a data
// attribute so devs can inspect the element to see the translation ID
const T = ({
id,
data,
tData,
id,
data,
tData,
}: {
id: string;
data?: Record<string, string | number | undefined>;
tData?: Record<string, string>;
id: string;
data?: Record<string, string | number | undefined>;
tData?: Record<string, string>;
}) => {
const translatedData: Record<string, string> = {};
if (tData) {
// iterate over tData and translate each value
Object.entries(tData).forEach(([key, value]) => {
translatedData[key] = intl.formatMessage({ id: value });
});
}
return (
<span data-translation-id={id}>
{intl.formatMessage(
{ id },
{
...data,
...translatedData,
},
)}
</span>
);
const translatedData: Record<string, string> = {};
if (tData) {
// iterate over tData and translate each value
Object.entries(tData).forEach(([key, value]) => {
translatedData[key] = intl.formatMessage({ id: value });
});
}
return (
<span data-translation-id={id}>
{intl.formatMessage(
{ id },
{
...data,
...translatedData,
},
)}
</span>
);
};
//console.log("L:", localeOptions);
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
export { localeOptions, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };

Some files were not shown because too many files have changed in this diff Show More