mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 11:10:18 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cfd3395cf | ||
|
|
c41ec008bb | ||
|
|
900bb1e480 | ||
|
|
633b653f79 | ||
|
|
58566c9d75 | ||
|
|
c53f52ab51 | ||
|
|
9445038b36 | ||
|
|
b1e0473a19 | ||
|
|
95f1b74757 | ||
|
|
8509e1b378 | ||
|
|
5f35e4916f | ||
|
|
da12ee5b0c | ||
|
|
61af8e23d8 | ||
|
|
c4e421e679 | ||
|
|
1e18bf9212 | ||
|
|
af1d4dd60f | ||
|
|
f47d85da9e | ||
|
|
ee33ecdfc2 | ||
|
|
f48c3e583a | ||
|
|
a1713b79e8 | ||
|
|
4282d6c6e9 | ||
|
|
50144205a0 | ||
|
|
73f784ea95 | ||
|
|
c8fe9abe38 | ||
|
|
1cb352847a | ||
|
|
d11e0f6c51 | ||
|
|
9ad799c13f | ||
|
|
5d0be3a5d5 | ||
|
|
735149210e | ||
|
|
dba90c36be | ||
|
|
0e152e8afe | ||
|
|
f2b1711549 | ||
|
|
568c105945 | ||
|
|
3105a23834 | ||
|
|
803f4968a2 | ||
|
|
d8488048ca | ||
|
|
0596581ec9 | ||
|
|
58605cbc70 | ||
|
|
280fcde528 | ||
|
|
c70fcb9f7c | ||
|
|
f477946bd9 | ||
|
|
18f1fe22b1 | ||
|
|
9fc56a2209 | ||
|
|
37b19d892c | ||
|
|
17f126482f | ||
|
|
81ec7e8d6a | ||
|
|
dd45ad8f51 | ||
|
|
33250a0e38 | ||
|
|
2070fd186a | ||
|
|
e4585ac688 | ||
|
|
1ffe3609f4 | ||
|
|
24b072a120 | ||
|
|
e482522792 | ||
|
|
23b4e7d6f7 | ||
|
|
a2d427902a | ||
|
|
05b867c21d | ||
|
|
ebbd262e06 | ||
|
|
5e6c53f0e2 | ||
|
|
baa5683bba | ||
|
|
386a166025 | ||
|
|
f7a696cf08 | ||
|
|
ec65105db2 | ||
|
|
aa211ea423 | ||
|
|
cd4448ebef | ||
|
|
e2546bfb51 | ||
|
|
c19182d978 | ||
|
|
1cbfe370ec | ||
|
|
6c2a57ad83 | ||
|
|
210366cca0 | ||
|
|
b75b0adaf2 | ||
|
|
918728fcac | ||
|
|
0453ddca35 | ||
|
|
a570c0e503 | ||
|
|
6383017b4c | ||
|
|
3f2cd26913 | ||
|
|
ece5dea0ad | ||
|
|
a75eb467ca | ||
|
|
708ea5cfbb | ||
|
|
d77d5c643d | ||
|
|
934a3fafe5 | ||
|
|
08b4bbdbd4 | ||
|
|
a7677c18c3 | ||
|
|
1d894f8ed1 | ||
|
|
7cde02c446 | ||
|
|
190b7a606d | ||
|
|
61e74cbb7f | ||
|
|
ba8117fbcf | ||
|
|
5e7fea7d56 | ||
|
|
4f1d309a90 | ||
|
|
58ff491dc0 | ||
|
|
85ee5e2200 | ||
|
|
09300941af | ||
|
|
c4fa8daa87 | ||
|
|
3d800c7138 | ||
|
|
2ed0c47de8 | ||
|
|
4d30946d2f | ||
|
|
a5e49a53c0 | ||
|
|
41539c027f | ||
|
|
046d3e022f | ||
|
|
c98b1685bd | ||
|
|
b7772016ba | ||
|
|
29687987b2 | ||
|
|
ab893706da | ||
|
|
246c54503e | ||
|
|
4ecb047299 | ||
|
|
4e2b052b50 | ||
|
|
ce4e35a3d4 | ||
|
|
4cbeecaad2 | ||
|
|
fc8b1eb1fb | ||
|
|
d64d9a8ab2 | ||
|
|
70f00d60be | ||
|
|
f43ef16fb5 | ||
|
|
35ad8227d7 | ||
|
|
4a0f212289 | ||
|
|
ab96d78f4d | ||
|
|
a42614094f | ||
|
|
2cd346d054 | ||
|
|
20b8068aaa | ||
|
|
9aa7fd5722 | ||
|
|
553f879c06 | ||
|
|
cbf2782571 | ||
|
|
a62c2a6dc3 | ||
|
|
a5db5ed156 | ||
|
|
101595b90b | ||
|
|
45a3659ffd | ||
|
|
a457fe4d96 | ||
|
|
33ef7c859e | ||
|
|
bfa2dca1b1 | ||
|
|
32fc268edf | ||
|
|
300d0653e0 | ||
|
|
06f060dc0f | ||
|
|
7e447acfcc | ||
|
|
acd26d7adb | ||
|
|
1f5a56d0bd | ||
|
|
258b522725 | ||
|
|
179c27e382 | ||
|
|
94176d2f9c | ||
|
|
6311329772 | ||
|
|
a08f821c54 | ||
|
|
fc4a409292 | ||
|
|
62ed330053 | ||
|
|
6629abed32 | ||
|
|
46bf3274d8 | ||
|
|
18fc3fe770 | ||
|
|
e72dfc3bfd |
@@ -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,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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -161,12 +161,12 @@ const internal2fa = {
|
||||
}
|
||||
|
||||
const result = await verify({
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
guardrails: createGuardrails({
|
||||
MIN_SECRET_BYTES: 10,
|
||||
}),
|
||||
});
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
guardrails: createGuardrails({
|
||||
MIN_SECRET_BYTES: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
throw new errs.AuthError("Invalid verification code");
|
||||
@@ -288,11 +288,7 @@ const internal2fa = {
|
||||
},
|
||||
|
||||
getUserPasswordAuth: async (userId) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.andWhere("type", "password")
|
||||
.first();
|
||||
const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first();
|
||||
|
||||
if (!auth) {
|
||||
throw new errs.ItemNotFoundError("Auth not found");
|
||||
|
||||
+101
-43
@@ -2,7 +2,9 @@ import fs from "node:fs";
|
||||
import batchflow from "batchflow";
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { isMysql, isPostgres } from "../lib/config.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import db from "../db.js";
|
||||
import { access as logger } from "../logger.js";
|
||||
import accessListModel from "../models/access_list.js";
|
||||
import accessListAuthModel from "../models/access_list_auth.js";
|
||||
@@ -15,6 +17,38 @@ const omissions = () => {
|
||||
return ["is_deleted"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find proxy hosts that reference an access list in their locations JSON.
|
||||
*
|
||||
* @param {Integer} accessListId
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
|
||||
let result;
|
||||
if (isMysql()) {
|
||||
const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
|
||||
result = await db().raw(
|
||||
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)",
|
||||
[searchObj, "$"],
|
||||
);
|
||||
} else if (isPostgres()) {
|
||||
result = await db().raw(
|
||||
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb",
|
||||
[JSON.stringify([{ access_list_id: accessListId }])],
|
||||
);
|
||||
} else {
|
||||
result = await db().raw(
|
||||
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?",
|
||||
[`%"access_list_id":${accessListId}%`],
|
||||
);
|
||||
}
|
||||
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
|
||||
if (!Array.isArray(result)) {
|
||||
return result?.rows || [];
|
||||
}
|
||||
return (Array.isArray(result[0]) ? result[0] : result) || [];
|
||||
};
|
||||
|
||||
const internalAccessList = {
|
||||
/**
|
||||
* @param {Access} access
|
||||
@@ -66,7 +100,7 @@ const internalAccessList = {
|
||||
id: data.id,
|
||||
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
|
||||
},
|
||||
true // skip masking
|
||||
true, // skip masking
|
||||
);
|
||||
|
||||
// Audit log
|
||||
@@ -180,13 +214,31 @@ const internalAccessList = {
|
||||
id: data.id,
|
||||
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
|
||||
},
|
||||
true // skip masking
|
||||
true, // skip masking
|
||||
);
|
||||
|
||||
await internalAccessList.build(freshRow)
|
||||
await internalAccessList.build(freshRow);
|
||||
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
|
||||
}
|
||||
|
||||
// Also regenerate configs for proxy hosts that reference this access list in their locations
|
||||
const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
|
||||
if (locationHostRows?.length) {
|
||||
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
|
||||
// Exclude hosts already regenerated above
|
||||
return !freshRow.proxy_hosts?.find((h) => h.id === id);
|
||||
});
|
||||
if (locationHostIds.length) {
|
||||
const locationHosts = await proxyHostModel.query()
|
||||
.where("is_deleted", 0)
|
||||
.whereIn("id", locationHostIds)
|
||||
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
|
||||
}
|
||||
}
|
||||
|
||||
await internalNginx.reload();
|
||||
return internalAccessList.maskItems(freshRow);
|
||||
},
|
||||
@@ -202,17 +254,13 @@ const internalAccessList = {
|
||||
*/
|
||||
get: async (access, data, skipMasking) => {
|
||||
const thisData = data || {};
|
||||
const accessData = await access.can("access_lists:get", thisData.id)
|
||||
const accessData = await access.can("access_lists:get", thisData.id);
|
||||
|
||||
const query = accessListModel
|
||||
.query()
|
||||
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
|
||||
.leftJoin("proxy_host", function () {
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
|
||||
"proxy_host.is_deleted",
|
||||
"=",
|
||||
0,
|
||||
);
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
|
||||
})
|
||||
.where("access_list.is_deleted", 0)
|
||||
.andWhere("access_list.id", thisData.id)
|
||||
@@ -267,28 +315,41 @@ const internalAccessList = {
|
||||
// 4. audit log
|
||||
|
||||
// 1. update row to be deleted
|
||||
await accessListModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
await accessListModel.query().where("id", row.id).patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
|
||||
// 2. update any proxy hosts that were using it (ignoring permissions)
|
||||
if (row.proxy_hosts) {
|
||||
await proxyHostModel
|
||||
const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
|
||||
if (affectedHostIds.size) {
|
||||
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
|
||||
}
|
||||
|
||||
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
|
||||
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
|
||||
for (const { id: hostId } of locationHostRows) {
|
||||
const host = await proxyHostModel.query().where("id", hostId).first();
|
||||
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
|
||||
const updatedLocations = host.locations.map((loc) => {
|
||||
if (loc.access_list_id === row.id) {
|
||||
return { ...loc, access_list_id: 0 };
|
||||
}
|
||||
return loc;
|
||||
});
|
||||
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
|
||||
affectedHostIds.add(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. reconfigure those hosts from fresh rows, then reload nginx
|
||||
if (affectedHostIds.size) {
|
||||
const affectedHosts = await proxyHostModel
|
||||
.query()
|
||||
.where("access_list_id", "=", row.id)
|
||||
.patch({ access_list_id: 0 });
|
||||
|
||||
// 3. reconfigure those hosts, then reload nginx
|
||||
// set the access_list_id to zero for these items
|
||||
row.proxy_hosts.map((_val, idx) => {
|
||||
row.proxy_hosts[idx].access_list_id = 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
|
||||
.where("is_deleted", 0)
|
||||
.whereIn("id", [...affectedHostIds])
|
||||
.allowGraph(proxyHostModel.defaultAllowGraph)
|
||||
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
|
||||
}
|
||||
|
||||
await internalNginx.reload();
|
||||
@@ -325,11 +386,7 @@ const internalAccessList = {
|
||||
.query()
|
||||
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
|
||||
.leftJoin("proxy_host", function () {
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
|
||||
"proxy_host.is_deleted",
|
||||
"=",
|
||||
0,
|
||||
);
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
|
||||
})
|
||||
.where("access_list.is_deleted", 0)
|
||||
.groupBy("access_list.id")
|
||||
@@ -371,10 +428,7 @@ const internalAccessList = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getCount: async (userId, visibility) => {
|
||||
const query = accessListModel
|
||||
.query()
|
||||
.count("id as count")
|
||||
.where("is_deleted", 0);
|
||||
const query = accessListModel.query().count("id as count").where("is_deleted", 0);
|
||||
|
||||
if (visibility !== "all") {
|
||||
query.andWhere("owner_user_id", userId);
|
||||
@@ -436,20 +490,24 @@ const internalAccessList = {
|
||||
}
|
||||
|
||||
// 2. create empty access file
|
||||
fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
|
||||
fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
|
||||
|
||||
// 3. generate password for each user
|
||||
if (list.items.length) {
|
||||
await new Promise((resolve, reject) => {
|
||||
batchflow(list.items).sequential()
|
||||
batchflow(list.items)
|
||||
.sequential()
|
||||
.each((_i, item, next) => {
|
||||
if (item.password?.length) {
|
||||
logger.info(`Adding: ${item.username}`);
|
||||
|
||||
utils.execFile('openssl', ['passwd', '-apr1', item.password])
|
||||
utils
|
||||
.execFile("openssl", ["passwd", "-apr1", item.password])
|
||||
.then((res) => {
|
||||
try {
|
||||
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'});
|
||||
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
@@ -471,7 +529,7 @@ const internalAccessList = {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default internalAccessList;
|
||||
|
||||
@@ -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(", ")}]`);
|
||||
|
||||
@@ -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, () => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -1,10 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import { debug, nginx as logger } from "../logger.js";
|
||||
import accessListModel from "../models/access_list.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -34,7 +36,7 @@ const internalNginx = {
|
||||
// We're deleting this config regardless.
|
||||
// Don't throw errors, as the file may not exist at all
|
||||
// Delete the .err file too
|
||||
return internalNginx.deleteConfig(host_type, host, false, true);
|
||||
return internalNginx.deleteConfig(host_type, host, true);
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.generateConfig(host_type, host);
|
||||
@@ -83,10 +85,12 @@ const internalNginx = {
|
||||
meta: combined_meta,
|
||||
})
|
||||
.then(() => {
|
||||
internalNginx.renameConfigAsError(host_type, host);
|
||||
// Keep the failed config as a .err file for inspection
|
||||
return internalNginx.renameConfigAsError(host_type, host);
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.deleteConfig(host_type, host, true);
|
||||
// The rename removed the live config already, don't touch the .err file
|
||||
return internalNginx.deleteConfig(host_type, host, false);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -165,6 +169,24 @@ const internalNginx = {
|
||||
host.locations[i],
|
||||
);
|
||||
|
||||
// A location with its own access list overrides the host's,
|
||||
// otherwise it inherits the host's access list
|
||||
let locationAccessList = null;
|
||||
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
|
||||
locationAccessList = await accessListModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", locationCopy.access_list_id)
|
||||
.withGraphFetched("[clients,items]")
|
||||
.first();
|
||||
}
|
||||
if (locationAccessList) {
|
||||
locationCopy.access_list = locationAccessList;
|
||||
} else {
|
||||
locationCopy.access_list_id = host.access_list_id;
|
||||
locationCopy.access_list = host.access_list;
|
||||
}
|
||||
|
||||
if (locationCopy.forward_host.indexOf("/") > -1) {
|
||||
const splitted = locationCopy.forward_host.split("/");
|
||||
|
||||
@@ -176,7 +198,9 @@ const internalNginx = {
|
||||
}
|
||||
};
|
||||
|
||||
locationRendering().then(() => resolve(renderedLocations));
|
||||
locationRendering()
|
||||
.then(() => resolve(renderedLocations))
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -217,10 +241,21 @@ const internalNginx = {
|
||||
}
|
||||
|
||||
// For redirection hosts, if the scheme is not http or https, set it to $scheme
|
||||
if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
|
||||
if (
|
||||
nice_host_type === "redirection_host" &&
|
||||
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
|
||||
) {
|
||||
host.forward_scheme = "$scheme";
|
||||
}
|
||||
|
||||
// A stream forwarding to an IPv6 literal must have the address wrapped in
|
||||
// square brackets before nginx appends ":<port>". Without the brackets nginx
|
||||
// reads the trailing ":<port>" as part of the address and rejects the upstream
|
||||
// ("invalid port in upstream"), so the stream saves but never activates (#5740).
|
||||
if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) {
|
||||
host.forwarding_host = `[${host.forwarding_host}]`;
|
||||
}
|
||||
|
||||
if (host.locations) {
|
||||
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
|
||||
origLocations = [].concat(host.locations);
|
||||
@@ -257,6 +292,9 @@ const internalNginx = {
|
||||
debug(logger, `Could not write ${filename}:`, err.message);
|
||||
reject(new errs.ConfigurationError(err.message));
|
||||
});
|
||||
}).catch((err) => {
|
||||
debug(logger, `Could not render locations for ${filename}:`, err.message);
|
||||
reject(new errs.ConfigurationError(err.message));
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -375,8 +413,8 @@ const internalNginx = {
|
||||
const config_file_err = `${config_file}.err`;
|
||||
|
||||
return new Promise((resolve /*, reject*/) => {
|
||||
fs.unlink(config_file, () => {
|
||||
// ignore result, continue
|
||||
fs.unlink(config_file_err, () => {
|
||||
// ignore result, a previous .err file may not exist
|
||||
fs.rename(config_file, config_file_err, () => {
|
||||
// also ignore result, as this is a debugging informative file anyway
|
||||
resolve();
|
||||
|
||||
@@ -83,28 +83,28 @@ const internalProxyHost = {
|
||||
expand: ["certificate", "owner", "access_list.[clients,items]"],
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
||||
.then(async (row) => {
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
||||
return row;
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
// Audit log
|
||||
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
|
||||
|
||||
// Add to audit log
|
||||
return internalAuditLog
|
||||
.add(access, {
|
||||
action: "created",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: thisData,
|
||||
})
|
||||
.then(() => {
|
||||
return row;
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
// Audit log
|
||||
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
|
||||
|
||||
// Add to audit log
|
||||
return internalAuditLog
|
||||
.add(access, {
|
||||
action: "created",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: thisData,
|
||||
})
|
||||
.then(() => {
|
||||
return row;
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -202,24 +202,24 @@ const internalProxyHost = {
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return internalProxyHost
|
||||
.get(access, {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate", "access_list.[clients,items]"],
|
||||
})
|
||||
.then((row) => {
|
||||
if (!row.enabled) {
|
||||
// No need to add nginx config if host is disabled
|
||||
return row;
|
||||
}
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
|
||||
row.meta = new_meta;
|
||||
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
|
||||
});
|
||||
.then(() => {
|
||||
return internalProxyHost
|
||||
.get(access, {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate", "access_list.[clients,items]"],
|
||||
})
|
||||
.then(async (row) => {
|
||||
if (!row.enabled) {
|
||||
// No need to add nginx config if host is disabled
|
||||
return row;
|
||||
}
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
|
||||
row.meta = new_meta;
|
||||
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -326,39 +326,36 @@ const internalProxyHost = {
|
||||
expand: ["certificate", "owner", "access_list"],
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
if (row.enabled) {
|
||||
throw new errs.ValidationError("Host is already enabled");
|
||||
}
|
||||
.then(async (row) => {
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
if (row.enabled) {
|
||||
throw new errs.ValidationError("Host is already enabled");
|
||||
}
|
||||
|
||||
row.enabled = 1;
|
||||
row.enabled = 1;
|
||||
|
||||
return proxyHostModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
enabled: 1,
|
||||
})
|
||||
.then(() => {
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||
})
|
||||
.then(() => {
|
||||
// Add to audit log
|
||||
return internalAuditLog.add(access, {
|
||||
action: "enabled",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: _.omit(row, omissions()),
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return true;
|
||||
await proxyHostModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
// Configure nginx
|
||||
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
action: "enabled",
|
||||
object_type: "proxy-host",
|
||||
object_id: row.id,
|
||||
meta: _.omit(row, omissions()),
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
+37
-17
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import _ from "lodash";
|
||||
import _ from "lodash";
|
||||
|
||||
export default (default_sort, default_offset, default_limit, max_limit) => {
|
||||
/**
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import net from "node:net";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import errs from "../error.js";
|
||||
|
||||
@@ -9,6 +10,8 @@ const ajv = new Ajv({
|
||||
coerceTypes: true,
|
||||
});
|
||||
|
||||
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
|
||||
|
||||
/**
|
||||
* @param {Object} schema
|
||||
* @param {Object} payload
|
||||
@@ -24,21 +27,17 @@ const apiValidator = async (schema, payload /*, description*/) => {
|
||||
throw new errs.ValidationError("Payload is undefined");
|
||||
}
|
||||
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const valid = validate(payload);
|
||||
|
||||
|
||||
if (valid && !validate.errors) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const message = ajv.errorsText(validate.errors);
|
||||
const err = new errs.ValidationError(message);
|
||||
err.debug = {validationErrors: validate.errors, payload};
|
||||
err.debug = { validationErrors: validate.errors, payload };
|
||||
throw err;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Ajv from 'ajv/dist/2020.js';
|
||||
import net from "node:net";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import _ from "lodash";
|
||||
import commonDefinitions from "../../schema/common.json" with { type: "json" };
|
||||
import errs from "../error.js";
|
||||
@@ -14,6 +15,8 @@ const ajv = new Ajv({
|
||||
schemas: [commonDefinitions],
|
||||
});
|
||||
|
||||
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} schema
|
||||
|
||||
+111
-11
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import signale from "signale";
|
||||
import { isDebugMode } from "./lib/config.js";
|
||||
|
||||
@@ -5,17 +7,115 @@ const opts = {
|
||||
logLevel: "info",
|
||||
};
|
||||
|
||||
const global = new signale.Signale({ scope: "Global ", ...opts });
|
||||
const migrate = new signale.Signale({ scope: "Migrate ", ...opts });
|
||||
const express = new signale.Signale({ scope: "Express ", ...opts });
|
||||
const access = new signale.Signale({ scope: "Access ", ...opts });
|
||||
const nginx = new signale.Signale({ scope: "Nginx ", ...opts });
|
||||
const ssl = new signale.Signale({ scope: "SSL ", ...opts });
|
||||
const certbot = new signale.Signale({ scope: "Certbot ", ...opts });
|
||||
const importer = new signale.Signale({ scope: "Importer ", ...opts });
|
||||
const setup = new signale.Signale({ scope: "Setup ", ...opts });
|
||||
const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts });
|
||||
const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts });
|
||||
// Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc).
|
||||
// Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...)
|
||||
// are left console-only since they carry no diagnostic value worth persisting.
|
||||
const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"];
|
||||
|
||||
const LOG_FILE = "/data/logs/backend.log";
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk
|
||||
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
let fileStream = null;
|
||||
let lastOpenAttempt = 0;
|
||||
// If the file can't be opened (eg. running outside the standard Docker image, or in
|
||||
// CI without /data), don't retry on every single log call - but do retry periodically
|
||||
// so a transient issue (disk full, permissions fixed later) recovers without a restart.
|
||||
const REOPEN_COOLDOWN_MS = 30 * 1000;
|
||||
|
||||
/**
|
||||
* Lazily opens the backend log file for appending. If the directory isn't writable,
|
||||
* file logging is silently disabled and console logging continues unaffected.
|
||||
*
|
||||
* @returns {import('node:fs').WriteStream|null}
|
||||
*/
|
||||
const getFileStream = () => {
|
||||
if (fileStream) {
|
||||
return fileStream;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) {
|
||||
return null;
|
||||
}
|
||||
lastOpenAttempt = now;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
||||
const stream = fs.createWriteStream(LOG_FILE, { flags: "a" });
|
||||
stream.on("error", () => {
|
||||
fileStream = null;
|
||||
});
|
||||
fileStream = stream;
|
||||
} catch (_err) {
|
||||
fileStream = null;
|
||||
}
|
||||
return fileStream;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats and appends a single log line to the backend log file.
|
||||
* Never throws - a failure here must never take down the application.
|
||||
*
|
||||
* @param {String} level
|
||||
* @param {String} scope
|
||||
* @param {Array} args
|
||||
*/
|
||||
const writeToFile = (level, scope, args) => {
|
||||
const stream = getFileStream();
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return arg.stack || arg.message;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (_err) {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ")
|
||||
.replace(ANSI_PATTERN, "");
|
||||
|
||||
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`;
|
||||
stream.write(line);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also
|
||||
* appended to the backend log file, in addition to its normal console output.
|
||||
*
|
||||
* @param {Signale} instance
|
||||
* @param {String} scope
|
||||
* @returns {Signale}
|
||||
*/
|
||||
const withFileSink = (instance, scope) => {
|
||||
for (const method of PERSISTED_METHODS) {
|
||||
const original = instance[method].bind(instance);
|
||||
instance[method] = (...args) => {
|
||||
writeToFile(method, scope, args);
|
||||
return original(...args);
|
||||
};
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope);
|
||||
|
||||
const global = createLogger("Global ");
|
||||
const migrate = createLogger("Migrate ");
|
||||
const express = createLogger("Express ");
|
||||
const access = createLogger("Access ");
|
||||
const nginx = createLogger("Nginx ");
|
||||
const ssl = createLogger("SSL ");
|
||||
const certbot = createLogger("Certbot ");
|
||||
const importer = createLogger("Importer ");
|
||||
const setup = createLogger("Setup ");
|
||||
const ipRanges = createLogger("IP Ranges");
|
||||
const remoteVersion = createLogger("Remote Version");
|
||||
|
||||
const debug = (logger, ...args) => {
|
||||
if (isDebugMode()) {
|
||||
|
||||
@@ -13,13 +13,14 @@ const migrateName = "settings";
|
||||
const up = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Up...`);
|
||||
|
||||
return knex.schema.createTable('setting', (table) => {
|
||||
table.string('id').notNull().primary();
|
||||
table.string('name', 100).notNull();
|
||||
table.string('description', 255).notNull();
|
||||
table.string('value', 255).notNull();
|
||||
table.json('meta').notNull();
|
||||
})
|
||||
return knex.schema
|
||||
.createTable("setting", (table) => {
|
||||
table.string("id").notNull().primary();
|
||||
table.string("name", 100).notNull();
|
||||
table.string("description", 255).notNull();
|
||||
table.string("value", 255).notNull();
|
||||
table.json("meta").notNull();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] setting Table created`);
|
||||
});
|
||||
|
||||
@@ -17,9 +17,7 @@ const up = (knex) => {
|
||||
.table("redirection_host", async (table) => {
|
||||
// change the column default from $scheme to auto
|
||||
await table.string("forward_scheme").notNull().defaultTo("auto").alter();
|
||||
await knex('redirection_host')
|
||||
.where('forward_scheme', '$scheme')
|
||||
.update({ forward_scheme: 'auto' });
|
||||
await knex("redirection_host").where("forward_scheme", "$scheme").update({ forward_scheme: "auto" });
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] redirection_host Table altered`);
|
||||
@@ -38,9 +36,7 @@ const down = (knex) => {
|
||||
return knex.schema
|
||||
.table("redirection_host", async (table) => {
|
||||
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
|
||||
await knex('redirection_host')
|
||||
.where('forward_scheme', 'auto')
|
||||
.update({ forward_scheme: '$scheme' });
|
||||
await knex("redirection_host").where("forward_scheme", "auto").update({ forward_scheme: "$scheme" });
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] redirection_host Table altered`);
|
||||
|
||||
@@ -11,15 +11,15 @@ const migrateName = "trust_forwarded_proto";
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const up = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Up...`);
|
||||
logger.info(`[${migrateName}] Migrating Up...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable('proxy_host', (table) => {
|
||||
table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0);
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] proxy_host Table altered`);
|
||||
});
|
||||
return knex.schema
|
||||
.alterTable("proxy_host", (table) => {
|
||||
table.tinyint("trust_forwarded_proto").notNullable().defaultTo(0);
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] proxy_host Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,15 +29,15 @@ const up = (knex) => {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const down = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Down...`);
|
||||
logger.info(`[${migrateName}] Migrating Down...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable('proxy_host', (table) => {
|
||||
table.dropColumn('trust_forwarded_proto');
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] proxy_host Table altered`);
|
||||
});
|
||||
return knex.schema
|
||||
.alterTable("proxy_host", (table) => {
|
||||
table.dropColumn("trust_forwarded_proto");
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] proxy_host Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
export { up, down };
|
||||
export { up, down };
|
||||
|
||||
@@ -31,6 +31,10 @@ class AccessList extends Model {
|
||||
|
||||
$parseDatabaseJson(json) {
|
||||
const thisJson = super.$parseDatabaseJson(json);
|
||||
// Postgres returns COUNT() as a string
|
||||
if (typeof thisJson.proxy_host_count === "string") {
|
||||
thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
|
||||
}
|
||||
return convertIntFieldsToBool(thisJson, boolFields);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@ import now from "./now_helper.js";
|
||||
Model.knex(db());
|
||||
|
||||
class UserPermission extends Model {
|
||||
$beforeInsert () {
|
||||
this.created_on = now();
|
||||
$beforeInsert() {
|
||||
this.created_on = now();
|
||||
this.modified_on = now();
|
||||
}
|
||||
|
||||
$beforeUpdate () {
|
||||
$beforeUpdate() {
|
||||
this.modified_on = now();
|
||||
}
|
||||
|
||||
static get name () {
|
||||
return 'UserPermission';
|
||||
static get name() {
|
||||
return "UserPermission";
|
||||
}
|
||||
|
||||
static get tableName () {
|
||||
return 'user_permission';
|
||||
static get tableName() {
|
||||
return "user_permission";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-14
@@ -13,37 +13,37 @@
|
||||
"regenerate-config": "node scripts/regenerate-config"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^15.3.5",
|
||||
"@apidevtools/json-schema-ref-parser": "^16.0.2",
|
||||
"ajv": "^8.20.0",
|
||||
"archiver": "^8.0.0",
|
||||
"batchflow": "^0.4.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.3.0",
|
||||
"chalk": "5.6.2",
|
||||
"compression": "^1.8.1",
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"gravatar": "^1.8.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"knex": "3.2.10",
|
||||
"liquidjs": "10.27.0",
|
||||
"knex": "3.3.0",
|
||||
"liquidjs": "10.29.0",
|
||||
"lodash": "^4.18.1",
|
||||
"moment": "^2.30.1",
|
||||
"mysql2": "^3.22.3",
|
||||
"node-rsa": "^1.1.1",
|
||||
"moment": "^2.31.0",
|
||||
"mysql2": "^3.24.4",
|
||||
"node-rsa": "^2.0.0",
|
||||
"objection": "3.1.5",
|
||||
"otplib": "^13.4.0",
|
||||
"otplib": "^13.5.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.21.0",
|
||||
"proxy-agent": "^8.0.1",
|
||||
"pg": "^8.23.0",
|
||||
"proxy-agent": "^8.0.2",
|
||||
"signale": "1.4.0",
|
||||
"sqlite3": "^6.0.1",
|
||||
"temp-write": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.1.0",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@apidevtools/swagger-parser": "^13.0.0",
|
||||
"@biomejs/biome": "^2.5.10",
|
||||
"nodemon": "^3.1.14"
|
||||
},
|
||||
"signale": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import internalProxyHost from "../../internal/proxy-host.js";
|
||||
import jwtdecode from "../../lib/express/jwt-decode.js";
|
||||
import apiValidator from "../../lib/validator/api.js";
|
||||
@@ -206,4 +207,70 @@ router
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Proxy-host logs
|
||||
*
|
||||
* /api/nginx/proxy-hosts/123/logs
|
||||
*/
|
||||
router
|
||||
.route("/:host_id/logs")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
|
||||
/**
|
||||
* GET /api/nginx/proxy-hosts/123/logs
|
||||
*
|
||||
* Retrieve logs for a specific proxy-host
|
||||
*/
|
||||
.get(async (req, res, next) => {
|
||||
try {
|
||||
const data = await validator(
|
||||
{
|
||||
required: ["host_id"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
host_id: {
|
||||
$ref: "common#/properties/id",
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["access", "error"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
host_id: req.params.host_id,
|
||||
type: req.query.type || "access",
|
||||
},
|
||||
);
|
||||
|
||||
const hostId = Number.parseInt(data.host_id, 10);
|
||||
const logType = data.type === "error" ? "error" : "access";
|
||||
const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`;
|
||||
|
||||
// Check access permission
|
||||
await res.locals.access.can("proxy_hosts:get", hostId);
|
||||
|
||||
let logs = "";
|
||||
if (fs.existsSync(logFile)) {
|
||||
const content = fs.readFileSync(logFile, { encoding: "utf8" });
|
||||
const lines = content.split("\n");
|
||||
// Return last 1000 lines to avoid huge payloads
|
||||
const maxLines = 1000;
|
||||
if (lines.length > maxLines) {
|
||||
logs = lines.slice(-maxLines).join("\n");
|
||||
} else {
|
||||
logs = content;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).send({ logs });
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
+10
-41
@@ -48,18 +48,11 @@ router
|
||||
},
|
||||
},
|
||||
{
|
||||
expand:
|
||||
typeof req.query.expand === "string"
|
||||
? req.query.expand.split(",")
|
||||
: null,
|
||||
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
|
||||
query: typeof req.query.query === "string" ? req.query.query : null,
|
||||
},
|
||||
);
|
||||
const users = await internalUser.getAll(
|
||||
res.locals.access,
|
||||
data.expand,
|
||||
data.query,
|
||||
);
|
||||
const users = await internalUser.getAll(res.locals.access, data.expand, data.query);
|
||||
res.status(200).send(users);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
@@ -95,10 +88,7 @@ router
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await apiValidator(
|
||||
getValidationSchema("/users", "post"),
|
||||
body,
|
||||
);
|
||||
const payload = await apiValidator(getValidationSchema("/users", "post"), body);
|
||||
const user = await internalUser.create(res.locals.access, payload);
|
||||
res.status(201).send(user);
|
||||
} catch (err) {
|
||||
@@ -169,20 +159,14 @@ router
|
||||
},
|
||||
{
|
||||
user_id: req.params.user_id,
|
||||
expand:
|
||||
typeof req.query.expand === "string"
|
||||
? req.query.expand.split(",")
|
||||
: null,
|
||||
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
|
||||
},
|
||||
);
|
||||
|
||||
const user = await internalUser.get(res.locals.access, {
|
||||
id: data.user_id,
|
||||
expand: data.expand,
|
||||
omit: internalUser.getUserOmisionsByAccess(
|
||||
res.locals.access,
|
||||
data.user_id,
|
||||
),
|
||||
omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id),
|
||||
});
|
||||
res.status(200).send(user);
|
||||
} catch (err) {
|
||||
@@ -198,10 +182,7 @@ router
|
||||
*/
|
||||
.put(async (req, res, next) => {
|
||||
try {
|
||||
const payload = await apiValidator(
|
||||
getValidationSchema("/users/{userID}", "put"),
|
||||
req.body,
|
||||
);
|
||||
const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body);
|
||||
payload.id = req.params.user_id;
|
||||
const result = await internalUser.update(res.locals.access, payload);
|
||||
res.status(200).send(result);
|
||||
@@ -248,10 +229,7 @@ router
|
||||
*/
|
||||
.put(async (req, res, next) => {
|
||||
try {
|
||||
const payload = await apiValidator(
|
||||
getValidationSchema("/users/{userID}/auth", "put"),
|
||||
req.body,
|
||||
);
|
||||
const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body);
|
||||
payload.id = req.params.user_id;
|
||||
const result = await internalUser.setPassword(res.locals.access, payload);
|
||||
res.status(200).send(result);
|
||||
@@ -281,15 +259,9 @@ router
|
||||
*/
|
||||
.put(async (req, res, next) => {
|
||||
try {
|
||||
const payload = await apiValidator(
|
||||
getValidationSchema("/users/{userID}/permissions", "put"),
|
||||
req.body,
|
||||
);
|
||||
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
|
||||
payload.id = req.params.user_id;
|
||||
const result = await internalUser.setPermissions(
|
||||
res.locals.access,
|
||||
payload,
|
||||
);
|
||||
const result = await internalUser.setPermissions(res.locals.access, payload);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
@@ -408,10 +380,7 @@ router
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
try {
|
||||
const { code } = await apiValidator(
|
||||
getValidationSchema("/users/{userID}/2fa/enable", "post"),
|
||||
req.body,
|
||||
);
|
||||
const { code } = await apiValidator(getValidationSchema("/users/{userID}/2fa/enable", "post"), req.body);
|
||||
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Available log sources for the log viewer",
|
||||
"required": ["system", "letsencrypt", "hosts"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"system": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"example": "System"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsencrypt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"example": "Let's Encrypt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"type": "object",
|
||||
"required": ["proxy", "redirection", "dead", "stream"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"proxy": {
|
||||
"$ref": "#/$defs/host-option-list"
|
||||
},
|
||||
"redirection": {
|
||||
"$ref": "#/$defs/host-option-list"
|
||||
},
|
||||
"dead": {
|
||||
"$ref": "#/$defs/host-option-list"
|
||||
},
|
||||
"stream": {
|
||||
"$ref": "#/$defs/host-option-list"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"proxy": [{ "id": 1, "label": "example.com" }],
|
||||
"redirection": [],
|
||||
"dead": [],
|
||||
"stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"host-option-list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "../common.json#/properties/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"example": "example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"type": "object",
|
||||
"description": "The last N lines of a log source",
|
||||
"required": ["lines", "size", "truncated", "exists"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."]
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"description": "Size in bytes of the underlying log file",
|
||||
"example": 4096
|
||||
},
|
||||
"truncated": {
|
||||
"type": "boolean",
|
||||
"description": "True when the file is larger than the maximum amount of data scanned per request",
|
||||
"example": false
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean",
|
||||
"description": "False when the underlying log file does not exist yet",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,9 @@
|
||||
},
|
||||
"advanced_config": {
|
||||
"type": "string"
|
||||
},
|
||||
"access_list_id": {
|
||||
"$ref": "../common.json#/properties/access_list_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -132,7 +135,8 @@
|
||||
"path": "/app",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "example.com",
|
||||
"forward_port": 80
|
||||
"forward_port": 80,
|
||||
"access_list_id": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
|
||||
"pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"operationId": "getLogSources",
|
||||
"summary": "Get available log sources",
|
||||
"tags": ["logs"],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": ["admin"]
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "200 response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": {
|
||||
"system": { "label": "System" },
|
||||
"letsencrypt": { "label": "Let's Encrypt" },
|
||||
"hosts": {
|
||||
"proxy": [{ "id": 1, "label": "example.com" }],
|
||||
"redirection": [],
|
||||
"dead": [],
|
||||
"stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "../../../components/log-sources-object.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -46,7 +46,7 @@ const logIt = (msg, type = "info") => logger[type](
|
||||
|
||||
// Let's do it.
|
||||
|
||||
const processItems = async (model, type) => {
|
||||
const processItems = async (model, type, hostType) => {
|
||||
const rows = await model
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
@@ -60,17 +60,17 @@ const processItems = async (model, type) => {
|
||||
for (const row of rows) {
|
||||
if (!DRY_RUN) {
|
||||
logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
|
||||
await internalNginx.configure(proxyHostModel, "proxy_host", row);
|
||||
await internalNginx.configure(model, hostType, row);
|
||||
} else {
|
||||
logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processItems(proxyHostModel, "Proxy Host");
|
||||
await processItems(redirectionHostModel, "Redirection Host");
|
||||
await processItems(deadHostModel, "404 Host");
|
||||
await processItems(streamModel, "Stream");
|
||||
await processItems(proxyHostModel, "Proxy Host", "proxy_host");
|
||||
await processItems(redirectionHostModel, "Redirection Host", "redirection_host");
|
||||
await processItems(deadHostModel, "404 Host", "dead_host");
|
||||
await processItems(streamModel, "Stream", "stream");
|
||||
|
||||
logIt("Completed", "success");
|
||||
process.exit(0);
|
||||
|
||||
+29
-43
@@ -6,11 +6,12 @@ import certificateModel from "./models/certificate.js";
|
||||
import settingModel from "./models/setting.js";
|
||||
import userModel from "./models/user.js";
|
||||
import userPermissionModel from "./models/user_permission.js";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
export const isSetup = async () => {
|
||||
const row = await userModel.query().select("id").where("is_deleted", 0).first();
|
||||
return row?.id > 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a default admin users if one doesn't already exist in the database
|
||||
@@ -44,18 +45,14 @@ const setupDefaultUser = async () => {
|
||||
roles: ["admin"],
|
||||
};
|
||||
|
||||
const user = await userModel
|
||||
.query()
|
||||
.insertAndFetch(data);
|
||||
const user = await userModel.query().insertAndFetch(data);
|
||||
|
||||
await authModel
|
||||
.query()
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
type: "password",
|
||||
secret: initialAdminPassword,
|
||||
meta: {},
|
||||
});
|
||||
await authModel.query().insert({
|
||||
user_id: user.id,
|
||||
type: "password",
|
||||
secret: initialAdminPassword,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
await userPermissionModel.query().insert({
|
||||
user_id: user.id,
|
||||
@@ -77,22 +74,16 @@ const setupDefaultUser = async () => {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const setupDefaultSettings = async () => {
|
||||
const row = await settingModel
|
||||
.query()
|
||||
.select("id")
|
||||
.where({ id: "default-site" })
|
||||
.first();
|
||||
const row = await settingModel.query().select("id").where({ id: "default-site" }).first();
|
||||
|
||||
if (!row?.id) {
|
||||
await settingModel
|
||||
.query()
|
||||
.insert({
|
||||
id: "default-site",
|
||||
name: "Default Site",
|
||||
description: "What to show when Nginx is hit with an unknown Host",
|
||||
value: "congratulations",
|
||||
meta: {},
|
||||
});
|
||||
await settingModel.query().insert({
|
||||
id: "default-site",
|
||||
name: "Default Site",
|
||||
description: "What to show when Nginx is hit with an unknown Host",
|
||||
value: "congratulations",
|
||||
meta: {},
|
||||
});
|
||||
logger.info("Default settings added");
|
||||
}
|
||||
};
|
||||
@@ -103,14 +94,10 @@ const setupDefaultSettings = async () => {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const setupCertbotPlugins = async () => {
|
||||
const certificates = await certificateModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("provider", "letsencrypt");
|
||||
const certificates = await certificateModel.query().where("is_deleted", 0).andWhere("provider", "letsencrypt");
|
||||
|
||||
if (certificates?.length) {
|
||||
const plugins = [];
|
||||
const promises = [];
|
||||
|
||||
certificates.map((certificate) => {
|
||||
if (certificate.meta && certificate.meta.dns_challenge === true) {
|
||||
@@ -118,24 +105,23 @@ const setupCertbotPlugins = async () => {
|
||||
plugins.push(certificate.meta.dns_provider);
|
||||
}
|
||||
|
||||
// Make sure credentials file exists
|
||||
const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
|
||||
// Escape single quotes and backslashes
|
||||
if (typeof certificate.meta.dns_provider_credentials === "string") {
|
||||
const escapedCredentials = certificate.meta.dns_provider_credentials
|
||||
.replaceAll("'", "\\'")
|
||||
.replaceAll("\\", "\\\\");
|
||||
const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
|
||||
promises.push(utils.exec(credentials_cmd));
|
||||
}
|
||||
// Deliberately does NOT write the DNS credentials file here any more.
|
||||
//
|
||||
// It used to, so that a later `certbot renew` would find the path recorded in its
|
||||
// renewal config. The effect was that every backend restart rewrote a plaintext
|
||||
// DNS provider API token for every DNS-01 certificate, and left it there.
|
||||
//
|
||||
// internalCertificate now writes that file immediately before it runs certbot and
|
||||
// removes it again afterwards, so there is exactly one writer and the credential
|
||||
// is on disk only for the length of a certbot run. Recreating the files at boot
|
||||
// would put every one of them straight back.
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
await installPlugins(plugins);
|
||||
|
||||
if (promises.length) {
|
||||
await Promise.all(promises);
|
||||
if (plugins.length) {
|
||||
logger.info(`Added Certbot plugins ${plugins.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{% if enabled %}
|
||||
{% if tcp_forwarding == 1 or tcp_forwarding == true -%}
|
||||
server {
|
||||
listen {{ incoming_port }} {%- if certificate %} ssl {%- endif %};
|
||||
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %};
|
||||
listen {{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
|
||||
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
|
||||
|
||||
{%- include "_certificates_stream.conf" %}
|
||||
|
||||
@@ -23,8 +23,8 @@ server {
|
||||
|
||||
{% if udp_forwarding == 1 or udp_forwarding == true -%}
|
||||
server {
|
||||
listen {{ incoming_port }} udp;
|
||||
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp;
|
||||
listen {{ incoming_port }} udp reuseport;
|
||||
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp reuseport;
|
||||
|
||||
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
||||
|
||||
|
||||
+417
-371
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ RUN curl -sL 'https://taskfile.dev/install.sh' | sh
|
||||
COPY rootfs /
|
||||
|
||||
COPY scripts/install-s6 /tmp/install-s6
|
||||
RUN rm -f /etc/nginx/conf.d/production.conf \
|
||||
RUN rm -f /etc/nginx/conf.d/production.conf.template \
|
||||
&& chmod 644 /etc/logrotate.d/nginx-proxy-manager \
|
||||
&& /tmp/install-s6 "${TARGETPLATFORM}" \
|
||||
&& rm -f /tmp/install-s6 \
|
||||
|
||||
@@ -28,6 +28,7 @@ acl SSL_ports port 443
|
||||
acl Safe_ports port 80 # http
|
||||
acl Safe_ports port 81
|
||||
acl Safe_ports port 443 # https
|
||||
acl Safe_ports port 8000 # for CI testing with custom admin port
|
||||
|
||||
#
|
||||
# Recommended minimum Access Permission configuration:
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
cypress:
|
||||
environment:
|
||||
CYPRESS_stack: "postgres"
|
||||
NPM_ADMIN_PORT: 8000
|
||||
|
||||
fullstack:
|
||||
environment:
|
||||
@@ -11,11 +12,14 @@ services:
|
||||
DB_POSTGRES_USER: "npm"
|
||||
DB_POSTGRES_PASSWORD: "npmpass"
|
||||
DB_POSTGRES_NAME: "npm"
|
||||
NPM_ADMIN_PORT: 8000
|
||||
depends_on:
|
||||
- db-postgres
|
||||
- authentik
|
||||
- authentik-worker
|
||||
- authentik-ldap
|
||||
expose:
|
||||
- "8000/tcp"
|
||||
|
||||
db-postgres:
|
||||
image: postgres:17
|
||||
|
||||
@@ -38,6 +38,16 @@ services:
|
||||
- website2.example.com
|
||||
- website3.example.com
|
||||
|
||||
examplesite:
|
||||
image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
|
||||
build:
|
||||
context: ../test/docker
|
||||
dockerfile: Dockerfile.website
|
||||
expose:
|
||||
- "80/tcp"
|
||||
networks:
|
||||
- fulltest
|
||||
|
||||
stepca:
|
||||
image: nginxproxymanager/testca
|
||||
volumes:
|
||||
@@ -105,6 +115,7 @@ services:
|
||||
environment:
|
||||
HTTP_PROXY: "squid:3128"
|
||||
HTTPS_PROXY: "squid:3128"
|
||||
NPM_ADMIN_PORT: 81
|
||||
volumes:
|
||||
- "cypress_logs:/test/results"
|
||||
- "./dev/resolv.conf:/etc/resolv.conf:ro"
|
||||
|
||||
@@ -25,3 +25,13 @@
|
||||
kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true
|
||||
endscript
|
||||
}
|
||||
|
||||
/data/logs/backend.log {
|
||||
su npm npm
|
||||
size 10M
|
||||
rotate 5
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
copytruncate
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Admin Interface
|
||||
server {
|
||||
listen 81 default;
|
||||
listen [::]:81 default;
|
||||
listen {{NPM_ADMIN_PORT}} default;
|
||||
listen [::]:{{NPM_ADMIN_PORT}} default;
|
||||
|
||||
server_name nginxproxymanager;
|
||||
root /app/frontend;
|
||||
@@ -17,6 +17,7 @@ fi
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/20-paths.sh
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/40-dynamic.sh
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh
|
||||
. /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh
|
||||
|
||||
@@ -46,6 +46,12 @@ for loc in "${locations[@]}"; do
|
||||
chownit "$loc"
|
||||
done
|
||||
|
||||
# Ensure the JWT key file is owned by the runtime user, even when the /data
|
||||
# directory ownership already matches PUID:PGID (chownit skips recursion then)
|
||||
if [ -f /data/keys.json ]; then
|
||||
chown "$PUID:$PGID" /data/keys.json
|
||||
fi
|
||||
|
||||
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
|
||||
log_info 'Skipping ownership change of certbot directories'
|
||||
else
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/command/with-contenv bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
# This command reads the `NPM_ADMIN_PORT` env var and will fall
|
||||
# back to 81 if this is not set or is not a number.
|
||||
|
||||
set -e
|
||||
|
||||
log_info 'Admin Port ...'
|
||||
|
||||
NPM_ADMIN_PORT="${NPM_ADMIN_PORT:-81}"
|
||||
# ensure admin port is a number
|
||||
if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "WARNING: NPM_ADMIN_PORT must be a number. Defaulting to 81" >&2
|
||||
NPM_ADMIN_PORT=81
|
||||
fi
|
||||
|
||||
PRODFILE="/etc/nginx/conf.d/production.conf"
|
||||
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g"
|
||||
|
||||
if is_mounted "$PRODFILE"; then
|
||||
echo "WARNING: skipping ${PRODFILE} — mounted file" >&2
|
||||
elif [ -f "$PRODFILE.template" ]; then
|
||||
if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then
|
||||
# success
|
||||
log_info "Generated ${PRODFILE} from template"
|
||||
else
|
||||
log_fatal "Failed to generate ${PRODFILE} from template"
|
||||
fi
|
||||
fi
|
||||
@@ -8,10 +8,6 @@ set -e
|
||||
|
||||
log_info 'IPv6 ...'
|
||||
|
||||
is_mounted() {
|
||||
awk -v p="$1" '$5 == p { found=1 } END { exit !found }' /proc/self/mountinfo
|
||||
}
|
||||
|
||||
process_folder () {
|
||||
FILES=$(find "$1" -type f -name "*.conf")
|
||||
SED_REGEX=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
OK=$(curl --silent http://127.0.0.1:81/api/ | jq --raw-output '.status')
|
||||
OK=$(curl --silent "http://127.0.0.1:${NPM_ADMIN_PORT:-81}/api/" | jq --raw-output '.status')
|
||||
|
||||
if [ "$OK" == "OK" ]; then
|
||||
echo "OK"
|
||||
|
||||
@@ -70,3 +70,7 @@ is_true () {
|
||||
echo '0'
|
||||
fi
|
||||
}
|
||||
|
||||
is_mounted() {
|
||||
awk -v p="$1" '$5 == p { found=1 } END { exit !found }' /proc/self/mountinfo
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
|
||||
GREEN='\E[1;32m'
|
||||
RESET='\E[0m'
|
||||
|
||||
S6_OVERLAY_VERSION=3.2.1.0
|
||||
S6_OVERLAY_VERSION=3.2.3.0
|
||||
TARGETPLATFORM=${1:-linux/amd64}
|
||||
|
||||
# Determine the correct binary file for the architecture given
|
||||
|
||||
@@ -248,3 +248,21 @@ On startup, we generate a resolvers directive for Nginx unless this is defined:
|
||||
|
||||
In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file
|
||||
and then the `/etc/resolv.conf`.
|
||||
|
||||
|
||||
## Changing the Admin UI port from 81 to something else
|
||||
|
||||
First, add an env var to your docker compose file:
|
||||
```yml
|
||||
environment:
|
||||
NPM_ADMIN_PORT: '8000'
|
||||
```
|
||||
|
||||
And you'll probably want to expose that port as well
|
||||
|
||||
```yml
|
||||
ports:
|
||||
- '8000:8000'
|
||||
```
|
||||
|
||||
Then you'll be able to access admin UI at `http://localhost:8000`
|
||||
|
||||
+8
-8
@@ -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
@@ -1,7 +1,7 @@
|
||||
src/locale/lang
|
||||
|
||||
# Logs
|
||||
logs
|
||||
/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
+73
-90
@@ -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
@@ -29,6 +29,9 @@ const allLocales = [
|
||||
["tr", "tr-TR"],
|
||||
["hu", "hu-HU"],
|
||||
["no", "no-NO"],
|
||||
["uk", "uk-UA"],
|
||||
["az", "az-AZ"],
|
||||
["fa", "fa-IR"],
|
||||
];
|
||||
|
||||
const ignoreUnused = [/^.*$/];
|
||||
|
||||
+31
-30
@@ -16,51 +16,52 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.4.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tabler/core": "^1.5.1",
|
||||
"@tabler/icons-react": "^3.46.0",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@tanstack/react-table": "^9.2.4",
|
||||
"@uiw/react-textarea-code-editor": "^3.1.1",
|
||||
"classnames": "^2.5.1",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"date-fns": "^4.3.0",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"date-fns": "^4.4.0",
|
||||
"ez-modal-react": "^1.0.5",
|
||||
"formik": "^2.4.9",
|
||||
"generate-password-browser": "^1.1.0",
|
||||
"humps": "^2.0.1",
|
||||
"query-string": "^9.3.1",
|
||||
"react": "^19.2.6",
|
||||
"query-string": "^9.5.1",
|
||||
"react": "^19.3.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-intl": "^10.1.9",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-intl": "^10.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-select": "^5.10.2",
|
||||
"react-toastify": "^11.1.0",
|
||||
"rooks": "^9.8.0"
|
||||
"rooks": "^9.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@formatjs/cli": "^6.16.3",
|
||||
"@tanstack/react-query-devtools": "^5.100.14",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@biomejs/biome": "^2.5.10",
|
||||
"@formatjs/cli": "^6.16.30",
|
||||
"@tanstack/react-query-devtools": "^5.103.1",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/country-flag-icons": "^1.2.2",
|
||||
"@types/humps": "^2.0.6",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"happy-dom": "^20.9.0",
|
||||
"postcss": "^8.5.15",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"happy-dom": "^20.14.5",
|
||||
"postcss": "^8.5.28",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.100.0",
|
||||
"tmp": "^0.2.6",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-checker": "^0.14.1",
|
||||
"vitest": "^4.1.7"
|
||||
"sass": "^1.104.1",
|
||||
"tmp": "^0.2.7",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-checker": "^0.14.5",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as api from "./base";
|
||||
import type { LogChannel, LogHostType, LogSourceType, LogTail } from "./models";
|
||||
|
||||
export interface GetLogTailParams {
|
||||
type: LogSourceType;
|
||||
hostType?: LogHostType;
|
||||
hostId?: number;
|
||||
channel?: LogChannel;
|
||||
lines?: number;
|
||||
level?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function getLogTail(params: GetLogTailParams): Promise<LogTail> {
|
||||
return await api.get({
|
||||
url: "/logs/tail",
|
||||
params: { ...params },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as api from "./base";
|
||||
|
||||
export async function getProxyHostLogs(id: number, type: "access" | "error" = "access"): Promise<{ logs: string }> {
|
||||
return await api.get({
|
||||
url: `/nginx/proxy-hosts/${id}/logs`,
|
||||
params: { type },
|
||||
});
|
||||
}
|
||||
@@ -26,7 +26,10 @@ export * from "./getDeadHost";
|
||||
export * from "./getDeadHosts";
|
||||
export * from "./getHealth";
|
||||
export * from "./getHostsReport";
|
||||
export * from "./getLogSources";
|
||||
export * from "./getLogTail";
|
||||
export * from "./getProxyHost";
|
||||
export * from "./getProxyHostLogs";
|
||||
export * from "./getProxyHosts";
|
||||
export * from "./getRedirectionHost";
|
||||
export * from "./getRedirectionHosts";
|
||||
|
||||
@@ -44,6 +44,30 @@ export interface AuditLog {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export type LogSourceType = "system" | "letsencrypt" | "host";
|
||||
export type LogHostType = "proxy" | "redirection" | "dead" | "stream";
|
||||
// Which of the two files nginx writes per host - named "channel", not "stream",
|
||||
// to avoid confusion with the "stream" LogHostType (TCP/UDP stream hosts).
|
||||
export type LogChannel = "access" | "error";
|
||||
|
||||
export interface LogHostOption {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LogSources {
|
||||
system: { label: string };
|
||||
letsencrypt: { label: string };
|
||||
hosts: Record<LogHostType, LogHostOption[]>;
|
||||
}
|
||||
|
||||
export interface LogTail {
|
||||
lines: string[];
|
||||
size: number;
|
||||
truncated: boolean;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface AccessList {
|
||||
id?: number;
|
||||
createdOn?: string;
|
||||
@@ -103,6 +127,8 @@ export interface ProxyLocation {
|
||||
forwardScheme: string;
|
||||
forwardHost: string;
|
||||
forwardPort: number;
|
||||
accessListId?: number;
|
||||
accessList?: AccessList;
|
||||
}
|
||||
|
||||
export interface ProxyHost {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
|
||||
import { IconArrowBackUp, IconLock, IconLockOpen2 } from "@tabler/icons-react";
|
||||
import { Field, useFormikContext } from "formik";
|
||||
import type { ReactNode } from "react";
|
||||
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
|
||||
@@ -31,14 +31,26 @@ interface Props {
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
onFormChange?: (value: number) => void;
|
||||
// When set, the 0 option inherits the host's access list instead of being public
|
||||
inheritHost?: boolean;
|
||||
}
|
||||
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) {
|
||||
export function AccessField({
|
||||
name = "accessListId",
|
||||
label = "access-list",
|
||||
id = "accessListId",
|
||||
onFormChange,
|
||||
inheritHost = false,
|
||||
}: Props) {
|
||||
const { locale } = useLocaleState();
|
||||
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {
|
||||
setFieldValue(name, newValue?.value);
|
||||
if (onFormChange) {
|
||||
onFormChange(newValue?.value ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
const options: AccessOption[] =
|
||||
@@ -56,13 +68,22 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
|
||||
icon: <IconLock size={14} className="text-lime" />,
|
||||
})) || [];
|
||||
|
||||
// Public option
|
||||
options?.unshift({
|
||||
value: 0,
|
||||
label: intl.formatMessage({ id: "access-list.public" }),
|
||||
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
|
||||
icon: <IconLockOpen2 size={14} className="text-red" />,
|
||||
});
|
||||
// Public or inherit option
|
||||
options?.unshift(
|
||||
inheritHost
|
||||
? {
|
||||
value: 0,
|
||||
label: intl.formatMessage({ id: "access-list.inherit" }),
|
||||
subLabel: intl.formatMessage({ id: "access-list.inherit.subtitle" }),
|
||||
icon: <IconArrowBackUp size={14} className="text-secondary" />,
|
||||
}
|
||||
: {
|
||||
value: 0,
|
||||
label: intl.formatMessage({ id: "access-list.public" }),
|
||||
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
|
||||
icon: <IconLockOpen2 size={14} className="text-red" />,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Field name={name}>
|
||||
|
||||
@@ -82,16 +82,13 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-1">
|
||||
<a
|
||||
role="button"
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-danger p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(idx);
|
||||
}}
|
||||
onClick={() => handleRemove(idx)}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
/* card-active points --tblr-card-border-color at --tblr-primary, which both the
|
||||
card outline and the card header's bottom border are drawn from. Tabler's
|
||||
stylesheet is loaded after this one, so the override needs !important. */
|
||||
.locationCard {
|
||||
border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
|
||||
--tblr-card-border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
|
||||
}
|
||||
|
||||
.filter {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
/* The header is a plain toggle rather than a button-styled control, so that a
|
||||
list of collapsed locations reads as rows instead of a stack of buttons. */
|
||||
.toggle {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--tblr-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Keeps the marker on one line next to the delete button, and lets it drop out
|
||||
of the way before the path does when the row runs out of room. */
|
||||
.marker {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import CodeEditor from "@uiw/react-textarea-code-editor";
|
||||
import cn from "classnames";
|
||||
import { useFormikContext } from "formik";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { ProxyLocation } from "src/api/backend";
|
||||
import { intl, T } from "src/locale";
|
||||
import { AccessField } from "./AccessField";
|
||||
import styles from "./LocationsFields.module.css";
|
||||
|
||||
// Below this many locations the list is short enough to scan by eye, and the
|
||||
// filter would only take up space.
|
||||
const FILTER_THRESHOLD = 5;
|
||||
|
||||
// Locations are identified by a client-side id rather than their array index,
|
||||
// so that expanded/advanced state stays with the right row when one is removed.
|
||||
interface Row {
|
||||
id: number;
|
||||
value: ProxyLocation;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialValues: ProxyLocation[];
|
||||
name?: string;
|
||||
}
|
||||
export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
const [values, setValues] = useState<ProxyLocation[]>(initialValues || []);
|
||||
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [expanded, setExpanded] = useState<number[]>([]);
|
||||
const [advVisible, setAdvVisible] = useState<number[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const nextId = useRef(rows.length);
|
||||
const scrollToId = useRef<number | null>(null);
|
||||
|
||||
const blankItem: ProxyLocation = {
|
||||
path: "",
|
||||
@@ -22,34 +46,71 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
forwardScheme: "http",
|
||||
forwardHost: "",
|
||||
forwardPort: 80,
|
||||
accessListId: 0,
|
||||
};
|
||||
|
||||
const toggleAdvVisible = (idx: number) => {
|
||||
setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
|
||||
const toggleExpanded = (id: number) => {
|
||||
setExpanded(expanded.includes(id) ? expanded.filter((i) => i !== id) : [...expanded, id]);
|
||||
};
|
||||
|
||||
const toggleAdvVisible = (id: number) => {
|
||||
setAdvVisible(advVisible.includes(id) ? advVisible.filter((i) => i !== id) : [...advVisible, id]);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setValues([...values, blankItem]);
|
||||
const id = nextId.current++;
|
||||
setRows([...rows, { id, value: blankItem }]);
|
||||
// A new location starts empty, so open it and make sure an active filter
|
||||
// doesn't hide the row that was just added.
|
||||
setExpanded([...expanded, id]);
|
||||
setFilter("");
|
||||
scrollToId.current = id;
|
||||
};
|
||||
|
||||
const handleRemove = (idx: number) => {
|
||||
const newValues = values.filter((_: ProxyLocation, i: number) => i !== idx);
|
||||
setValues(newValues);
|
||||
setFormField(newValues);
|
||||
const handleRemove = (id: number) => {
|
||||
const newRows = rows.filter((r: Row) => r.id !== id);
|
||||
setRows(newRows);
|
||||
setExpanded(expanded.filter((i) => i !== id));
|
||||
setAdvVisible(advVisible.filter((i) => i !== id));
|
||||
setFormField(newRows);
|
||||
};
|
||||
|
||||
const handleChange = (idx: number, field: string, fieldValue: string) => {
|
||||
const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
|
||||
setValues(newValues);
|
||||
setFormField(newValues);
|
||||
const handleChange = (id: number, field: string, fieldValue: string) => {
|
||||
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, [field]: fieldValue } } : r));
|
||||
setRows(newRows);
|
||||
setFormField(newRows);
|
||||
};
|
||||
|
||||
const setFormField = (newValues: ProxyLocation[]) => {
|
||||
const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
||||
const handleAccessListChange = (id: number, accessListId: number) => {
|
||||
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, accessListId } } : r));
|
||||
setRows(newRows);
|
||||
setFormField(newRows);
|
||||
};
|
||||
|
||||
const setFormField = (newRows: Row[]) => {
|
||||
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
|
||||
setFieldValue(name, filtered);
|
||||
};
|
||||
|
||||
if (values.length === 0) {
|
||||
const forwardSummary = (item: ProxyLocation) => {
|
||||
if (!item.forwardHost) {
|
||||
return "";
|
||||
}
|
||||
return `${item.forwardScheme}://${item.forwardHost}${item.forwardPort ? `:${item.forwardPort}` : ""}`;
|
||||
};
|
||||
|
||||
// Matches the path as well as the destination, so a location can be found by
|
||||
// the host or port it forwards to and not just by its path.
|
||||
const matchesFilter = (item: ProxyLocation, query: string) =>
|
||||
[item.path, item.forwardScheme, item.forwardHost, item.forwardPort, forwardSummary(item)]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const visibleRows = query ? rows.filter((r: Row) => matchesFilter(r.value, query)) : rows;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<button type="button" className="btn my-3" onClick={handleAdd}>
|
||||
@@ -61,125 +122,224 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((item: ProxyLocation, idx: number) => (
|
||||
<div key={idx} className={cn("card", "card-active", "mb-3", styles.locationCard)}>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">Location</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="/path"
|
||||
autoComplete="off"
|
||||
value={item.path}
|
||||
onChange={(e) => handleChange(idx, "path", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2 text-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn p-0"
|
||||
title="Advanced"
|
||||
onClick={() => toggleAdvVisible(idx)}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardScheme">
|
||||
<T id="host.forward-scheme" />
|
||||
</label>
|
||||
<select
|
||||
id="forwardScheme"
|
||||
className="form-control"
|
||||
value={item.forwardScheme}
|
||||
onChange={(e) => handleChange(idx, "forwardScheme", e.target.value)}
|
||||
>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardHost">
|
||||
<T id="proxy-host.forward-host" />
|
||||
</label>
|
||||
<input
|
||||
id="forwardHost"
|
||||
type="text"
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 10.0.0.1/path/"
|
||||
value={item.forwardHost}
|
||||
onChange={(e) => handleChange(idx, "forwardHost", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardPort">
|
||||
<T id="host.forward-port" />
|
||||
</label>
|
||||
<input
|
||||
id="forwardPort"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 8081"
|
||||
value={item.forwardPort}
|
||||
onChange={(e) => handleChange(idx, "forwardPort", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{advVisible.includes(idx) && (
|
||||
<div className="">
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
|
||||
padding={15}
|
||||
data-color-mode="dark"
|
||||
minHeight={170}
|
||||
indentWidth={2}
|
||||
value={item.advancedConfig}
|
||||
onChange={(e) => handleChange(idx, "advancedConfig", e.target.value)}
|
||||
style={{
|
||||
fontFamily:
|
||||
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
borderRadius: "0.3rem",
|
||||
minHeight: "170px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(idx);
|
||||
}}
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
{rows.length >= FILTER_THRESHOLD && (
|
||||
<div className={cn("input-group", styles.filter)}>
|
||||
<span className="input-group-text">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
autoComplete="off"
|
||||
placeholder={intl.formatMessage({ id: "location.filter" })}
|
||||
aria-label={intl.formatMessage({ id: "location.filter" })}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
{filter ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon"
|
||||
title={intl.formatMessage({ id: "action.clear" })}
|
||||
aria-label={intl.formatMessage({ id: "action.clear" })}
|
||||
onClick={() => setFilter("")}
|
||||
>
|
||||
<T id="action.delete" />
|
||||
</a>
|
||||
</div>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" className="btn btn-sm" onClick={handleAdd}>
|
||||
)}
|
||||
<button type="button" className="btn ms-auto" onClick={handleAdd}>
|
||||
<IconPlus size={16} className="me-1" />
|
||||
<T id="action.add-location" />
|
||||
</button>
|
||||
</div>
|
||||
{visibleRows.length === 0 ? (
|
||||
<div className="text-secondary text-center my-3">
|
||||
<T id="empty-search" />
|
||||
</div>
|
||||
) : (
|
||||
visibleRows.map((row: Row) => {
|
||||
const item = row.value;
|
||||
const isOpen = expanded.includes(row.id);
|
||||
const bodyId = `location-body-${row.id}`;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
ref={(node) => {
|
||||
if (node && scrollToId.current === row.id) {
|
||||
scrollToId.current = null;
|
||||
node.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}}
|
||||
className={cn("card", "card-active", "mb-2", styles.locationCard)}
|
||||
>
|
||||
<div className={cn("card-header", "p-2", !isOpen && "border-bottom-0")}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => toggleExpanded(row.id)}
|
||||
>
|
||||
{isOpen ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
<span className={cn("ms-2", styles.path)}>{item.path}</span>
|
||||
<span className={cn("ms-2", "text-secondary", styles.summary)}>
|
||||
{forwardSummary(item)}
|
||||
</span>
|
||||
</button>
|
||||
{item.advancedConfig ? (
|
||||
// Deliberately the same icon as the advanced-config toggle in the
|
||||
// body below, so the marker reads as "this row has that section
|
||||
// filled in" rather than as a decoration of its own.
|
||||
<span
|
||||
className={cn("ms-2", "text-secondary", styles.marker)}
|
||||
role="img"
|
||||
title={intl.formatMessage({ id: "location.advanced-config" })}
|
||||
aria-label={intl.formatMessage({ id: "location.advanced-config" })}
|
||||
>
|
||||
<IconSettings size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-action ms-2"
|
||||
title={intl.formatMessage({ id: "action.delete" })}
|
||||
aria-label={intl.formatMessage({ id: "action.delete" })}
|
||||
onClick={() => handleRemove(row.id)}
|
||||
>
|
||||
<IconTrash size={16} className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="card-body" id={bodyId}>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">Location</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="/path"
|
||||
autoComplete="off"
|
||||
value={item.path}
|
||||
onChange={(e) => handleChange(row.id, "path", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2 text-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn p-0"
|
||||
title="Advanced"
|
||||
aria-expanded={advVisible.includes(row.id)}
|
||||
onClick={() => toggleAdvVisible(row.id)}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`location-forwardScheme-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-scheme" />
|
||||
</label>
|
||||
<select
|
||||
id={`location-forwardScheme-${row.id}`}
|
||||
className="form-control"
|
||||
value={item.forwardScheme}
|
||||
onChange={(e) =>
|
||||
handleChange(row.id, "forwardScheme", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`location-forwardHost-${row.id}`}
|
||||
>
|
||||
<T id="proxy-host.forward-host" />
|
||||
</label>
|
||||
<input
|
||||
id={`location-forwardHost-${row.id}`}
|
||||
type="text"
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 10.0.0.1/path/"
|
||||
value={item.forwardHost}
|
||||
onChange={(e) =>
|
||||
handleChange(row.id, "forwardHost", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`location-forwardPort-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-port" />
|
||||
</label>
|
||||
<input
|
||||
id={`location-forwardPort-${row.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="eg: 8081"
|
||||
value={item.forwardPort}
|
||||
onChange={(e) =>
|
||||
handleChange(row.id, "forwardPort", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AccessField
|
||||
name={`locations[${row.id}].accessListId`}
|
||||
label="access-list"
|
||||
id={`locations-access-list-${row.id}`}
|
||||
onFormChange={(value) => handleAccessListChange(row.id, value)}
|
||||
inheritHost
|
||||
/>
|
||||
{advVisible.includes(row.id) && (
|
||||
<div className="">
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
|
||||
padding={15}
|
||||
data-color-mode="dark"
|
||||
minHeight={170}
|
||||
indentWidth={2}
|
||||
value={item.advancedConfig}
|
||||
onChange={(e) => handleChange(row.id, "advancedConfig", e.target.value)}
|
||||
style={{
|
||||
fontFamily:
|
||||
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
borderRadius: "0.3rem",
|
||||
minHeight: "170px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ interface Props {
|
||||
requireDomainNames?: boolean; // used for streams
|
||||
color?: string;
|
||||
}
|
||||
export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
|
||||
export function SSLOptionsFields({
|
||||
forHttp = true,
|
||||
forProxyHost = false,
|
||||
forceDNSForNew,
|
||||
requireDomainNames,
|
||||
color = "bg-cyan",
|
||||
}: Props) {
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
const v: any = values || {};
|
||||
|
||||
@@ -116,10 +122,12 @@ export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDN
|
||||
</div>
|
||||
);
|
||||
|
||||
const getHttpAdvancedOptions = () =>(
|
||||
const getHttpAdvancedOptions = () => (
|
||||
<div>
|
||||
<details>
|
||||
<summary className="mb-1"><T id="domains.advanced" /></summary>
|
||||
<summary className="mb-1">
|
||||
<T id="domains.advanced" />
|
||||
</summary>
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<Field name="trustForwardedProto">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from "./Formatter";
|
||||
export * from "./features";
|
||||
export * from "./TableHeader";
|
||||
export * from "./TableHelpers";
|
||||
export * from "./TableLayout";
|
||||
|
||||
@@ -10,7 +10,10 @@ export * from "./useDeadHosts";
|
||||
export * from "./useDnsProviders";
|
||||
export * from "./useHealth";
|
||||
export * from "./useHostReport";
|
||||
export * from "./useLogSources";
|
||||
export * from "./useLogTail";
|
||||
export * from "./useProxyHost";
|
||||
export * from "./useProxyHostLogs";
|
||||
export * from "./useProxyHosts";
|
||||
export * from "./useRedirectionHost";
|
||||
export * from "./useRedirectionHosts";
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getProxyHostLogs } from "src/api/backend";
|
||||
|
||||
const useProxyHostLogs = (id: number, type: "access" | "error" = "access") => {
|
||||
return useQuery<{ logs: string }, Error>({
|
||||
queryKey: ["proxy-host-logs", id, type],
|
||||
queryFn: () => getProxyHostLogs(id, type),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
};
|
||||
|
||||
export { useProxyHostLogs };
|
||||
Executable → Regular
+102
-83
@@ -21,124 +21,143 @@ import langZh from "./lang/zh.json";
|
||||
import langTr from "./lang/tr.json";
|
||||
import langHu from "./lang/hu.json";
|
||||
import langNo from "./lang/no.json";
|
||||
import langUk from "./lang/uk.json";
|
||||
import langAz from "./lang/az.json";
|
||||
import langFa from "./lang/fa.json";
|
||||
import langList from "./lang/lang-list.json";
|
||||
|
||||
// first item of each array should be the language code,
|
||||
// not the country code
|
||||
// Remember when adding to this list, also update check-locales.js script
|
||||
const localeOptions = [
|
||||
["en", "en-US", langEn],
|
||||
["de", "de-DE", langDe],
|
||||
["es", "es-ES", langEs],
|
||||
["et", "et-EE", langEt],
|
||||
["pt", "pt-PT", langPt],
|
||||
["fr", "fr-FR", langFr],
|
||||
["ga", "ga-IE", langGa],
|
||||
["ja", "ja-JP", langJa],
|
||||
["it", "it-IT", langIt],
|
||||
["nl", "nl-NL", langNl],
|
||||
["pl", "pl-PL", langPl],
|
||||
["ru", "ru-RU", langRu],
|
||||
["sk", "sk-SK", langSk],
|
||||
["cs", "cs-CZ", langCs],
|
||||
["vi", "vi-VN", langVi],
|
||||
["zh", "zh-CN", langZh],
|
||||
["ko", "ko-KR", langKo],
|
||||
["bg", "bg-BG", langBg],
|
||||
["id", "id-ID", langId],
|
||||
["tr", "tr-TR", langTr],
|
||||
["hu", "hu-HU", langHu],
|
||||
["no", "no-NO", langNo],
|
||||
["en", "en-US", langEn],
|
||||
["de", "de-DE", langDe],
|
||||
["es", "es-ES", langEs],
|
||||
["et", "et-EE", langEt],
|
||||
["pt", "pt-PT", langPt],
|
||||
["fr", "fr-FR", langFr],
|
||||
["ga", "ga-IE", langGa],
|
||||
["ja", "ja-JP", langJa],
|
||||
["it", "it-IT", langIt],
|
||||
["nl", "nl-NL", langNl],
|
||||
["pl", "pl-PL", langPl],
|
||||
["ru", "ru-RU", langRu],
|
||||
["sk", "sk-SK", langSk],
|
||||
["cs", "cs-CZ", langCs],
|
||||
["vi", "vi-VN", langVi],
|
||||
["zh", "zh-CN", langZh],
|
||||
["ko", "ko-KR", langKo],
|
||||
["bg", "bg-BG", langBg],
|
||||
["id", "id-ID", langId],
|
||||
["tr", "tr-TR", langTr],
|
||||
["hu", "hu-HU", langHu],
|
||||
["no", "no-NO", langNo],
|
||||
["uk", "uk-UA", langUk],
|
||||
["az", "az-AZ", langAz],
|
||||
["fa", "fa-IR", langFa],
|
||||
];
|
||||
|
||||
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
|
||||
const thisLocale = (locale || "en").slice(0, 2);
|
||||
const thisLocale = (locale || "en").slice(0, 2);
|
||||
|
||||
// ensure this lang exists in localeOptions above, otherwise fallback to en
|
||||
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
|
||||
return Object.assign({}, langList, langEn);
|
||||
}
|
||||
// ensure this lang exists in localeOptions above, otherwise fallback to en
|
||||
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
|
||||
return Object.assign({}, langList, langEn);
|
||||
}
|
||||
|
||||
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
|
||||
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
|
||||
};
|
||||
|
||||
const getFlagCodeForLocale = (locale?: string) => {
|
||||
const thisLocale = (locale || "en").slice(0, 2);
|
||||
const thisLocale = (locale || "en").slice(0, 2);
|
||||
|
||||
// only add to this if your flag is different from the locale code
|
||||
const specialCases: Record<string, string> = {
|
||||
ja: "jp", // Japan
|
||||
zh: "cn", // China
|
||||
vi: "vn", // Vietnam
|
||||
ko: "kr", // Korea
|
||||
cs: "cz", // Czechia
|
||||
ga: "ie", // Ireland (Irish)
|
||||
};
|
||||
// only add to this if your flag is different from the locale code
|
||||
const specialCases: Record<string, string> = {
|
||||
ja: "jp", // Japan
|
||||
zh: "cn", // China
|
||||
vi: "vn", // Vietnam
|
||||
ko: "kr", // Korea
|
||||
cs: "cz", // Czechia
|
||||
ga: "ie", // Ireland (Irish)
|
||||
et: "ee", // Estonia (ISO 3166-1). "et" as a country code would be Ethiopia.
|
||||
uk: "ua", // Ukraine
|
||||
fa: "ir", // Iran (Persian)
|
||||
};
|
||||
|
||||
if (specialCases[thisLocale]) {
|
||||
return specialCases[thisLocale].toUpperCase();
|
||||
}
|
||||
return thisLocale.toUpperCase();
|
||||
if (specialCases[thisLocale]) {
|
||||
return specialCases[thisLocale].toUpperCase();
|
||||
}
|
||||
return thisLocale.toUpperCase();
|
||||
};
|
||||
|
||||
const rtlLocales = ["fa"];
|
||||
|
||||
const isRTLLocale = (locale?: string) => rtlLocales.includes((locale || "en").slice(0, 2));
|
||||
|
||||
const applyDocumentLocale = (locale: string): void => {
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = isRTLLocale(locale) ? "rtl" : "ltr";
|
||||
};
|
||||
|
||||
const getLocale = (short = false) => {
|
||||
let loc = window.localStorage.getItem("locale");
|
||||
if (!loc) {
|
||||
loc = document.documentElement.lang;
|
||||
}
|
||||
if (short) {
|
||||
return loc.slice(0, 2);
|
||||
}
|
||||
// finally, fallback
|
||||
if (!loc) {
|
||||
loc = "en";
|
||||
}
|
||||
return loc;
|
||||
let loc = window.localStorage.getItem("locale");
|
||||
if (!loc) {
|
||||
loc = document.documentElement.lang;
|
||||
}
|
||||
if (short) {
|
||||
return loc.slice(0, 2);
|
||||
}
|
||||
// finally, fallback
|
||||
if (!loc) {
|
||||
loc = "en";
|
||||
}
|
||||
return loc;
|
||||
};
|
||||
|
||||
const cache = createIntlCache();
|
||||
|
||||
const initialMessages = loadMessages(getLocale());
|
||||
applyDocumentLocale(getLocale());
|
||||
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
|
||||
|
||||
const changeLocale = (locale: string): void => {
|
||||
const messages = loadMessages(locale);
|
||||
intl = createIntl({ locale, messages }, cache);
|
||||
window.localStorage.setItem("locale", locale);
|
||||
document.documentElement.lang = locale;
|
||||
const messages = loadMessages(locale);
|
||||
intl = createIntl({ locale, messages }, cache);
|
||||
window.localStorage.setItem("locale", locale);
|
||||
applyDocumentLocale(locale);
|
||||
};
|
||||
|
||||
// This is a translation component that wraps the translation in a span with a data
|
||||
// attribute so devs can inspect the element to see the translation ID
|
||||
const T = ({
|
||||
id,
|
||||
data,
|
||||
tData,
|
||||
id,
|
||||
data,
|
||||
tData,
|
||||
}: {
|
||||
id: string;
|
||||
data?: Record<string, string | number | undefined>;
|
||||
tData?: Record<string, string>;
|
||||
id: string;
|
||||
data?: Record<string, string | number | undefined>;
|
||||
tData?: Record<string, string>;
|
||||
}) => {
|
||||
const translatedData: Record<string, string> = {};
|
||||
if (tData) {
|
||||
// iterate over tData and translate each value
|
||||
Object.entries(tData).forEach(([key, value]) => {
|
||||
translatedData[key] = intl.formatMessage({ id: value });
|
||||
});
|
||||
}
|
||||
return (
|
||||
<span data-translation-id={id}>
|
||||
{intl.formatMessage(
|
||||
{ id },
|
||||
{
|
||||
...data,
|
||||
...translatedData,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
const translatedData: Record<string, string> = {};
|
||||
if (tData) {
|
||||
// iterate over tData and translate each value
|
||||
Object.entries(tData).forEach(([key, value]) => {
|
||||
translatedData[key] = intl.formatMessage({ id: value });
|
||||
});
|
||||
}
|
||||
return (
|
||||
<span data-translation-id={id}>
|
||||
{intl.formatMessage(
|
||||
{ id },
|
||||
{
|
||||
...data,
|
||||
...translatedData,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
//console.log("L:", localeOptions);
|
||||
|
||||
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
|
||||
export { localeOptions, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user