Merge branch 'develop' into develop

This commit is contained in:
Dimas Rakha Wisnu
2026-09-23 18:53:06 +07:00
committed by GitHub
145 changed files with 7471 additions and 2668 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");
+21 -34
View File
@@ -98,7 +98,7 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
},
true // skip masking
true, // skip masking
);
// Audit log
@@ -212,10 +212,10 @@ 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);
}
@@ -272,17 +272,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)
@@ -337,19 +333,13 @@ const internalAccessList = {
// 4. audit log
// 1. update row to be deleted
await accessListModel
.query()
.where("id", row.id)
.patch({
is_deleted: 1,
});
await accessListModel.query().where("id", row.id).patch({
is_deleted: 1,
});
// 2. update any proxy hosts that were using it (ignoring permissions)
if (row.proxy_hosts) {
await proxyHostModel
.query()
.where("access_list_id", "=", row.id)
.patch({ access_list_id: 0 });
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
@@ -426,11 +416,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")
@@ -472,10 +458,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);
@@ -537,20 +520,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);
}
@@ -572,7 +559,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;
+20 -6
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import net from "node:net";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import _ from "lodash";
@@ -34,7 +35,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 +84,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);
});
});
})
@@ -217,10 +220,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);
@@ -375,8 +389,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();
+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);
}
+1 -5
View File
@@ -24,21 +24,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;
};
+1 -1
View File
@@ -1,4 +1,4 @@
import Ajv from 'ajv/dist/2020.js';
import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import commonDefinitions from "../../schema/common.json" with { type: "json" };
import errs from "../error.js";
+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 };
+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";
}
}
+13 -13
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",
"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}`);
+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
}
}
}
@@ -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);
+28 -40
View File
@@ -11,7 +11,7 @@ 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
@@ -45,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,
@@ -78,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");
}
};
@@ -104,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) {
@@ -119,21 +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}`;
if (typeof certificate.meta.dns_provider_credentials === "string") {
promises.push(fs.mkdir("/etc/letsencrypt/credentials", { recursive: true })
.then(() => fs.writeFile(credentials_loc, certificate.meta.dns_provider_credentials, { mode: 0o600, flag: "wx" }))
.catch((err) => { if (err.code !== "EEXIST") throw err; }));
}
// 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(", ")}`);
}
}
+411 -358
View File
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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
+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 = [/^.*$/];
+29 -28
View File
@@ -16,51 +16,52 @@
"test": "vitest"
},
"dependencies": {
"@tabler/core": "^1.4.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.101.0",
"@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",
"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.4.0",
"react": "^19.2.7",
"query-string": "^9.5.1",
"react": "^19.3.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.7",
"react-intl": "^10.1.11",
"react-dom": "^19.3.0",
"react-intl": "^10.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.16.0",
"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.6",
"@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.16",
"@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",
"sass": "^1.104.1",
"tmp": "^0.2.7",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vite-plugin-checker": "^0.14.1",
"vitest": "^4.1.8"
"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 },
});
}
+2
View File
@@ -26,6 +26,8 @@ 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";
+24
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;
+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
@@ -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;
}
+269 -88
View File
@@ -1,21 +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: "",
@@ -26,24 +49,36 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
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 handleAccessListChange = (idx: number, accessListId: number) => {
@@ -57,7 +92,25 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
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}>
@@ -69,85 +122,213 @@ 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">
<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("")}
>
<IconX size={16} />
</button>
) : null}
</div>
)}
<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="btn p-0"
title="Advanced"
onClick={() => toggleAdvVisible(idx)}
className={styles.toggle}
aria-expanded={isOpen}
aria-controls={bodyId}
onClick={() => toggleExpanded(row.id)}
>
<IconSettings size={20} />
{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>
</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>
{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>
{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>
<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>
<AccessField
name={`locations[${idx}].accessListId`}
@@ -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";
+2
View File
@@ -10,6 +10,8 @@ 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";
+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 };
+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 };
+15
View File
@@ -92,8 +92,23 @@ describe("getFlagCodeForLocale", () => {
expect(getFlagCodeForLocale("ga-IE")).toBe("IE");
});
it("returns EE (Estonia) for Estonian locale, not ET (Ethiopia)", () => {
expect(getFlagCodeForLocale("et")).toBe("EE");
expect(getFlagCodeForLocale("et-EE")).toBe("EE");
});
it("falls back to EN when no locale is provided", () => {
expect(getFlagCodeForLocale()).toBe("EN");
expect(getFlagCodeForLocale(undefined)).toBe("EN");
});
});
describe("formatDateTime Estonian locale", () => {
it("uses 24-hour clock for et and et-EE", () => {
const value = "2024-06-15T15:30:00.000Z";
for (const locale of ["et", "et-EE"]) {
const text = formatDateTime(value, locale);
expect(text.toLowerCase()).not.toMatch(/\b(am|pm)\b/);
}
});
});
+14 -8
View File
@@ -1,9 +1,4 @@
import {
fromUnixTime,
type IntlFormatFormatOptions,
intlFormat,
parseISO,
} from "date-fns";
import { fromUnixTime, type IntlFormatFormatOptions, intlFormat, parseISO } from "date-fns";
const isUnixTimestamp = (value: unknown): boolean => {
if (typeof value !== "number" && typeof value !== "string") return false;
@@ -25,18 +20,29 @@ const parseDate = (value: string | number): Date | null => {
}
};
const DATE_LOCALE_BY_LANG: Record<string, string> = {
et: "et-EE",
};
const HOUR_CYCLE_BY_LANG: Record<string, "h12" | "h23"> = {
et: "h23",
};
const formatDateTime = (value: string | number, locale = "en-US"): string => {
const d = parseDate(value);
if (!d) return `${value}`;
const lang = (locale || "en").slice(0, 2);
const resolvedLocale = DATE_LOCALE_BY_LANG[lang] || locale;
const hourCycle = HOUR_CYCLE_BY_LANG[lang] || "h12";
try {
return intlFormat(
d,
{
dateStyle: "medium",
timeStyle: "medium",
hourCycle: "h12",
hourCycle,
} as IntlFormatFormatOptions,
{ locale },
{ locale: resolvedLocale },
);
} catch {
return `${value}`;
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import en from "./src/en.json";
import et from "./src/et.json";
const english = en as Record<string, { defaultMessage: string }>;
const estonian = et as Record<string, { defaultMessage: string }>;
// Brand names, codes and ICU-only strings can stay the same as English.
const allowedIdentical = new Set([
"certificates.key-type-ecdsa",
"certificates.key-type-rsa",
"column.ssl",
"lets-encrypt",
"object.actions-title",
"streams.tcp",
"streams.udp",
]);
describe("Estonian UI locale", () => {
it("covers every English UI key", () => {
const missing = Object.keys(english).filter((key) => !(key in estonian));
expect(missing).toEqual([]);
});
it("does not add keys that are missing from English", () => {
const extra = Object.keys(estonian).filter((key) => !(key in english));
expect(extra).toEqual([]);
});
it("is translated, not an English copy", () => {
const identical = Object.keys(english).filter(
(key) => estonian[key]?.defaultMessage === english[key]?.defaultMessage,
);
const unexpected = identical.filter((key) => !allowedIdentical.has(key));
expect(unexpected).toEqual([]);
});
it("keeps ICU data placeholders from English strings", () => {
const argNames = [
"count",
"object",
"objects",
"id",
"date",
"users",
"rules",
"max",
"min",
"domain",
"code",
"latestVersion",
"name",
];
const usedArgs = (message: string) => argNames.filter((name) => message.includes(`{${name}`));
const broken: string[] = [];
for (const key of Object.keys(english)) {
const expected = usedArgs(english[key].defaultMessage).join(",");
const actual = usedArgs(estonian[key]?.defaultMessage ?? "").join(",");
if (expected !== actual) {
broken.push(`${key}: expected {${expected}} got {${actual}}`);
}
}
expect(broken).toEqual([]);
});
});
@@ -0,0 +1,7 @@
## Giriş siyahısı nədir?
Giriş siyahıları müəyyən müştəri IP ünvanları üçün qara və ya ağ siyahı təqdim edir və Əsas HTTP Doğrulaması vasitəsilə Proksi Hostlar üçün autentifikasiya təmin edir.
Tək bir Giriş siyahısı üçün bir neçə müştəri qaydası, istifadəçi adı və parol konfiqurasiya edə, sonra onu bir və ya bir neçə _Proksi Host_-a tətbiq edə bilərsiniz.
Bu, öz daxili doğrulama mexanizmi olmayan yönləndirilən veb xidmətlər üçün və ya naməlum müştərilərdən qorunmaq istədiyiniz hallarda ən faydalıdır.
@@ -0,0 +1,28 @@
## Sertifikatlar üzrə kömək
### HTTP Sertifikatı
HTTP ilə doğrulanmış sertifikat o deməkdir ki, Let's Encrypt serverləri domenlərinizə
HTTP (HTTPS deyil!) üzərindən çatmağa cəhd edəcək və uğurlu olarsa sertifikatınızı verəcək.
Bu üsul üçün domen(lər)iniz üçün HTTP ilə əlçatan olan və bu Nginx quraşdırmasına yönləndirilmiş bir
_Proksi Host_ yaratmalısınız. Sertifikat verildikdən sonra, _Proksi Host_-u HTTPS bağlantıları
üçün də bu sertifikatı istifadə etmək üzrə dəyişə bilərsiniz. Bununla belə, sertifikatın yenilənə bilməsi
üçün _Proksi Host_ hələ də HTTP girişi üçün konfiqurasiya edilmiş qalmalıdır.
Bu proses wildcard domenləri dəstəkləmir.
### DNS Sertifikatı
DNS ilə doğrulanmış sertifikat DNS Provayder plaginindən istifadə etməyinizi tələb edir. Bu DNS
Provayderi domeninizdə müvəqqəti qeydlər yaratmaq üçün istifadə olunacaq, sonra Let's
Encrypt sizin sahibi olduğunuzu təsdiqləmək üçün həmin qeydləri sorğulayacaq və uğurlu olarsa sertifikatınızı verəcək.
Bu tip sertifikatı sorğulamazdan əvvəl _Proksi Host_ yaratmağınıza ehtiyac yoxdur. Həmçinin
_Proksi Host_-unuzun HTTP girişi üçün konfiqurasiya edilməsinə də ehtiyac yoxdur.
Bu proses wildcard domenləri dəstəkləyir.
### Fərdi sertifikat
Öz Sertifikat Səlahiyyətinizin verdiyi öz SSL Sertifikatınızı yükləmək üçün bu seçimdən istifadə edin.
@@ -0,0 +1,10 @@
## 404 Host nədir?
404 Host, sadəcə 404 səhifəsi göstərən bir host quraşdırmasıdır.
Bu, domeniniz axtarış sistemlərində siyahıya alındıqda və daha səliqəli bir xəta səhifəsi
göstərmək istədiyiniz zaman, ya da xüsusi olaraq axtarış indeksatorlarına domen səhifələrinin
artıq mövcud olmadığını bildirmək istədiyiniz zaman faydalı ola bilər.
Bu hostu saxlamağın başqa bir faydası isə ona gələn müraciətlərin jurnallarını izləmək
və istinad edən mənbələrə baxmaqdır.
@@ -0,0 +1,7 @@
## Proksi Host nədir?
Proksi Host, yönləndirmək istədiyiniz veb xidmət üçün daxil olan giriş nöqtəsidir.
O, öz daxili SSL dəstəyi olmaya bilən xidmətiniz üçün istəyə bağlı SSL sonlanması təmin edir.
Proksi Hostlar Nginx Proxy Manager-in ən çox istifadə olunan funksiyasıdır.
@@ -0,0 +1,8 @@
## Yönləndirmə Hostu nədir?
Yönləndirmə Hostu daxil olan domendən gələn sorğuları yönləndirəcək və izləyicini
başqa bir domenə göndərəcək.
Bu tip hostdan istifadə etməyin ən çox rast gəlinən səbəbi veb saytınızın domeni
dəyişəndə, lakin axtarış sistemi və ya istinad edən linklərin hələ də köhnə domenə
işarə etdiyi hallardır.
@@ -0,0 +1,6 @@
## Axın (Stream) nədir?
Nginx üçün nisbətən yeni bir funksiya olan Axın, TCP/UDP trafikini şəbəkədəki başqa
bir kompüterə birbaşa yönləndirməyə xidmət edir.
Oyun serverləri, FTP və ya SSH serverləri işlədirsinizsə, bu funksiya faydalı ola bilər.
@@ -0,0 +1,6 @@
export * as AccessLists from "./AccessLists.md";
export * as Certificates from "./Certificates.md";
export * as DeadHosts from "./DeadHosts.md";
export * as ProxyHosts from "./ProxyHosts.md";
export * as RedirectionHosts from "./RedirectionHosts.md";
export * as Streams from "./Streams.md";
@@ -1,7 +1,7 @@
## Mis on juurdepääsuloend?
Ligipääsuloendid pakuvad konkreetsete klientide IP-aadresside musta või valget nimekirja koos puhverserverite autentimisega põhilise HTTP-autentimise kaudu.
Juurdepääsuloendiga saad lubada või keelata klientide IP-aadresse ning panna puhverserverile ette HTTP basic-autentimise.
Saate ühe juurdepääsuloendi jaoks konfigureerida mitu kliendireeglit, kasutajanime ja parooli ning seejärel rakendada neid ühele või mitmele _puhverserverile_.
Ühte loendisse saab panna mitu reeglit, kasutajanime ja parooli. Sama loendi saad kinnitada ühele või mitmele puhverserverile.
See on kõige kasulikum edastatud veebiteenuste puhul, millel pole sisseehitatud autentimismehhanisme või kui soovite kaitsta tundmatute klientide eest.
Sellest on kasu siis, kui edastataval teenusel endal sisselogimist pole või tahad võõrad kliendid eemal hoida.
@@ -2,25 +2,20 @@
### HTTP-sertifikaat
HTTP-valideeritud sertifikaat tähendab, et Let's Encrypti serverid
HTTP-ga kinnitatud sertifikaadi puhul üritavad Let's Encrypti serverid su domeenidele ligi saada HTTP kaudu (mitte HTTPS!) ja õnnestumise korral väljastavad sertifikaadi.
proovivad teie domeenidega ühendust luua HTTP (mitte HTTPS!) kaudu ja kui see õnnestub,
väljastavad nad teile sertifikaadi.
Selleks peab olema loodud puhverserver, mis on HTTP kaudu kättesaadav ja osutab sellele Nginxile. Kui sertifikaat on käes, võid puhverserveri panna HTTPS-i ka kasutama. Uuendamiseks peab HTTP-ligipääs siiski alles jääma.
Selle meetodi jaoks peate oma domeeni(de) jaoks looma _Proxy Host_, millele pääseb ligi HTTP kaudu ja mis osutab sellele Nginxi installile. Pärast sertifikaadi väljastamist saate muuta _Proxy Host_'i, et seda sertifikaati ka HTTPS
ühenduste jaoks kasutada. Sertifikaadi uuendamiseks tuleb aga _Proxy Host_ ikkagi HTTP-juurdepääsu jaoks konfigureerida.
See protsess _ei_ toeta metamärke kasutavaid domeene.
Metamärgid (wildcard) selle meetodiga ei tööta.
### DNS-sertifikaat
DNS-i poolt valideeritud sertifikaadi saamiseks peate kasutama DNS-pakkuja pistikprogrammi. Seda DNS-teenuse pakkujat kasutatakse teie domeenis ajutiste kirjete loomiseks ja seejärel pärib Let's
Encrypt nende kirjete kohta päringu, et veenduda, et olete omanik, ja kui see õnnestub, väljastavad nad teile sertifikaadi.
DNS-iga kinnitatud sertifikaat vajab DNS-teenuse pakkuja pluginat. Plugin loob domeenile ajutised kirjed, Let's Encrypt kontrollib need üle ja õnnestumise korral väljastab sertifikaadi.
Selle tüüpi sertifikaadi taotlemiseks ei ole vaja luua _Proxy Host_'i. Samuti ei pea teie _Proxy Host_ olema HTTP-juurdepääsu jaoks konfigureeritud.
Puhverserverit enne taotlust looma ei pea. HTTP-ligipääsu ka ei nõuta.
See protsess _toetab_ metamärke kasutavaid domeene.
See meetod toetab metamärke.
### Kohandatud sertifikaat
Kasutage seda valikut oma SSL-sertifikaadi üleslaadimiseks, mille on esitanud teie enda sertifitseerimisasutus.
Siia saad üles laadida oma SSL-sertifikaadi, mille on väljastanud sinu sertifitseerija.
@@ -1,9 +1,7 @@
## Mis on 404 host?
## Mis on 404-host?
404 host on lihtsalt hosti seadistus, mis kuvab 404 lehte.
404-host on lihtsalt host, mis näitab 404 lehte.
See võib olla kasulik, kui teie domeen on otsingumootorites loetletud ja soovite
esitada kenama vealehe või konkreetselt otsingu indekseerijatele öelda, et
domeenilehed enam ei eksisteeri.
Sellest on abi, kui domeen on otsingumootorites kirjas ja tahad näidata kenamat vealehte või anda indekseerijatele märku, et lehti enam pole.
Selle hosti teine eelis on selle külastatavuste logide jälgimine ja suunajate vaatamine.
Lisaks näed logist, kes sinna sattus ja kust nad tulid.
@@ -1,7 +1,7 @@
## Mis on puhverserver?
Puhverserver on veebiteenuse sissetuleva andmevoo lõpp-punkt, mida soovite edastada.
Puhverserver on veebiteenuse sisend, mille suunad edasi oma tegelikule teenusele.
See pakub valikulist SSL-i lõpetamist teie teenusele, millel ei pruugi olla sisseehitatud SSL-tuge.
Soovi korral lõpetab see SSL-i sinu eest — isegi kui teenusel endal SSL-tuge pole.
Puhverserverid on Nginxi puhverserveri halduri kõige levinum kasutusala.
Puhverserverid on Nginx Proxy Manageri kõige tavalisem kasutus.
@@ -1,5 +1,5 @@
## Mis on ümbersuunamishost?
Ümbersuunamishost suunab sissetuleva domeeni päringud ümber ja suunab vaataja teisele domeenile.
Ümbersuunamishost võtab päringud ühelt domeenilt ja saadab külastaja teisele.
Kõige levinum põhjus seda tüüpi hosti kasutamiseks on see, kui teie veebisaidi domeenid muutuvad, kuid otsingumootori või suunaja lingid osutavad endiselt vanale domeenile.
Kõige sagedamini on seda vaja siis, kui sait kolis uuele domeenile, aga vanad lingid otsingust või viidetest käivad veel vana aadressi pihta.
@@ -1,5 +1,5 @@
## Mis on voog?
Nginxi suhteliselt uus funktsioon, voog, edastab TCP/UDP liiklust otse võrgus olevale teisele arvutile.
Voog on Nginxi suhteliselt uus võimalus suunata TCP/UDP liiklus otse teisele masinale võrgus.
Kui sul on mänguserverid, FTP- või SSH-serverid, võib see kasuks tulla.
Kasulik näiteks mängu-, FTP- või SSH-serverite puhul.
@@ -0,0 +1,7 @@
## فهرست دسترسی چیست؟
فهرست دسترسی (Access List) به شما اجازه می‌دهد دسترسی آدرس‌های IP مشخص را مجاز یا مسدود کنید و در صورت نیاز با استفاده از Basic HTTP Authentication از Proxy Hostها با نام کاربری و رمز عبور محافظت کنید.
در هر فهرست دسترسی می‌توانید چندین قانون برای کلاینت‌ها و چندین نام کاربری و رمز عبور تعریف کنید و سپس همان فهرست را به یک یا چند _میزبان پراکسی (Proxy Host)_ اختصاص دهید.
این قابلیت برای سرویس‌های وبی که خودشان مکانیزم احراز هویت ندارند، یا زمانی که می‌خواهید دسترسی کلاینت‌های ناشناس را محدود کنید، بسیار کاربردی است.
@@ -0,0 +1,21 @@
## راهنمای گواهی‌ها
### گواهی HTTP
در اعتبارسنجی از طریق HTTP، سرورهای Let's Encrypt تلاش می‌کنند از طریق HTTP (نه HTTPS) به دامنهٔ شما دسترسی پیدا کنند. اگر اعتبارسنجی موفق باشد، گواهی صادر می‌شود.
برای این روش باید از قبل یک _میزبان پراکسی (Proxy Host)_ برای دامنهٔ موردنظر ایجاد کرده باشید که از طریق HTTP در دسترس باشد و به همین نصب Nginx Proxy Manager اشاره کند. پس از صدور گواهی می‌توانید Proxy Host را طوری تنظیم کنید که برای اتصال‌های HTTPS نیز از آن استفاده کند. با این حال، برای تمدید خودکار گواهی، دسترسی HTTP باید همچنان برقرار بماند.
این روش از دامنه‌های Wildcard پشتیبانی _نمی‌کند_.
### گواهی DNS
در اعتبارسنجی از طریق DNS باید از افزونهٔ ارائه‌دهندهٔ DNS (DNS Provider) استفاده کنید. افزونه به‌طور موقت رکوردهایی در DNS دامنه ایجاد می‌کند و Let's Encrypt با بررسی آن‌ها مالکیت دامنه را تأیید می‌کند. در صورت موفقیت، گواهی صادر می‌شود.
برای درخواست این نوع گواهی لازم نیست از قبل Proxy Host ساخته باشید و Proxy Host نیز نیازی به دسترسی HTTP ندارد.
این روش از دامنه‌های Wildcard پشتیبانی _می‌کند_.
### گواهی سفارشی
از این گزینه برای بارگذاری گواهی SSL/TLS خود که توسط مرجع صدور گواهی (CA) موردنظر شما صادر شده است استفاده کنید.
@@ -0,0 +1,7 @@
## میزبان 404 چیست؟
میزبان 404 (404 Host) میزبانی است که برای درخواست‌های ورودی یک صفحهٔ 404 نمایش می‌دهد.
این قابلیت زمانی مفید است که دامنهٔ شما هنوز در موتورهای جست‌وجو فهرست شده است و می‌خواهید صفحهٔ خطای مناسب‌تری نمایش دهید، یا به خزنده‌های موتورهای جست‌وجو اعلام کنید که صفحات آن دامنه دیگر وجود ندارند.
همچنین می‌توانید لاگ درخواست‌های ورودی به این Host و آدرس‌های ارجاع‌دهنده (Referrer) را بررسی کنید.
@@ -0,0 +1,7 @@
## میزبان پراکسی چیست؟
میزبان پراکسی (Proxy Host) نقطهٔ ورودی برای یک سرویس وب است که می‌خواهید درخواست‌ها را به آن فوروارد کنید.
Proxy Host می‌تواند SSL/TLS Termination را نیز انجام دهد؛ بنابراین حتی اگر سرویس مقصد خودش SSL نداشته باشد، می‌توانید دسترسی HTTPS را در Nginx Proxy Manager فراهم کنید.
Proxy Hostها رایج‌ترین قابلیت مورد استفاده در Nginx Proxy Manager هستند.
@@ -0,0 +1,5 @@
## میزبان تغییر مسیر چیست؟
میزبان تغییر مسیر (Redirection Host) درخواست‌های دامنهٔ ورودی را به دامنهٔ دیگری Redirect می‌کند.
کاربرد رایج آن زمانی است که دامنهٔ وب‌سایت تغییر کرده، اما نتایج موتورهای جست‌وجو یا لینک‌های ارجاع‌دهنده هنوز به دامنهٔ قدیمی اشاره می‌کنند.

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